How-To Guide

How to Upload Media
with the X API v2

The complete chunked upload flow: INIT, APPEND, FINALIZE, STATUS polling for video, the OAuth gotcha that breaks v1.1 migrations, and working Python and Node code. Then the one-call alternative.

One bearer key • No X developer account • Free 7-day trial

What Changed in Media Upload

For a decade, posting an image to Twitter meant upload.twitter.com/1.1/media/upload.json with OAuth 1.0a. X deprecated that endpoint on March 31, 2025. The replacement lives at https://api.x.com/2/media/upload, keeps the same INIT, APPEND, FINALIZE command structure, but switches auth to OAuth 2.0 user context with a new media.write scope and changes the response shape. Code migrated line by line from v1.1 fails in two places at once.

The flow itself has real moving parts. You declare the file size and category up front, split the bytes into chunks of at most 5MB, upload each chunk with a sequence number, finalize, and, for video and GIFs, poll a status endpoint until X finishes server-side processing. Do it out of order, use the wrong category, or attach the media too early and the post fails even though every upload call returned 200.

This guide walks the full v2 flow with copy-paste curl, Python, and Node that handle image and video. At the end: the same result through OpenTweet in one request, with no X developer account, no OAuth scopes, and no chunking. Pick whichever fits your project.

Step-by-Step: X API v2 Media Upload

1

Get the auth right (the v1.1 migration gotcha)

The v2 endpoint at api.x.com/2/media/upload wants an OAuth 2.0 user context access token that includes the media.write scope, issued by an app attached to a Project in the X developer portal. This trips up almost everyone migrating from v1.1: the old upload.twitter.com/1.1/media/upload.json endpoint used OAuth 1.0a and was deprecated on March 31, 2025. An app-only bearer token will never work here, and a user token issued before you added media.write stays scopeless until the user re-authorizes. If /2/tweets works but /2/media/upload returns 403, this is why.

403 on upload, 200 on tweets?

That combination almost always means the token lacks the media.write scope. Scopes are fixed at authorization time, so adding media.write in the developer portal only affects tokens issued after the user goes through OAuth again. Getting set up from scratch? Start with how to get an X API key.
2

INIT the upload session

POST to https://api.x.com/2/media/upload with command=INIT, the media_type (like image/png or video/mp4), total_bytes of the full file, and a media_category. For organic posts the categories are tweet_image, tweet_video, and tweet_gif. The response returns the media id you use for every following call, plus an expires_after_secs window in which you must finish the upload and use the id.

bash
curl -X POST "https://api.x.com/2/media/upload" \
  -H "Authorization: Bearer $X_USER_ACCESS_TOKEN" \
  -F "command=INIT" \
  -F "media_type=video/mp4" \
  -F "total_bytes=10485760" \
  -F "media_category=tweet_video"
3

APPEND the file in chunks

Send the file bytes with command=APPEND, one request per chunk, each at most 5MB. Every request carries the media_id, a segment_index starting at 0, and the chunk itself in a multipart field named media. A small image fits in a single segment; a 100MB video is around 25 chunks at 4MB each. Chunks are independent, so a failed segment can be retried on its own without restarting the upload.

bash
curl -X POST "https://api.x.com/2/media/upload" \
  -H "Authorization: Bearer $X_USER_ACCESS_TOKEN" \
  -F "command=APPEND" \
  -F "media_id=1880028106020515840" \
  -F "segment_index=0" \
  -F "media=@chunk0.mp4"

One request per chunk

segment_index starts at 0 and increments per chunk. Each chunk is at most 5MB, sent in a multipart field named media. Repeat until the whole file is uploaded.
4

FINALIZE, then poll STATUS for video

POST command=FINALIZE with the media_id. Images usually complete synchronously. Videos and GIFs return a processing_info object with a state (pending, in_progress, succeeded, failed) and a check_after_secs hint. Poll GET /2/media/upload?command=STATUS&media_id=... at that interval until the state is succeeded. Attaching a media id before processing finishes is the classic cause of a failing tweet create right after a successful upload.

