# X publishing (Phase 5B) Phase 5B publishes text, image, animated GIF, and video posts for connected X users through the existing MediaRouter social architecture. It uses only the official X API v2 and does not add an X-specific REST, MCP, SDK, n8n, token, scheduler, retry, or job architecture. The API contract was re-verified on 2026-08-01 against the official X documentation and live OpenAPI version `2.167`: - [Create or Edit Post](https://docs.x.com/x-api/posts/creation-of-a-post) - [Media upload introduction](https://docs.x.com/x-api/media/introduction) - [One-shot media upload](https://docs.x.com/x-api/media/upload-media) - [Initialize media upload](https://docs.x.com/x-api/media/initialize-media-upload) - [Append media upload](https://docs.x.com/x-api/media/append-media-upload) - [Finalize media upload](https://docs.x.com/x-api/media/finalize-media-upload) - [Get media upload status](https://docs.x.com/x-api/media/get-media-upload-status) - [Chunked media upload](https://docs.x.com/x-api/media/quickstart/media-upload-chunked) - [Media best practices](https://docs.x.com/x-api/media/quickstart/best-practices) - [Delete Post](https://docs.x.com/x-api/posts/post-delete-by-post-id) - [X API access and pricing](https://docs.x.com/x-api/getting-started/pricing) X API v2 is the current API. No scraping, unofficial endpoint, OAuth 1.0a fallback, or legacy v1.1 media endpoint is used. ## Architecture and credentials ```text frontend / REST / MCP / TypeScript SDK / Python SDK / n8n | SocialService | PublishingService / Scheduler | durable SocialJob | SocialPublisher | OAuthService -> TokenService | XProvider | official X API v2 ``` All requests remain under `/v1/social`. TokenService is the only credential reader. X access and refresh tokens are never serialized to REST, frontend, browser storage, MCP, n8n, either SDK, logs, exceptions, provider metadata, or audit records. Configure the backend only: ```dotenv X_CLIENT_ID= X_CLIENT_SECRET= X_REDIRECT_URI=https://api.example.com/v1/social/accounts/x/callback X_PUBLISHING_ENABLED=false X_UPLOAD_CHUNK_BYTES=5242880 X_REQUEST_TIMEOUT_SECONDS=60 X_MEDIA_PROCESSING_POLL_SECONDS=5 X_MEDIA_PROCESSING_TIMEOUT_SECONDS=300 ``` `X_CLIENT_SECRET` must never use a frontend-visible environment prefix. The redirect URI must exactly match the confidential Web App callback registered in the X Developer Console. `X_PUBLISHING_ENABLED` is a fail-closed operator assertion. Set it to `true` only after confirming that the X project currently has the paid access, credits, write permission, and v2 media endpoint entitlement required by the deployment. OAuth account discovery remains available while this setting is false, but publishing, scheduling, media, and deletion capabilities are not advertised. ## Authorization and access A normal Phase 5A connection still requests only `tweet.read`, `users.read`, and `offline.access`. Publishing scopes are not silently added. The user must choose **Enable publishing**, or call the common connect route with `authorization_purpose=publishing`, to explicitly request: | Scope | Use | |---|---| | `tweet.write` | Create and delete posts | | `media.write` | Upload images, animated GIFs, and videos | Existing read-only connections return `SOCIAL_PERMISSION_DENIED` until this explicit authorization is completed. The Developer Console project must also have the current paid access, credits, endpoint entitlement, and spending limits required by X. A media endpoint that is unavailable to the project's access tier returns `SOCIAL_CAPABILITY_UNSUPPORTED`; MediaRouter does not pretend the upload succeeded. Current X pricing and access tiers can change independently of a MediaRouter deployment. Confirm the Developer Console and official pricing page before production launch. ## Text and replies MediaRouter accepts a strict `x` metadata object. Unknown fields fail closed: ```json { "targets": [{ "social_account_id": "", "caption": {"text": "Release complete"}, "x": { "text": "Release complete", "reply": { "in_reply_to_tweet_id": "1890123456789012345", "auto_populate_reply_metadata": true } } }], "publish_mode": "draft" } ``` The supported contract is `text` plus the official reply fields shown above. IDs must be 1–19 ASCII digits. The conservative MediaRouter text limit is 280 Unicode code points; X remains authoritative for its weighted character and account-specific limits. A post may be text-only, media-only, or contain both, but it must contain text or a workspace-owned media asset. Quote posting is deliberately absent. The current Create Post documentation marks `quote_tweet_id` as Enterprise-only, so self-serve MediaRouter clients cannot submit it as an arbitrary provider payload. ## Images, animated GIFs, and video MediaRouter resolves only a workspace-owned `MediaAsset`/`MediaVariant`, re-probes the file at worker time, checks that its bytes have not changed, and validates provider compatibility before any X request. It does not silently modify media. Use the existing FFmpeg/template pipeline to create a compatible variant when validation fails. Supported contract: | Media | Local validation | Upload workflow | |---|---|---| | JPEG, PNG, WEBP | MIME/codec agreement, readable file, dimensions, unchanged size, at most 5 MB | `POST /2/media/upload` one-shot API | | Animated GIF | GIF MIME/codec, at most 15 MB, at most 1280×1080, at most 350 frames and 300 million total pixels when reported by FFprobe | initialize → chunk append → finalize → status | | MP4 video | MP4/H.264, 0.5–140 seconds, 32×32 through 1280×1024, 60 FPS maximum, 1:3–3:1 aspect ratio, YUV 4:2:0, square pixels, progressive scan, AAC-LC mono/stereo, at most 512 MB | initialize → chunk append → finalize → status | The current `SocialPostCreate` model references one primary media asset, so Phase 5B exposes one X attachment. X itself can accept up to four photos; that larger multi-asset contract is not advertised until MediaRouter's existing post-media model exposes an ordered multi-asset request safely. Small images are bounded before their base64 one-shot body is read. GIF and video files are never loaded wholly into memory. The worker reads configurable chunks, persists the next segment after each accepted append, heartbeats its job lease, finalizes once, and polls official processing status. Upload state is encrypted in `SocialJob.provider_state_encrypted` and omitted from all job views. ## Publishing lifecycle and status The shared worker uses the existing lifecycle: ```text QUEUED -> PREPARING -> PROCESSING -> UPLOADING -> PUBLISHING -> PUBLISHED | | +-> RETRYING <-+ | FAILED ``` After media processing completes, the adapter sends the typed body to `POST /2/tweets`, persists the returned post ID and public URL, then confirms it with `GET /2/tweets/{id}`. Status is normalized as `PUBLISHED`, `DELETED`, or `UNAVAILABLE`; provider-controlled metadata passes through the common credential redactor. Deletion uses `DELETE /2/tweets/{id}` only after tenant-scoped post, target, account, capability, and external-ID checks. A 200 response must contain `data.deleted=true`; 404 is treated as idempotent already-deleted success. ## Scheduling Phase 5B does not claim native X scheduling. `native_scheduling` is false. When `scheduled_publish` is available, MediaRouter stores the offset-aware input as canonical UTC while preserving the supplied IANA timezone. The existing scheduler queues the same X publishing job when due. ## Idempotency and crash recovery The existing `(workspace_id, Idempotency-Key)` post and job constraints ensure that repeated requests with the same payload create one logical post/job. The same key with a different payload returns `SOCIAL_IDEMPOTENCY_CONFLICT`. Retries reuse the same job and encrypted upload state. The X Create Post endpoint does not expose a provider idempotency key. Before creation, MediaRouter durably stores the publish start time and uploaded media identity. After an ambiguous timeout or a provider success followed by local database failure, recovery queries the authenticated user's official timeline from that time and matches exact text, reply reference, and media keys. It pages through bounded results and fails safely if it cannot exhaust the window; it does not blindly create a second post. Once found, the external ID is persisted and normal status reconciliation resumes. ## Retries and errors The shared retry service applies bounded backoff and jitter without creating a new logical job: | X result | MediaRouter result | Retry | |---|---|---| | 401 / invalid or expired token | `SOCIAL_REAUTH_REQUIRED`; one TokenService-managed refresh attempt | once after refresh | | 403 / insufficient scope | `SOCIAL_PERMISSION_DENIED` | no | | 404 from posting/media endpoint access tier | `SOCIAL_CAPABILITY_UNSUPPORTED` | no | | 429 | `SOCIAL_RATE_LIMITED` | yes, bounded | | 408, 500, 502, 503, 504, transport timeout | temporary provider failure | yes, bounded | | 400/409 invalid post metadata | `SOCIAL_PUBLISH_FAILED` | no | | 400/413/415/422 invalid media | `SOCIAL_MEDIA_INVALID` | no | Provider response details are not reflected into public errors because they may contain request fragments or credentials. ## Frontend, MCP, n8n, and SDKs The frontend discovers X accounts and capabilities from the backend. It supports text, optional media, optional reply ID, publish, MediaRouter scheduling, job status, reconnect, disconnect, and normalized errors. It does not assume X capabilities when the backend reports them unavailable. The existing MCP tools and MediaRouter Social n8n node call SocialService through the generic social API. The node supports JSON post creation, batches, Continue On Fail, scheduling, publishing, and idempotency without storing X credentials. TypeScript exports `XPostMetadata`/`XReplyMetadata`; Python exports matching `TypedDict` contracts. `client.social` remains canonical. ## Limitations - A configured project may still lack X media endpoint entitlement, credits, or sufficient access. `X_PUBLISHING_ENABLED=true` records an operator's verification; it cannot grant provider access or override X policy. - Phase 5B deliberately supports one attachment, no polls, no community posts, no direct-message deep links, no paid-partnership fields, no long posts, and no Enterprise quote posting. - X has no native provider idempotency key for Create Post. Recovery is built from the official user timeline. MediaRouter durably marks the submission before calling X and never resubmits an uncertain Create Post request; it retries bounded reconciliation and fails safe when it cannot prove the outcome. - Analytics certification is outside Phase 5B.