Spaces:
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
- Media upload introduction
- One-shot media upload
- Initialize media upload
- Append media upload
- Finalize media upload
- Get media upload status
- Chunked media upload
- Media best practices
- Delete Post
- X API access and 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
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:
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:
{
"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:
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=truerecords 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.