API GuidesAugust 9, 202610 min readingMarcel Czuryszkiewicz

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.

Reels of 8 mm film lying on a white surface with loose strips of film curled beside them, one clear strip carrying the word PLUTO in reversed lettering
One file, many segments: the shape of a chunked upload.

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.

Stepv2 method and pathScopePer user / 15 minReturns
INITPOST /2/media/upload/initializemedia.write1,875id, media_key, expires_after_secs
APPENDPOST /2/media/upload/{id}/appendmedia.write1,875expires_at
FINALIZEPOST /2/media/upload/{id}/finalizemedia.write1,875size, optional processing_info
STATUSGET /2/media/upload?command=STATUSmedia.write1,000processing_info.state
One-shotPOST /2/media/uploadmedia.write500Images and subtitles only
Alt textPOST /2/media/metadatamedia.write500associated_metadata
SubtitlesPOST /2/media/subtitlesmedia.write100associated_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: pendingin_progresssucceeded 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.

An automated warehouse sorting hall seen from a gantry, cardboard boxes and plastic totes riding several levels of roller conveyors, a green and amber stack light standing on the near line
Segments ride the belt. The light at the far end is your STATUS call.

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.

ConstraintX's documented limitOur validator
Media per post4 photos or 1 GIF or 1 video, no mixing4 items, mixing allowed
Image size5 MB5 MB
Animated GIF15 MB, ≤ 1280×1080, ≤ 350 frames5 MB, GIF is routed as an image
Video size512 MB (documented for amplify_video)512 MB
Video duration0.5–140 s, no tier distinction140 s free/basic, 600 s Premium
Video aspect ratio1:3 to 3:11:3 to 3:1
Video containerINIT accepts mp4, webm, mp2t, quicktimevideo/mp4 only
Video codecH.264, YUV 4:2:0, AAC-LCNot 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.

Pages torn from old printing and drawing manuals fanned out across a table, every one of them dense with printed text and section headings
Alt text is a second call, not a field on the upload.

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.

Marcel Czuryszkiewicz
Written by

Marcel Czuryszkiewicz

Co-Founder

Marcel is one of the builders behind bundle.social, creating a social media API with a strong focus on developer experience, reliability, and real support. Before bundle.social, he worked at Samsung R&D, Reply AI, and Docplanner, building internal tools that helped companies grow. He brought that same practical, product-focused mindset to bundle.social, so you can trust that your project is in good hands.

Keep reading