FINALIZE

bash
curl -X POST "https://api.x.com/2/media/upload" \
  -H "Authorization: Bearer $X_USER_ACCESS_TOKEN" \
  -F "command=FINALIZE" \
  -F "media_id=1880028106020515840"

STATUS (video and GIF only)

bash
curl "https://api.x.com/2/media/upload?command=STATUS&media_id=1880028106020515840" \
  -H "Authorization: Bearer $X_USER_ACCESS_TOKEN"
5

Attach the media_id to a post

Once processing has succeeded, create the post via POST https://api.x.com/2/tweets with a media object: {"media": {"media_ids": ["..."]}}. You can attach up to four images or one video per post. Note that on X API pay-per-use pricing, each published post bills your credits on top of the developer account you needed for all of the above.

bash
curl -X POST "https://api.x.com/2/tweets" \
  -H "Authorization: Bearer $X_USER_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "New demo video is up.",
    "media": { "media_ids": ["1880028106020515840"] }
  }'

On pay-per-use plans every published post bills your X API credits, and posts containing a link cost more. See X API pay-per-use explained for the current numbers.

Complete Working Code

One function that handles image and video: INIT, chunked APPEND, FINALIZE, and STATUS polling, then attaches the result to a post. Swap in your OAuth 2.0 user token.

Python

python
import os
import time
import requests

TOKEN = os.environ["X_USER_ACCESS_TOKEN"]  # OAuth 2.0 user token with media.write
BASE = "https://api.x.com/2/media/upload"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
CHUNK = 4 * 1024 * 1024  # stay under the 5MB per-request cap

def upload_media(path, media_type, media_category):
    total = os.path.getsize(path)

    init = requests.post(BASE, headers=HEADERS, data={
        "command": "INIT",
        "media_type": media_type,
        "total_bytes": total,
        "media_category": media_category,
    })
    init.raise_for_status()
    media_id = init.json()["data"]["id"]

    with open(path, "rb") as f:
        index = 0
        while chunk := f.read(CHUNK):
            requests.post(BASE, headers=HEADERS, data={
                "command": "APPEND",
                "media_id": media_id,
                "segment_index": index,
            }, files={"media": chunk}).raise_for_status()
            index += 1

    fin = requests.post(BASE, headers=HEADERS, data={
        "command": "FINALIZE",
        "media_id": media_id,
    })
    fin.raise_for_status()

    info = fin.json()["data"].get("processing_info")
    while info and info["state"] in ("pending", "in_progress"):
        time.sleep(info.get("check_after_secs", 5))
        status = requests.get(BASE, headers=HEADERS, params={
            "command": "STATUS",
            "media_id": media_id,
        })
        status.raise_for_status()
        info = status.json()["data"].get("processing_info")

    if info and info["state"] == "failed":
        raise RuntimeError(f"Media processing failed: {info}")
    return media_id

image_id = upload_media("chart.png", "image/png", "tweet_image")
video_id = upload_media("demo.mp4", "video/mp4", "tweet_video")

tweet = requests.post(
    "https://api.x.com/2/tweets",
    headers={**HEADERS, "Content-Type": "application/json"},
    json={"text": "New demo video is up.", "media": {"media_ids": [video_id]}},
)
print(tweet.json())

Node.js (18+)

javascript
import { readFile } from "node:fs/promises";

const TOKEN = process.env.X_USER_ACCESS_TOKEN; // OAuth 2.0 user token with media.write
const BASE = "https://api.x.com/2/media/upload";
const CHUNK = 4 * 1024 * 1024;

