X API media upload: the v2 flow that actually works
X API media upload in v2: initialize, append, finalize, status. The real size limits, what it costs under pay-per-use, and the 403 after a clean upload.
On this page · 8 sections
The short version
- ▸Videos and large files take four calls, not one: POST /2/media/upload/initialize, POST /2/media/upload/:id/append per chunk, POST /2/media/upload/:id/finalize, then GET /2/media/upload?command=STATUS until processing succeeds. Images and subtitle files can still go in one shot to POST /2/media/upload.
- ▸The command=INIT / APPEND / FINALIZE query parameter is gone. X replaced it with dedicated paths on 30 April 2025 and sunset the v1.1 upload endpoints on 9 June 2025, so most sample code still on the internet now returns 400 or 404.
- ▸Size and duration limits follow the authenticated user's X Premium status and the media_category you declare, not your developer plan. Default accounts post video up to 20 minutes and 8 GB; Premium and verified accounts get 125 minutes and 16 GB.
- ▸Upload and posting are enforced separately. A media_id that finalized cleanly can still fail at POST /2/tweets with 403 'This user is not allowed to post a video longer than N minutes', so handle that error after a successful finalize.
- ▸The upload calls themselves do not appear on either X API pay-per-use billing table as of 13 October 2026. The Post that carries the media costs $0.015, or $0.200 if it contains a URL, and attaching alt text through Media Metadata costs $0.005.
Quick answer
X API media upload needs four calls for anything larger than an image: POST /2/media/upload/initialize to open a session and get a media_id, POST /2/media/upload/:id/append once per chunk, POST /2/media/upload/:id/finalize to close it, then GET /2/media/upload?command=STATUS until processing reports succeeded. Images and subtitle files can still go in a single shot to POST /2/media/upload. The scope is media.write, the media_id lives for 24 hours, and the bytes themselves are not billed.
Last updated: October 2026
The hard part is not the four calls. It is that the protocol changed in 2025, so most of the sample code you will find does something X no longer accepts, and that upload limits and posting limits are two separate gates with the same video failing at the second one.
The four calls
Open the session with a JSON body. Not form fields, not query parameters.
curl -X POST "https://api.x.com/2/media/upload/initialize" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"media_type":"video/mp4","total_bytes":1048576,"media_category":"tweet_video"}'
You get back an id, a media_key, and expires_after_secs: 86400. That last field is the one worth writing down: 24 hours from initialize to attachment, which is generous for a publish flow and tight for a scheduling queue that pre-uploads a week of video.
Chunks go up one at a time, as multipart, indexed from zero:
curl -X POST "https://api.x.com/2/media/upload/1880028106020515840/append" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-F "segment_index=0" \
-F "media=@/path/to/chunk0.mp4"
X recommends keeping each segment at or below 5 MB against a server maximum of 8 MB. Finalize takes no body at all. If the finalize response carries a processing_info object, you are not done: poll the status endpoint and respect the check_after_secs it hands you, walking the state machine from pending through in_progress to succeeded or failed. No processing_info means the media is ready immediately, which is the normal case for stills.
Then the media_id goes into the Post:
curl -X POST "https://api.x.com/2/tweets" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"text":"Check out this video","media":{"media_ids":["1880028106020515840"]}}'
One Post takes up to 4 photos, 1 animated GIF, or 1 video. Not a mix.
The command parameter is dead, and that is why your code broke
X shipped v2 media upload on 17 January 2025 as a single endpoint that took command=INIT, command=APPEND and command=FINALIZE as query parameters, the same shape the old v1.1 endpoint used. That lasted about three months. On 30 April 2025 X replaced the command parameter with the three dedicated paths and gave developers until 30 May 2025 to migrate, then sunset the v1.1 upload endpoints on 9 June 2025.
In practice, POST /2/media/upload still exists, but only for one-shot image and subtitle uploads. Send it command=INIT with total_bytes and media_category today and you get a 400 that reads like nonsense, one error object per parameter, each saying the query parameter "is not one of []". That exact response is all over X's developer forum from the end of May 2025, and X staff answered it the same way every time: migrate to the dedicated endpoints.
Two more artifacts from that migration still cost people time. The append documentation listed segment_index with a required range starting at 1, while the server requires it to start at 0; a developer caught that on 2 May 2025 and the docs were corrected. And a 403 at the initialize step, from a token that posts text perfectly well, is almost always a missing media.write scope rather than a broken token, which is how one developer resolved it on 28 July 2025 after a day of debugging. If you are still deciding which token type to hand this flow, X API OAuth has the scope table and the endpoint-by-method split.
Limits follow the user, not your plan
This is the single most misread part of media upload. Your developer tier does not decide how long a video you can push. The uploading account's X Premium or verified status does, together with the media_category you declared at initialize.
| Media | Category | Default account | Premium or verified |
|---|---|---|---|
| Image | tweet_image, dm_image | 5 MB | 5 MB |
| Animated GIF | tweet_gif, dm_gif | 15 MB | 15 MB |
| Video on a Post | tweet_video, amplify_video | 20 min, 8 GB | 125 min, 16 GB |
| Video in a DM | dm_video | 140 sec, 512 MB | 10 min, 1 GB |
| Subtitle file | subtitles | 1 MB | 1 MB |
Minimum duration is 0.5 seconds everywhere. Images accept JPG, PNG, GIF and WEBP. Those Post-video ceilings are the same ones the X app enforces, which is why they match what X video length describes for ordinary users.
Declaring the category correctly matters more than it looks. If you omit it, X infers a Post category from the content type, which is usually right. If you declare a DM category and then attach the result to a Post, the upload succeeds and the Post create fails, and nothing in the error tells you the category was the problem. Use tweet_video for regular Posts and amplify_video for Ads creatives; for Post video the two carry identical caps.
GIFs have their own failure mode, where a file inside the 15 MB limit still dies at Post creation. X publishes the real constraints: no more than 1280x1080, no more than 350 frames, and width times height times frame count no greater than 300 million pixels. Large GIFs need the chunked flow with media_category=tweet_gif so the server can process them asynchronously.
For video encoding, the recommendations that matter are H264 High Profile, AAC LC audio, mono or stereo only, progressive scan, no open GOP, YUV 4:2:0 pixel format, a 1:1 pixel aspect ratio, 60 FPS or less, and an aspect ratio between 1:3 and 3:1.
The 403 that arrives after a clean upload
Upload limits and Post-create limits are enforced separately. X says so twice in its own docs, because it surprises everyone. A media_id that finalized with state succeeded can still be rejected when you attach it:
{
"title": "Forbidden",
"detail": "This user is not allowed to post a video longer than 20 minutes.",
"type": "about:blank",
"status": 403
}
That N is the posting user's cap, 20 minutes by default and 125 for Premium. The initialize endpoint happily accepts a total_bytes up to 16 GB regardless of who is authenticated, so a tool that uploads on behalf of many accounts will absolutely hit this: the same 40-minute video succeeds for your Premium beta tester and fails for everyone else, after you already burned the bandwidth. Check the author's entitlement before you start the upload, not after.
What it costs, and what counts against rate limits
Under pay-per-use, media upload is one of the few things that is effectively free. Initialize, append, finalize and status appear on neither the read nor the write pricing table as of 13 October 2026. What you pay for is the entity:
| Action | Price |
|---|---|
| Post: Create | $0.015 per request |
| Post: Create (with URL) | $0.200 per request |
| Media Metadata | $0.005 per request |
A Post containing a link costs more than thirteen times a Post without one, which is its own design constraint if you publish video with a call to action. The rest of the per-request and per-resource table is in X API pricing.
Rate limits are where chunked upload actually bites, because they count calls and not files. Initialize, append and finalize each allow 1,875 requests per 15 minutes and 180,000 per 24 hours. A 1 GB video split into 4 MB chunks is 256 append calls, so that 15-minute window holds roughly seven such uploads from one app. Push the chunk size to the 8 MB server maximum and you halve the call count, at the cost of a more expensive retry when a chunk fails. The one-shot POST /2/media/upload runs on a tighter 500 per 15 minutes, the status GET allows 1,000, metadata 500, and subtitles only 100.
Alt text, subtitles, and per-media analytics
Three endpoints hang off a finalized media_id, and all three are easy to miss.
POST /2/media/metadata is where accessibility and distribution controls live. metadata.alt_text.text takes up to 1,000 characters, which is far more than the app's own field offers, and the same call carries sensitive_media_warning, allow_download_status, content_expiration with a timestamp_sec, plus geo and domain restrictions. Alt text is the only part of this that cannot be changed later, exactly as it works for alt text on X in the app.
POST /2/media/subtitles attaches a caption track, and it has a genuine trap. A subtitle file is itself uploaded as media with media_category=subtitles, capped at 1 MB, and the resulting id is what you attach. The language_code field is described as BCP47 but validated against a two-uppercase-letter pattern, so EN passes and en or en-US do not.
GET /2/media/analytics reports per-asset playback: video_views, watch_time_ms, the playback_start through playback_complete quartiles, and cta_url_clicks. It takes up to 100 media_keys at once, requires start_time and end_time, and accepts hourly, daily or total granularity. Note it keys on media_key, the 13_1880028106020515840 form, not the bare media_id, and it only needs tweet.read.
One more thing before you ship it
Media upload needs a write scope, and since 14 September 2026 any pay-per-use app requesting a write scope shows the user a "Sensitive permissions requested" notice, a statement that your app is not affiliated with X, and a mandatory "I trust this app" checkbox before the Authorize button works. X's own forum post says there is no way to remove it. Build your authorization funnel expecting that friction rather than treating it as a misconfiguration.
Setup is otherwise ordinary: an app, a user-context token with media.write, and credits in the console. If you have not done that part yet, how to get an X API key covers it.
What the API will not do is make automated engagement cheap or sanctioned. Posting, replying and following through code sit under X's automation rules, which require an accountable operator and the automated account label on accounts posting without a human, and the per-Post price means a high-volume reply habit meters fast. Plenty of growth work therefore never touches a token at all: replying in your own voice, from your own browser session, on your own machine, carries no API bill and a smaller detection surface than a cloud tool calling in from a shared IP pool, though a browser-driven workflow is a gray area under those same rules rather than something anyone should call compliant. That is the trade X-Autopilot makes.
For media, though, the API is the only route. Get the four calls right, declare the category honestly, check the author's entitlement before you upload, and the rest is bandwidth.
Frequently asked
Answers indexed by Google + AI assistants.
How do I upload a video with the X API?+
Use the four-step v2 chunked flow. POST to /2/media/upload/initialize with a JSON body containing media_type, total_bytes and media_category to get a media_id. POST each chunk to /2/media/upload/:id/append as multipart with segment_index starting at 0. POST to /2/media/upload/:id/finalize. If the finalize response contains processing_info, poll GET /2/media/upload?command=STATUS&media_id=... until the state is succeeded, then attach the media_id to POST /2/tweets.
Why does POST /2/media/upload return 400 or 404 now?+
Because you are sending the old command-style parameters. X deprecated command=INIT, command=APPEND and command=FINALIZE on that path and replaced them with dedicated endpoints on 30 April 2025. The single /2/media/upload path still works, but only for one-shot image and subtitle uploads. command=STATUS survives on the status GET and nowhere else.
What scope do I need for X API media upload?+
media.write, on a user-context token. A 403 at the initialize step with a token that posts text fine is almost always a missing media.write scope rather than a bad token. Since 14 September 2026, asking for any write scope on a pay-per-use app also adds a 'Sensitive permissions requested' notice and an 'I trust this app' checkbox to your consent screen.
How big a video can I upload through the X API?+
It depends on the uploading account, not your API plan. A default account can upload and post video up to 20 minutes and 8 GB with media_category tweet_video or amplify_video. An X Premium or verified account gets 125 minutes and 16 GB. DM video is capped at 140 seconds and 512 MB by default, or 10 minutes and 1 GB on Premium. Minimum duration is 0.5 seconds in every case.
Does media upload cost X API credits?+
Upload, append, finalize and status do not appear on the published read or write pricing tables as of 13 October 2026, so the bytes themselves are not metered. What you pay for is the entity you attach them to: $0.015 per Post created, $0.200 if the Post contains a URL, and $0.005 per Media Metadata request if you set alt text.
How long does a media_id stay valid?+
The initialize response returns expires_after_secs of 86400, so you have 24 hours between finalizing an upload and attaching the media_id to something. Pre-uploading media for a scheduled queue is fine inside that window and breaks outside it.
- X developer docs - 'Media upload endpoints on the X API v2': the per-category size and duration table, the separate enforcement of upload versus Post-create limits, and the note that total_bytes is accepted up to 16 GB (accessed 13 October 2026)
- X developer docs - 'Chunked Media Upload': the four-step v2 flow, the 5 MB recommended chunk size against an 8 MB server maximum, the processing_info state machine, and the explicit warning not to send command-style parameters (accessed 13 October 2026)
- X Developers forum - 'Media Upload Endpoints Update and Extended Migration Deadline' by @taycaldwell: the dedicated initialize/append/finalize paths, the deprecation of the command query parameter, the 30 May 2025 migration deadline and the 9 June 2025 v1.1 sunset (posted 30 April 2025)
- X Developers forum - 'New Dedicated Endpoints for Chunked Media Upload Broken': the thread where developers hit 500s on the new append endpoint the day after launch, and where the segment_index range was confirmed to start at 0 despite the documented range (posted 1 May 2025)
- X developer docs - 'X API Rate Limits': the eight media endpoints with their 24-hour and 15-minute ceilings, including 1,875 per 15 minutes on initialize, append and finalize (accessed 13 October 2026)
- X developer docs - 'Pricing': the pay-per-use read and write tables showing Post: Create at $0.015, Post: Create (with URL) at $0.200 and Media Metadata at $0.005, with no line item for media upload (accessed 13 October 2026)
Product designer and indie hacker. Runs the agent on his own X account every day and writes up what the data shows, including when it's inconvenient.
Follow on X →