X API Media Upload: Chunked Uploads, Processing, and Alt Text
Media upload on X is a different API from the post endpoint, with a different auth model. Covers the four chunked calls and their rate limits, why an OAuth 2.0 token that posts text returns 403 on a file, a polling loop that terminates, and three places X's own docs contradict themselves.
X API media upload is not part of the post endpoint. It is a separate API (a separate host on the legacy path, a separate OAuth scope on the v2 path), and attaching a photo or a video takes four calls before you touch POST /2/tweets at all: INIT, APPEND, FINALIZE, STATUS. This is why an access token that publishes text perfectly returns 403 the first time you send it a file.

Why media upload is not part of the post endpoint
Two paths exist right now, and they are not the same API.
The legacy path is https://upload.x.com/1.1/media/upload.json. Look at the host: upload.x.com, not api.x.com. One URL, four commands passed as a form field (INIT, APPEND, FINALIZE, STATUS), signed with OAuth 1.0a. This is what we run in production today.
The v2 path splits the same flow across real REST resources on api.x.com: /2/media/upload/initialize, /2/media/upload/{id}/append, /2/media/upload/{id}/finalize, and a GET on /2/media/upload?command=STATUS. Same four steps, different shape, and a different authorization requirement. That last one is what bites. X documents the chunked sequence in its media upload reference.
Do you have to rewrite? Not this quarter. As of this review the v1.1 upload host still works for us in production, and X has announced no sunset date for it. The migration overview says only that they "do intend to deprecate them eventually," which is a direction, not a deadline. Treat the v2 rewrite as planned work, not as an incident. The rest of this guide names both paths.
The four calls: INIT, APPEND, FINALIZE, STATUS
Every media endpoint takes the same scope; the per-user limits differ by an order of magnitude.
| Step | v2 method and path | Scope | Per user / 15 min | Returns |
|---|---|---|---|---|
| INIT | POST /2/media/upload/initialize | media.write | 1,875 | id, media_key, expires_after_secs |
| APPEND | POST /2/media/upload/{id}/append | media.write | 1,875 | expires_at |
| FINALIZE | POST /2/media/upload/{id}/finalize | media.write | 1,875 | size, optional processing_info |
| STATUS | GET /2/media/upload?command=STATUS | media.write | 1,000 | processing_info.state |
| One-shot | POST /2/media/upload | media.write | 500 | Images and subtitles only |
| Alt text | POST /2/media/metadata | media.write | 500 | associated_metadata |
| Subtitles | POST /2/media/subtitles | media.write | 100 | associated_subtitles |
Limits come from X's per-endpoint reference, which does not name the access tier, so check yours in the developer console. How these windows sit next to the post and read counters, and which of the three ends up binding, is the same question every platform poses: social media API rate limits.
APPEND is the bottleneck, not publishing. A 512 MB video cut into 5 MB chunks is over a hundred APPEND calls, roughly seventeen full-size videos per account per window. The loop we run:
const CHUNK_SIZE = 5_000_000; // binary bytes, before base64 let offset = 0; await streamUpload(file.url, CHUNK_SIZE, async (chunk) => { await client.v1.post( "media/upload.json", { command: "APPEND", media_id: mediaId, segment_index: offset / CHUNK_SIZE, media: chunk.toString("base64"), }, { prefix: "https://upload.x.com/1.1/" }, ); offset += chunk.length; });
segment_index is the byte offset divided by the chunk size: zero-based, incremented by one, and a gap fails the whole upload at FINALIZE, not at the chunk that caused it. Stream the source; do not load 512 MB into memory.
Two things the docs leave open. The schema caps segment_index at 999 (MediaSegments, maximum: 999), a schema constraint, not something we have watched X enforce. And the v2 docs never state a maximum chunk size; their quickstart uses 1 MB. We cut at 5,000,000 binary bytes and send them base64, so the body is about 6.67 MB. For a nominal 5 MB body, cut at 4 MB.
Which auth actually uploads a file
The OpenAPI security block on every media endpoint lists two alternatives:
security: - OAuth2UserToken: - media.write - UserToken: []
UserToken is OAuth 1.0a, which has no scopes at all: the token and secret cover whatever the app is permissioned for, so any scope string stored beside them is decoration. OAuth2UserToken is the PKCE flow, and for media it needs exactly one scope: media.write.
The set almost every X publishing tutorial hands you is tweet.read tweet.write users.read offline.access. That is also the string in our own OAuth service. media.write is not in it. tweet.write lets you create a post; it does not let you upload the bytes that go inside one. The failure mode is a working integration that 403s the moment someone attaches an image, with nothing in the error naming the missing scope.
Adding it is not a config change. media.write is granted on the consent screen, so every already-connected account has to authorize again. We cannot confirm from X's docs whether tokens issued before the scope existed are grandfathered; we run OAuth 1.0a and have no traffic to test it. Assume re-consent.
Waiting for processing without hanging the job
Images normally come back from FINALIZE with no processing_info block. A missing block means ready, not "not started yet". Treat it as pending and every image upload hangs. Video gets a real state machine: pending → in_progress → succeeded or failed.
X's quickstart shows the wait as while (true) { sleep(check_after_secs) }. Do not ship that. It has no exit condition: once X stops returning a terminal state, the job holds a worker slot forever.
let attempts = 0; let lastProgress: number | undefined; while (attempts < 8) { const pi = (await status(mediaId)).processing_info; if (!pi || pi.state === "succeeded") return; if (pi.state === "failed" || pi.error?.code) throw terminal(pi.error); // 422, do not retry const progressed = pi.progress_percent != null && (lastProgress == null || pi.progress_percent > lastProgress); lastProgress = pi.progress_percent; await wait(Math.max(2, Math.min(20, pi.check_after_secs ?? 5)) * 1000); if (!progressed) attempts += 1; } throw timeout(); // 504
Three differences from the quickstart, all of them taught by production.
check_after_secs is clamped to 2–20 seconds. It is a hint, not a contract. Zero spins the loop; a large value parks the job.
The attempt counter only increments when progress_percent did not move. A slow but progressing encode gets as long as it needs; a stuck one dies after eight idle checks.
FINALIZE can return state: "failed" in its own response, before you ever call STATUS. We turn that into a 422 immediately instead of feeding it into the loop as "pending": a file X has already rejected should not sit in a queue burning retries. The docs never mention that FINALIZE can already be terminal; that one is from our logs. Terminal-versus-retryable across platforms: social media API error handling.

Size, duration and count limits
Verified against X's best-practices page and our validator on 3 August 2026. The second column matters as much as the first: some rejections are X's, some your middleware's.
| Constraint | X's documented limit | Our validator |
|---|---|---|
| Media per post | 4 photos or 1 GIF or 1 video, no mixing | 4 items, mixing allowed |
| Image size | 5 MB | 5 MB |
| Animated GIF | 15 MB, ≤ 1280×1080, ≤ 350 frames | 5 MB, GIF is routed as an image |
| Video size | 512 MB (documented for amplify_video) | 512 MB |
| Video duration | 0.5–140 s, no tier distinction | 140 s free/basic, 600 s Premium |
| Video aspect ratio | 1:3 to 3:1 | 1:3 to 3:1 |
| Video container | INIT accepts mp4, webm, mp2t, quicktime | video/mp4 only |
| Video codec | H.264, YUV 4:2:0, AAC-LC | Not checked |
Two rows are us being stricter than X, one looser, and the duration row is a straight disagreement: X documents 0.5 to 140 seconds with no tiers at all. The cross-platform table: media requirements across platforms.
X's documentation contradicts itself in three load-bearing places.
One. The chunked-upload quickstart says to send command=INIT with media_type=video/mp4 to POST /2/media/upload. The schema for that endpoint allows text/srt, text/vtt, image/jpeg, image/bmp, image/png, image/webp, image/pjpeg, image/tiff. No video, no image/gif.
Two. INIT accepts total_bytes up to 17179869184, or 16 GiB. APPEND caps segment_index at 999, which at 5 MB per chunk is about 5 GB. Same specification.
Three. Best practices promises subscribers 1080p upload and playback, then nine lines later caps dimensions at "1280x1024."
bundle.social
One POST with the file. INIT, APPEND, FINALIZE and the polling are ours.
$0.015 per post, $0.200 for a post containing a link. No quote call, no minimum.

Alt text, media_category and the flags most integrations skip
Alt text is a separate call. POST /2/media/metadata, body { "id": "...", "metadata": { "alt_text": { "text": "..." } } }, maximum 1,000 characters, its own limit of 500 per user per 15 minutes. It goes after FINALIZE and before POST /2/tweets: the media id has to exist and the post must not. The spec lists UserToken: [] next to OAuth2UserToken: [media.write], so OAuth 1.0a should work. We do not call this endpoint in production, so that is the spec's claim, not our evidence.
media_category is optional the way a seatbelt is optional. X calls it inferred from the content type, and in the same paragraph something that "can affect file size limits or other constraints." The full enum: amplify_video, tweet_gif, tweet_image, tweet_video, dm_gif, dm_image, dm_video, subtitles. Omitting it costs you twice: the 512 MB ceiling is documented specifically for amplify_video, and larger animated GIFs need tweet_gif for asynchronous processing at all.
Two more INIT fields almost nobody sets: additional_owners, and shared for reusing an upload.
What this looks like as one call
With bundle.social you send one POST with the file. INIT, APPEND, the segment_index arithmetic, the clamped backoff, the terminal-failure path and the media id handoff all sit behind it. Our X API integration runs on OAuth 1.0a, which is why it has never needed media.write or a re-consent round, and also why it still depends on a v1.1 host whose lifetime we do not control.
Where we are not the right fit: we do not call POST /2/media/metadata, so alt text on X is not settable through us today, though it is on Instagram, Facebook and Pinterest. Our validator is also stricter than X on mp4-only and GIFs, and looser on mixing image with video. Know that before you pick. The publishing half of this flow is in posting a tweet with the X API; the rate card is in X API pricing and limits.
Frequently asked questions
Why does my X media upload fail when text posts work fine?
Because they are different APIs. Media goes either to https://upload.x.com/1.1/media/upload.json under OAuth 1.0a, or to the v2 /2/media/upload/* endpoints, which require media.write. The usual publishing scope set (tweet.read tweet.write users.read offline.access) omits it, so the token 403s on the file.
Which endpoints does the X API chunked upload use?
Four: /2/media/upload/initialize returns a media id, /2/media/upload/{id}/append takes each chunk with a zero-based segment_index, /2/media/upload/{id}/finalize closes the session, and a GET on /2/media/upload?command=STATUS reports processing_info.state for video.
Do I need the media.write scope?
On OAuth 2.0 with PKCE, yes, for every media endpoint, alt text and subtitles included. On OAuth 1.0a, no; that scheme has no scopes. Adding it sends every connected account back through the consent screen, so plan a re-connect campaign, not a config change.
How large can each APPEND chunk be?
X's v2 docs state no maximum and their quickstart uses 1 MB. We cut at 5,000,000 binary bytes and send them base64, so the body is around 6.67 MB. For an encoded body under a nominal 5 MB, cut at 4 MB. The schema caps segment_index at 999.
How do I add alt text to an image on the X API?
Call POST /2/media/metadata after FINALIZE and before creating the post, with metadata.alt_text.text up to 1,000 characters. It is a field on neither the upload nor the post, and it has its own limit of 500 calls per user per 15 minutes.