async function xUpload(form) {
  const res = await fetch(BASE, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}` },
    body: form,
  });
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  return res.json();
}

export async function uploadMedia(path, mediaType, mediaCategory) {
  const buffer = await readFile(path);

  const init = new FormData();
  init.append("command", "INIT");
  init.append("media_type", mediaType);
  init.append("total_bytes", String(buffer.length));
  init.append("media_category", mediaCategory);
  const { data } = await xUpload(init);
  const mediaId = data.id;

  for (let i = 0; i * CHUNK < buffer.length; i++) {
    const form = new FormData();
    form.append("command", "APPEND");
    form.append("media_id", mediaId);
    form.append("segment_index", String(i));
    form.append("media", new Blob([buffer.subarray(i * CHUNK, (i + 1) * CHUNK)]));
    await xUpload(form);
  }

  const fin = new FormData();
  fin.append("command", "FINALIZE");
  fin.append("media_id", mediaId);
  const finalized = await xUpload(fin);

  let info = finalized.data.processing_info;
  while (info && (info.state === "pending" || info.state === "in_progress")) {
    await new Promise((r) => setTimeout(r, (info.check_after_secs ?? 5) * 1000));
    const res = await fetch(`${BASE}?command=STATUS&media_id=${mediaId}`, {
      headers: { Authorization: `Bearer ${TOKEN}` },
    });
    info = (await res.json()).data.processing_info;
  }
  if (info && info.state === "failed") throw new Error("Media processing failed");
  return mediaId;
}

const videoId = await uploadMedia("demo.mp4", "video/mp4", "tweet_video");

await fetch("https://api.x.com/2/tweets", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    text: "New demo video is up.",
    media: { media_ids: [videoId] },
  }),
});

Images skip the polling

For a PNG or JPG, FINALIZE usually returns without processing_info, so the loop exits immediately and the media_id is ready to attach. Video and GIF always process asynchronously.

Media Upload at a Glance

Endpoint and commands

  • POST https://api.x.com/2/media/upload for INIT, APPEND, FINALIZE
  • GET the same URL with command=STATUS to poll processing
  • media_category: tweet_image, tweet_video, tweet_gif (amplify_video is for ads)
  • Attach via POST /2/tweets with media.media_ids

Limits and auth

  • APPEND chunks: 5MB max per request
  • OAuth 2.0 user context token with media.write scope
  • App must be attached to a Project in the developer portal
  • v2 response shape: data.id, not media_id_string

Pro Tips for Media Uploads

Chunk at 4MB, not 5MB

The APPEND limit is 5MB per request, but multipart encoding adds overhead. A 4MB chunk size keeps you safely under the cap and still uploads a 100MB video in around 25 requests. Retry individual segments on failure instead of restarting the whole session.

Respect check_after_secs

FINALIZE and STATUS responses tell you exactly when to poll next via processing_info.check_after_secs. Hammering STATUS in a tight loop wastes rate limit and, on pay-per-use, money. Sleep the suggested interval, then check again.

The v2 response shape changed

v1.1 returned media_id_string at the top level. v2 wraps everything in a data object and calls the field id. If your migrated code reads response["media_id_string"], it will find nothing. Read response["data"]["id"] instead.

Mind the expiry window

INIT returns expires_after_secs. The media_id is only valid for that window, both for finishing the upload and for attaching it to a post. If you upload media long before posting, check the window or upload closer to publish time.

Common Mistakes to Avoid

Using an app-only bearer token

The app-only token from your developer portal keys page cannot upload media. You need a user context OAuth 2.0 access token obtained through the authorization code flow with PKCE, with media.write among the granted scopes.

Adding media.write but not re-authorizing

Scopes are baked into the token at authorization time. Adding media.write to your app settings does nothing for tokens that already exist. Send the user through the OAuth flow again and the new token will carry the scope.

Skipping media_category on video

Uploading a video without media_category=tweet_video (or with the wrong category) leads to processing failures or media that cannot be attached to an organic post. Set the category in INIT, every time.

Posting before processing succeeds

A successful FINALIZE does not mean the video is ready. If processing_info is present, the media is not attachable until STATUS reports state succeeded. Attach too early and POST /2/tweets fails even though the upload looked fine.

The Same Thing in One Call

If you just want a tweet with an image or video, OpenTweet collapses INIT, APPEND, FINALIZE, and STATUS into a single multipart request. Upload the file, get back a URL, pass it as media_urls when you create the post. OpenTweet runs the chunked X flow for you at publish time. No X developer account, no OAuth scopes, no polling loop.

Upload

bash
curl -X POST https://opentweet.io/api/v1/upload \
  -H "Authorization: Bearer ot_your_key" \
  -F "file=@demo.mp4"

Response

json
{
  "url": "https://cdn.opentweet.io/media/demo.mp4",
  "type": "video",
  "content_type": "video/mp4",
  "size_bytes": 10485760,
  "filename": "demo.mp4"
}

Upload and post, Node.js

javascript
// 1. Upload once, get back a URL
const form = new FormData();
form.append("file", new Blob([await readFile("demo.mp4")], { type: "video/mp4" }), "demo.mp4");

const up = await fetch("https://opentweet.io/api/v1/upload", {
  method: "POST",
  headers: { "Authorization": `Bearer ${process.env.OPENTWEET_API_KEY}` },
  body: form,
});
const { url } = await up.json();

// 2. Attach it to a post via media_urls
await fetch("https://opentweet.io/api/v1/posts", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.OPENTWEET_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    text: "New demo video is up.",
    media_urls: [url],
    publish_now: true,
  }),
});

Works for threads and agents too

For threads, pass the returned URLs in thread_media, one array per tweet, as shown in post a thread from the API. AI agents get the same shortcut through the opentweet_upload_media MCP tool. Supported formats: JPG, PNG, GIF, WebP images up to 5MB, plus MP4 and MOV video. Full details in the API reference.

Frequently Asked Questions

How do I upload media with the X API v2?

Use the chunked flow at POST https://api.x.com/2/media/upload. Send command=INIT with media_type, total_bytes, and media_category to get a media id, then command=APPEND for each chunk of up to 5MB with a segment_index, then command=FINALIZE. Videos and GIFs then need STATUS polling until processing succeeds. Finally attach the media_id to a post via POST /2/tweets with a media.media_ids array.

What media_category value should I use?

For organic posts use tweet_image for images, tweet_video for video, and tweet_gif for animated GIFs. amplify_video exists for ads. Setting the wrong category, or omitting it for video, is a common cause of processing failures and of media that cannot be attached to a post.

Why does POST /2/media/upload return 403 when /2/tweets works?

Almost always the token is missing the media.write scope. Tokens issued before you added the scope do not gain it retroactively, so the user has to go through the OAuth 2.0 authorization again. The app must also be attached to a Project in the X developer portal. App-only bearer tokens never work here; media upload needs user context.

Do image uploads need the STATUS polling step?

Usually not. Images typically finish synchronously and FINALIZE returns without processing_info, so you can attach the media_id immediately. Videos and GIFs always go through async processing: poll GET /2/media/upload?command=STATUS&media_id=... every check_after_secs seconds until state is succeeded or failed.

Is the v1.1 media/upload.json endpoint still available?

No. X deprecated the v1.1 endpoint at upload.twitter.com/1.1/media/upload.json on March 31, 2025. The replacement is https://api.x.com/2/media/upload, which uses OAuth 2.0 user context with the media.write scope instead of OAuth 1.0a, and wraps its response in a data object with an id field instead of media_id_string.

Is there a simpler way to post a tweet with an image or video?

Yes. OpenTweet turns the whole INIT, APPEND, FINALIZE, STATUS dance into one request: POST https://opentweet.io/api/v1/upload with a multipart file field returns a URL, which you pass as media_urls when creating the post. One bearer key with the ot_ prefix, no X developer account, no OAuth scopes, no chunking, no polling. OpenTweet runs the chunked X flow for you at publish time.

Post Media Without the Chunked Flow

One upload call, one post call. No X developer account. Free 7-day trial.