Spaces:
Running
Running
| # 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": "<x-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. | |