DEV Community

Fast Data
Fast Data

Posted on

How to post a video to X with the API in Python (chunked upload, OAuth 1.0a, and the processing step everyone forgets)

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]))
Enter fullscreen mode Exit fullscreen mode

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/..."}}
Enter fullscreen mode Exit fullscreen mode

Three things in there are worth noticing:

  • INIT answers 202, not 200. Code that checks status_code == 200 fails on the first call; use raise_for_status() or accept any 2xx.
  • The media ID expires. expires_after_secs: 86400 means 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: 100 is not done. The second STATUS call already said 100 % but still in_progress. Only succeeded means 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
Enter fullscreen mode Exit fullscreen mode

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/create and 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.

Top comments (0)