You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Meta Developer App and Instagram Business Account are manually configured ✅
A staging environment with Azure Blob Storage is available
Context
IgSender implements the Instagram Graph API two-step flow (container creation + media publish),
but UploadImageToPublicUrl throws NotImplementedException.
Instagram is not included in any active slot in DefaultSlotProfileProvider and must be added only after the sender is verified in staging.
Known gaps identified by static analysis (June 2026)
Instagram Graph API only accepts JPEG; PNG/SVG/MPO/JPS are rejected
3
access_token sent in JSON body
🟠 High
Meta best practice: pass as query parameter, not in body
4
Raw API response logged on error
🟠 High
Response body may echo the token — violates "no sensitive token logging" rule
5
No rate-limit (429) handling
🟡 Medium
API enforces 50 posts/24h; Polly pipeline must handle retry-after
6
No container_status polling before publish
🟡 Medium
Required: verify container is FINISHED before calling /media_publish — implemented via XPosterContainerPollingFunction, not inline in IgSender
7
Token expiry not managed
🟡 Medium
✅ Resolved — a long-lived non-expiring token is already in production. No refresh flow needed. docs/integrations/SenderPlugins/setup-instagram.md must be updated to remove the reference to automated refresh tracked in this issue.
Architecture Constraints (from .net-architect)
IBlobStorageService must be injected via constructor — no direct BlobServiceClient instantiation inside IgSender.
Azure Blob configuration must be bound via IOptions registered in Program.cs, not read via Environment.GetEnvironmentVariable inline.
BlobServiceClient should be registered as a singleton in the DI container (use Azure.Storage.Blobs native DI extension or manual singleton registration).
Sender boundary must be preserved: IgSender calls IBlobStorageService.UploadAsync, it does not own storage lifecycle.
IgSender must not poll container_status inline — it saves state and returns. Polling is delegated to XPosterContainerPollingFunction.
Slot activation (DefaultSlotProfileProvider) is a separate, final step — gated on staging validation.
Two-Phase Async Architecture
Instagram publishing requires an asynchronous two-phase flow due to Meta's media processing pipeline. Implementing polling inline in IgSender.SendAsync would risk Function timeout and block the main timer trigger. The solution uses two independent Azure Functions within the same Function App.
Phase 1 — XFunction (existing, extended)
Timer fires → XFunction → IgSender.SendAsync()
├── Upload image → IBlobStorageService.UploadAsync() → SAS URL (30 min, read-only)
├── POST /media → Meta Graph API → creation_id
├── IContainerStateStore.SaveAsync(creation_id, blobName, Pending)
└── return true ← does NOT block, no timeout risk
Phase 2 — XPosterContainerPollingFunction (new)
Timer fires (~every 2 min) → XPosterContainerPollingFunction
├── IContainerStateStore.GetPendingAsync() → [creation_id, blobName]
└── For each pending:
GET /{creation_id}?fields=status_code → Meta
├── FINISHED → POST /media_publish → DeleteBlob → UpdateStatus(Published)
├── IN_PROGRESS → skip, next timer round
└── ERROR/EXPIRED → UpdateStatus(Failed) + log + DeleteBlob
Why not Azure Durable Functions
Durable Functions require a dedicated Storage Account for orchestration state (tables + queues), introduce non-linear pricing at scale, and add significant architectural complexity. For XPoster's current and near-term volume, the cost/benefit ratio is unfavorable. A second Timer Trigger is sufficient and fully consistent with the existing architecture pattern in XFunction.cs.
Cost impact of the new TimerTrigger (every 2 min)
Metric
Value
Executions/month
~21,900
Azure Functions free grant
1,000,000 exec + 400,000 GB-s/month
Total subscription executions (incl. XPosterFunction)
~22,620
% of free grant used
2.3%
Additional monthly cost
€ 0.00
Application Insights log volume
~11 MB/month (free grant: 5 GB)
No additional Azure cost is incurred. Log verbosity must be kept low: LogInformation only on actionable events (publish, fail); LogDebug for skip rounds.
Part 1 — Azure Blob Storage Setup
Create/reuse Azure Storage Account in the same resource group as the Function App.
Create container xposter-images with private access (no anonymous read).
(Recommended for production) Use Managed Identity + DefaultAzureCredential with Storage Blob Data Contributor role.
Add lifecycle rule to auto-delete blobs older than 1 day (safety net).
SAS URL policy:IBlobStorageService.UploadAsync must return a SAS URL with read-only permission and a validity of 30 minutes (start time set to UtcNow.AddMinutes(-5) to absorb clock skew between Azure and Meta servers). This is more secure than anonymous public access and fully compatible with Meta's media upload policy.
⚠️ Facebook Graph API (/photos endpoint) is stricter than Instagram on image format: only JPG, PNG, GIF, TIFF, HEIF, WebP are accepted. SVG and redirect URLs are rejected with error 1366046.
// XPoster.ContractspublicinterfaceIBlobStorageService{/// /// Uploads raw bytes to blob storage and returns a time-limited SAS URL/// suitable for use as Meta media_url (direct GET, no auth headers, no redirects)./// Task<Uri>UploadAsync(byte[]data,stringcontentType,CancellationTokencancellationToken=default);TaskDeleteAsync(stringblobName,CancellationTokencancellationToken=default);}
Initial implementation: InMemoryContainerStateStore in XPoster.Services. Suitable for staging and single-instance production (one post/day). Replace with Table Storage or Cosmos DB backing when multi-instance scale is required — no contract changes needed.
Centralises Meta HTTP calls shared between IgSender and XPosterContainerPollingFunction. Uses the named "Instagram"HttpClient already registered via AddHttpClients().
2.5 Implementation: BlobStorageService
Implement in XPoster.Services, reading config from IOptions.
Return a SAS URL with BlobSasPermissions.Read, expiry UtcNow.AddMinutes(30), start UtcNow.AddMinutes(-5).
Log upload URI only — never log connection string or credentials.
Follow the same pattern used for FacebookCredentials (see #224):
namespaceXPoster.Credentials;/// <summary>/// Typed credentials for the Instagram sender./// Property names must match Azure Key Vault secret names exactly./// Bound via AddAzureKeyVault Configuration Provider and injected as IOptions{InstagramCredentials}./// </summary>publicsealedclassInstagramCredentials{/// Section name used to bind this class from configuration.publicconststringSectionName="InstagramCredentials";/// Instagram Business Account numeric ID.publicstringInstagramAccountId{get;init;}=string.Empty;/// Long-lived Instagram Page Access Token (non-expiring).publicstringInstagramAccessToken{get;init;}=string.Empty;}
The Key Vault secret names follow the {SectionName}{PropertyName} convention:
usingMicrosoft.Extensions.Options;namespaceXPoster.Credentials;publicclassInstagramCredentialsValidator:IValidateOptions<InstagramCredentials>{publicValidateOptionsResultValidate(string?name,InstagramCredentialsoptions){if(string.IsNullOrWhiteSpace(options.InstagramAccountId))returnValidateOptionsResult.Fail("InstagramCredentials:InstagramAccountId is required.");if(string.IsNullOrWhiteSpace(options.InstagramAccessToken))returnValidateOptionsResult.Fail("InstagramCredentials:InstagramAccessToken is required.");returnValidateOptionsResult.Success;}}
// Azure Blob Storagebuilder.Services.Configure<BlobStorageOptions>(builder.Configuration);builder.Services.AddSingleton(sp =>newBlobServiceClient(builder.Configuration["AZURE_STORAGE_CONNECTION_STRING"]));builder.Services.AddTransient<IBlobStorageService,BlobStorageService>();// Instagram credentialsbuilder.Services.AddInstagramCredentials(builder.Configuration);// Instagram async statebuilder.Services.AddSingleton<IContainerStateStore,InMemoryContainerStateStore>();// Meta publishing service (shared HTTP calls)builder.Services.AddTransient<IMetaPublishingService,MetaPublishingService>();
Call only the extension method for credentials — never raw Configure(configuration.GetSection("...")) literals in Program.cs.
2.10 Update IgSender
Inject IBlobStorageService, IContainerStateStore, IMetaPublishingService, and IOptions<InstagramCredentials> via constructor.
Replace NotImplementedException in UploadImageToPublicUrl with await _blobStorageService.UploadAsync(image, "image/jpeg") returning the SAS Uri.
Validate JPEG format before upload: check magic bytes FF D8 and reject non-JPEG with LogWarning + return false.
Move access_token to query parameter: pass as ?access_token=... in the URL, never in JSON body.
Sanitize error logs: never log raw API response bodies; log only HTTP status code and a safe error summary.
Do NOT poll container_status inline: after POST /media succeeds, call IContainerStateStore.SaveAsync(creationId, blobName) and return true immediately.
Handle 429 rate-limit: ensure the Polly pipeline on the "Instagram" named client includes a retry policy respecting the Retry-After header from Meta.
2.11 New function: XPosterContainerPollingFunction
Schedule externalised via ContainerPollingSchedule app setting (default: "0 */2 * * * *" — every 2 minutes).
The function is sender-agnostic: it operates exclusively on IContainerStateStore and IMetaPublishingService, with no direct dependency on IgSender or any platform-specific sender.
Wrap in try/catch(OperationCanceledException) — log warning, do not rethrow (same pattern as XFunction.cs).
Unexpected exceptions → LogError + rethrow to surface in Azure Monitor.
The function also exposes an optional [HttpTrigger] overload (GET, anonymous, /api/container-polling) for manual invocation during staging and debugging. Disabled in production via app setting ContainerPollingHttpEnabled = false.
2.12 Enable Instagram slot in DefaultSlotProfileProvider (staging gate)
Add SenderPlatform.Instagram to the existing fan-out slot at hour 6. Uncomment only after staging validation:
Enable HTTP trigger for manual polling invocation (default: false)
InstagramAccessToken and FacebookAccessToken may share the same underlying token value, but must be stored as separate Key Vault secrets — one per sender plugin.
Update Step 6 and the Token Management table: remove the 60-day expiry warning and the reference to automated refresh tracked in this issue. Document that a long-lived non-expiring token is in use in production.
Add InstagramCredentials:InstagramAccessToken and InstagramCredentials:InstagramAccountId to the Key Vault — Required Secrets Instagram table (remove the not yet active warning once the slot is enabled).
Add AZURE_STORAGE_CONNECTION_STRING, AZURE_STORAGE_CONTAINER_NAME, ContainerPollingSchedule, ContainerPollingHttpEnabled as new configuration entries.
docs/architecture.md
Update the IgSender row in the Sender Plugins table: remove the placeholder note, reflect IBlobStorageService and IContainerStateStore dependencies.
Add IBlobStorageService / BlobStorageService to the Services Layer section.
Add IContainerStateStore / InMemoryContainerStateStore to the Services Layer section.
Add IMetaPublishingService / MetaPublishingService to the Services Layer section.
Add XPosterContainerPollingFunction to the Functions section with its trigger type and schedule.
src/local.settings.json.example
Add commented entries for AZURE_STORAGE_CONNECTION_STRING, AZURE_STORAGE_CONTAINER_NAME, ContainerPollingSchedule, ContainerPollingHttpEnabled under a new ── Azure Blob Storage & Instagram Polling ─── section header.
README.md
Update the sender status table: Instagram remains not yet active until slot is enabled after staging validation.
tests/README.md
Add BlobStorageServiceTests and XPosterContainerPollingFunctionTests to the test inventory.
CHANGELOG.md
Add an entry under the release that ships this feature: IBlobStorageService integration with SAS URL, JPEG validation, access_token query param fix, 429 handling, XPosterContainerPollingFunction async polling architecture, Instagram slot activation.
Acceptance Criteria
IBlobStorageService defined in XPoster.Contracts, returns SAS URL (not anonymous public URL)
IContainerStateStore defined in XPoster.Contracts, InMemoryContainerStateStore implemented in XPoster.Services
IMetaPublishingService defined in XPoster.Contracts, MetaPublishingService implemented in XPoster.Services
All three services registered in Program.cs via DI (no inline instantiation in sender or function)
src/Credentials/IgCredentials.cs renamed to src/Credentials/InstagramCredentials.cs
This issue is deferred. It will not be scheduled until:
Context
IgSenderimplements the Instagram Graph API two-step flow (container creation + media publish),but
UploadImageToPublicUrlthrowsNotImplementedException.Instagram is not included in any active slot in
DefaultSlotProfileProviderand must be added only after the sender is verified in staging.Known gaps identified by static analysis (June 2026)
UploadImageToPublicUrlthrowsNotImplementedExceptionSendAsynccall fails silentlyaccess_tokensent in JSON bodycontainer_statuspolling before publishFINISHEDbefore calling/media_publish— implemented viaXPosterContainerPollingFunction, not inline inIgSender7Token expiry not managed🟡 Mediumdocs/integrations/SenderPlugins/setup-instagram.mdmust be updated to remove the reference to automated refresh tracked in this issue.Architecture Constraints (from .net-architect)
IBlobStorageServicemust be injected via constructor — no directBlobServiceClientinstantiation insideIgSender.IOptionsregistered inProgram.cs, not read viaEnvironment.GetEnvironmentVariableinline.BlobServiceClientshould be registered as a singleton in the DI container (useAzure.Storage.Blobsnative DI extension or manual singleton registration).IgSendercallsIBlobStorageService.UploadAsync, it does not own storage lifecycle.IgSendermust not pollcontainer_statusinline — it saves state and returns. Polling is delegated toXPosterContainerPollingFunction.DefaultSlotProfileProvider) is a separate, final step — gated on staging validation.Two-Phase Async Architecture
Instagram publishing requires an asynchronous two-phase flow due to Meta's media processing pipeline. Implementing polling inline in
IgSender.SendAsyncwould risk Function timeout and block the main timer trigger. The solution uses two independent Azure Functions within the same Function App.Phase 1 —
XFunction(existing, extended)Phase 2 —
XPosterContainerPollingFunction(new)Why not Azure Durable Functions
Durable Functions require a dedicated Storage Account for orchestration state (tables + queues), introduce non-linear pricing at scale, and add significant architectural complexity. For XPoster's current and near-term volume, the cost/benefit ratio is unfavorable. A second Timer Trigger is sufficient and fully consistent with the existing architecture pattern in
XFunction.cs.Cost impact of the new TimerTrigger (every 2 min)
No additional Azure cost is incurred. Log verbosity must be kept low:
LogInformationonly on actionable events (publish, fail);LogDebugfor skip rounds.Part 1 — Azure Blob Storage Setup
xposter-imageswith private access (no anonymous read).AZURE_STORAGE_CONNECTION_STRING,AZURE_STORAGE_CONTAINER_NAME.DefaultAzureCredentialwithStorage Blob Data Contributorrole.Part 2 — Code Implementation
2.1 NuGet
2.2 New interface:
IBlobStorageService2.3 New interface:
IContainerStateStore2.4 New interface:
IMetaPublishingService2.5 Implementation:
BlobStorageServiceImplement in
XPoster.Services, reading config fromIOptions.Return a SAS URL with
BlobSasPermissions.Read, expiryUtcNow.AddMinutes(30), startUtcNow.AddMinutes(-5).Log upload URI only — never log connection string or credentials.
2.6 Rename
src/Credentials/IgCredentials.cs→src/Credentials/InstagramCredentials.csFollow the same pattern used for
FacebookCredentials(see #224):2.7 Create
src/Credentials/InstagramCredentialsValidator.cs2.8 Create
src/Credentials/InstagramCredentialsExtensions.cs2.9 Register in
Program.cs2.10 Update
IgSenderIBlobStorageService,IContainerStateStore,IMetaPublishingService, andIOptions<InstagramCredentials>via constructor.NotImplementedExceptioninUploadImageToPublicUrlwithawait _blobStorageService.UploadAsync(image, "image/jpeg")returning the SASUri.FF D8and reject non-JPEG withLogWarning+return false.access_tokento query parameter: pass as?access_token=...in the URL, never in JSON body.container_statusinline: afterPOST /mediasucceeds, callIContainerStateStore.SaveAsync(creationId, blobName)and returntrueimmediately."Instagram"named client includes a retry policy respecting theRetry-Afterheader from Meta.2.11 New function:
XPosterContainerPollingFunctionContainerPollingScheduleapp setting (default:"0 */2 * * * *"— every 2 minutes).IContainerStateStoreandIMetaPublishingService, with no direct dependency onIgSenderor any platform-specific sender.IContainerStateStore.GetPendingAsync().PendingContainer:IMetaPublishingService.GetContainerStatusAsync(creationId).FINISHED→PublishContainerAsync→IBlobStorageService.DeleteAsync(blobName)→UpdateStatusAsync(Published).IN_PROGRESS→LogDebug+ skip (no state change).ERROR/EXPIRED→LogWarning→DeleteAsync(blobName)→UpdateStatusAsync(Failed).try/catch(OperationCanceledException)— log warning, do not rethrow (same pattern asXFunction.cs).LogError+ rethrow to surface in Azure Monitor.2.12 Enable Instagram slot in
DefaultSlotProfileProvider(staging gate)Add
SenderPlatform.Instagramto the existing fan-out slot at hour 6. Uncomment only after staging validation:Part 3 — Configuration
Update
src/local.settings.json.exampleanddocs/configuration.md:InstagramCredentialsInstagramAccessTokenInstagramCredentialsInstagramAccountIdAZURE_STORAGE_CONNECTION_STRINGAZURE_STORAGE_CONTAINER_NAMExposter-images)ContainerPollingSchedule0 */2 * * * *)ContainerPollingHttpEnabledfalse)Part 4 — Tests
tests/SenderPlugins/IgSenderTests.cs(extend existing)SendAsync_WhenImageIsNull_LogsWarningAndReturnsFalseSendAsync_WhenImageIsNotJpeg_LogsWarningAndReturnsFalseSendAsync_WhenBlobUploadSucceeds_CreatesMediaContainerWithCorrectSasUrlSendAsync_WhenBlobUploadSucceeds_AccessTokenIsNotInRequestBodySendAsync_WhenBlobUploadSucceeds_SavesCreationIdToStateStoreSendAsync_WhenMediaContainerFails_ReturnsFalseSendAsync_WhenCaptionExceedsLimit_TruncatesCaptionSendAsync_WhenApiReturns429_DoesNotLogRawResponseBodySendAsync_DoesNotPollContainerStatus_DelegatesStateToStoretests/Services/BlobStorageServiceTests.cs(new)UploadAsync_WhenBlobClientSucceeds_ReturnsSasUriUploadAsync_WhenContainerDoesNotExist_CreatesItAndUploadsUploadAsync_WhenStorageThrows_PropagatesExceptionDeleteAsync_WhenBlobExists_DeletesSuccessfullytests/Functions/XPosterContainerPollingFunctionTests.cs(new)RunAsync_WhenNoPendingContainers_DoesNothingGetPendingAsyncreturns empty list — no Meta calls, no blob deleteRunAsync_WhenStatusIsInProgress_SkipsContainerIN_PROGRESS— no publish, no state changeRunAsync_WhenStatusIsFinished_PublishesAndCleansUpFINISHED→ publish + blob delete +PublishedRunAsync_WhenStatusIsError_MarksFailedAndCleansUpERROR→ blob delete +Failed+ log WarningRunAsync_WhenStatusIsExpired_MarksFailedAndCleansUpEXPIRED→ same as ERRORRunAsync_WhenPublishFails_MarksFailedAndCleansUpmedia_publishreturns error →Failed+ log ErrorRunAsync_WhenBlobDeleteFails_StillUpdatesStatusDeleteAsyncthrows → state updated anyway; exception logged, not rethrownRunAsync_WhenMultiplePendingContainers_ProcessesAllRunAsync_WhenCancelled_StopsGracefullyCancellationTokencancelled →OperationCanceledExceptionnot rethrown, log WarningPart 5 — Documentation Updates
All documentation changes must be included in the same PR as the code implementation.
docs/integrations/SenderPlugins/setup-instagram.mddocs/configuration.mdInstagramCredentials:InstagramAccessTokenandInstagramCredentials:InstagramAccountIdto the Key Vault — Required Secrets Instagram table (remove thenot yet activewarning once the slot is enabled).AZURE_STORAGE_CONNECTION_STRING,AZURE_STORAGE_CONTAINER_NAME,ContainerPollingSchedule,ContainerPollingHttpEnabledas new configuration entries.docs/architecture.mdIgSenderrow in the Sender Plugins table: remove the placeholder note, reflectIBlobStorageServiceandIContainerStateStoredependencies.IBlobStorageService/BlobStorageServiceto the Services Layer section.IContainerStateStore/InMemoryContainerStateStoreto the Services Layer section.IMetaPublishingService/MetaPublishingServiceto the Services Layer section.XPosterContainerPollingFunctionto the Functions section with its trigger type and schedule.src/local.settings.json.exampleAZURE_STORAGE_CONNECTION_STRING,AZURE_STORAGE_CONTAINER_NAME,ContainerPollingSchedule,ContainerPollingHttpEnabledunder a new── Azure Blob Storage & Instagram Polling ───section header.README.mdnot yet activeuntil slot is enabled after staging validation.tests/README.mdBlobStorageServiceTestsandXPosterContainerPollingFunctionTeststo the test inventory.CHANGELOG.mdIBlobStorageServiceintegration with SAS URL, JPEG validation,access_tokenquery param fix, 429 handling,XPosterContainerPollingFunctionasync polling architecture, Instagram slot activation.Acceptance Criteria
IBlobStorageServicedefined inXPoster.Contracts, returns SAS URL (not anonymous public URL)IContainerStateStoredefined inXPoster.Contracts,InMemoryContainerStateStoreimplemented inXPoster.ServicesIMetaPublishingServicedefined inXPoster.Contracts,MetaPublishingServiceimplemented inXPoster.ServicesProgram.csvia DI (no inline instantiation in sender or function)src/Credentials/IgCredentials.csrenamed tosrc/Credentials/InstagramCredentials.csInstagramCredentials.SectionName = "InstagramCredentials"InstagramAccountIdandInstagramAccessTokenInstagramCredentialsValidatorimplemented insrc/Credentials/InstagramCredentialsValidator.csInstagramCredentialsExtensions.AddInstagramCredentials()implemented insrc/Credentials/InstagramCredentialsExtensions.csbuilder.Services.AddInstagramCredentials(builder.Configuration)called inProgram.csIgSenderupdated to injectIOptions<InstagramCredentials>— no inline credential readsIgSender.UploadImageToPublicUrlcallsIBlobStorageService.UploadAsyncand returns SASUriIgSender.SendAsyncsavescreation_idtoIContainerStateStoreand returns immediately — no inline pollingNotImplementedExceptionat runtimeFF D8check)access_tokenpassed as query parameter, never in JSON body"Instagram"client handles 429 withRetry-AfterXPosterContainerPollingFunctionimplemented withTimerTrigger("%ContainerPollingSchedule%")XPosterContainerPollingFunctionis sender-agnostic (no direct dependency onIgSender)FINISHED,IN_PROGRESS,ERROR,EXPIREDexplicitlyAZURE_STORAGE_*,ContainerPollingSchedule,ContainerPollingHttpEnableddocumented indocs/configuration.mdandlocal.settings.json.examplesetup-instagram.mdupdated: token refresh warning and Instagram production readiness — IBlobStorageService + IgSender activation #72 reference removedSenderPlatform.Instagramadded to the hour-6 slot inDefaultSlotProfileProviderafter staging validationBlocked By
Related
docs/integrations/SenderPlugins/setup-instagram.md— full manual setup walkthrough (Facebook Login flow)FacebookAccessTokenKey Vault secret)