Posting text to X from a script is one request. Posting a video is five different calls, two hosts, one asynchronous transcoding step and an auth scheme many tutorials get wrong. I needed it for short data videos we render from code, so here is the version that works, with the real responses from yesterday's upload.
Disclosure: I build data tools for AI agents as Fast Data on Apify. This post needs none of them; the script is free in our data-recipes repo.
The flow in one table
| Step | Endpoint | What you send | What comes back |
|---|---|---|---|
| 1. INIT | POST upload.x.com/1.1/media/upload.json | total size, video/mp4, media_category=tweet_video | media_id_string |
| 2. APPEND | same | the file in chunks with segment_index 0, 1, 2 … | 2xx, empty body |
| 3. FINALIZE | same | media_id | processing_info: pending |
| 4. STATUS | GET same | media_id | in_progress … succeeded |
| 5. Post | POST api.x.com/2/tweets | text + media_ids | the new post's id |
Two hosts: media goes to upload.x.com, the post itself to api.x.com. Both need OAuth 1.0a user context (consumer key and secret plus access token and secret of the account that posts). An app-only bearer token can read, but it cannot post on behalf of a user.
The script
import os, sys, time import requests from requests_oauthlib import OAuth1 UPLOAD = "https://upload.x.com/1.1/media/upload.json" TWEETS = "https://api.x.com/2/tweets" CHUNK = 4 * 1024 * 1024 # 4 MB per APPEND auth = OAuth1(os.environ["X_API_KEY"], os.environ["X_API_SECRET"], os.environ["X_ACCESS_TOKEN"], os.environ["X_ACCESS_TOKEN_SECRET"]) def upload_video(path): r = requests.post(UPLOAD, auth=auth, timeout=60, data={ "command": "INIT", "total_bytes": os.path.getsize(path), "media_type": "video/mp4", "media_category": "tweet_video"}) r.raise_for_status() media_id = r.json()["media_id_string"] with open(path, "rb") as f: index = 0 while chunk := f.read(CHUNK): r = requests.post(UPLOAD, auth=auth, timeout=120, data={"command": "APPEND", "media_id": media_id, "segment_index": index}, files={"media": chunk}) r.raise_for_status() index += 1 r = requests.post(UPLOAD, auth=auth, timeout=60, data={"command": "FINALIZE", "media_id": media_id}) r.raise_for_status() info = r.json().get("processing_info") while info and info["state"] in ("pending", "in_progress"): time.sleep(info.get("check_after_secs", 5)) r = requests.get(UPLOAD, auth=auth, timeout=60, params={"command": "STATUS", "media_id": media_id}) r.raise_for_status() info = r.json().get("processing_info") if info and info["state"] == "failed": raise RuntimeError(info.get("error")) return media_id def post_with_video(path, text): media_id = upload_video(path) r = requests.post(TWEETS, auth=auth, timeout=60, json={"text": text, "media": {"media_ids": [media_id]}}) r.raise_for_status() return r.json()["data"]["id"] if __name__ == "__main__": print("https://x.com/i/status/" + post_with_video(sys.argv[1], sys.argv[2])) Run it with pip install requests requests_oauthlib, the four keys as environment variables, and python x_video_upload.py clip.mp4 "Your text".
What a real upload looked like
The file was a 13-second vertical clip, 1080x1920, H.264 with AAC audio, 17,907,542 bytes. That is five APPEND calls at 4 MB each. The responses, shortened:
INIT 202 {"media_id_string": "21213064111497…", "expires_after_secs": 86400, ...} FINALIZE 200 {"size": 17907542, "processing_info": {"state": "pending", ...}} STATUS {"state": "in_progress", "check_after_secs": 1, "progress_percent": 20} STATUS {"state": "in_progress", "check_after_secs": 1, "progress_percent": 100} STATUS {"state": "succeeded", "progress_percent": 100} TWEET 201 {"data": {"id": "21213064500813…", "text": "... https://t.co/..."}} Three things in there are worth noticing:
- INIT answers 202, not 200. Code that checks
status_code == 200fails on the first call; useraise_for_status()or accept any 2xx. - The media ID expires.
expires_after_secs: 86400means you have a day to attach it to a post. You can upload in a batch job and post later, but not next week. -
progress_percent: 100is not done. The second STATUS call already said 100 % but stillin_progress. Onlysucceededmeans you can post.
The four errors you will hit first
| Symptom | Cause | Fix |
|---|---|---|
| Post fails with a media error right after FINALIZE | you skipped STATUS and posted while the video was still transcoding | poll STATUS until succeeded, honouring check_after_secs |
| 401 on INIT although reading works | bearer token instead of OAuth 1.0a user context, or the app only has read permission | give the app read and write permission, then regenerate the access token (old tokens keep the old permission) |
Upload of a longer clip is rejected or never gets processing_info | no media_category; asynchronous video processing is tied to tweet_video | always send media_category=tweet_video for MP4 |
Processing ends in failed | an unusual codec or pixel format | encode as H.264, yuv420p, AAC audio, -movflags +faststart |
The ffmpeg line I use before every upload:
ffmpeg -i master.mp4 -c:v libx264 -crf 23 -preset slow -pix_fmt yuv420p \ -c:a aac -b:a 128k -movflags +faststart upload.mp4 What this does not cover
- Rate limits and pricing depend on your X API access tier and change; check your developer portal before you schedule many uploads.
- Alt text and subtitles are separate calls (
media/metadata/createand subtitle upload); worth adding for accessibility, not shown here. - Reach. The API gives you the post, not the audience. Two videos I posted this way showed 1 and 2 impressions after about 12 hours, while text posts on the same account usually get somewhere between 3 and 20. That is two data points, not a finding; I do not know yet whether it is the videos, the timing or something about API uploads.
Try it
The full script with comments is in our public repo: snippets/x_video_upload.py. The repo also has n8n workflows and Python snippets for public European business data, if that is your thing.
Question: has anyone measured whether videos uploaded through the API get less distribution than the same video uploaded in the app? I would love to see numbers before I run my own A/B test.
