X API Media Upload v2: How Twitter (X) Media Upload Works, Its Limits, and a One-Call Alternative

By TwitterAPI.io Team•16 min read
Diagram of two media upload paths. Official X API v2: initialize, append in chunks of 5 MB or less, finalize, status polling, then POST /2/tweets with media_ids. TwitterAPI.io: log in once with user_login_v2, upload with upload_media_v2 to get a media_id, then post with create_tweet_v2.
The official X API v2 chunked upload session versus a single multipart upload call on TwitterAPI.io. Both end with a media_id attached to a post.

This page is about one narrow step: getting an image, GIF or video file onto X through an API and receiving the media_id you attach to a post. If you want the wider question of scheduling posts, publishing from RSS or a sheet, or choosing a posting tool, read our Twitter auto post guide instead; here we stay inside the upload itself, the endpoint paths, the limits, and the errors that come out of it.

X rewrote its media upload for the v2 API. The old pattern of sending command=INIT, command=APPEND and command=FINALIZE to one upload URL is now described in X's docs as the previous protocol; the v2 flow uses separate paths for initialize, append and finalize, a JSON body at initialize, and an OAuth user token with the media.write scope. Every limit and path below was read from docs.x.com while writing this guide, and we quote the docs rather than forum folklore.

To ground the limits in what real accounts post, we pulled the latest 1280 posts from eight organization timelines (@NASA, @NBA, @NatGeo, @SpaceX, @XDevelopers, @nytimes, @BBCWorld, @verge) and measured the media attached to each: type, items per post, video duration, and the variants X serves back. The numbers appear in the tables and charts below.

01 — Section

How X API media upload v2 works: the five calls

X's chunked upload quickstart (docs.x.com/x-api/media/quickstart/media-upload-chunked) lists the flow as four upload calls and one post call. You authenticate as a user throughout: the endpoint reference for POST /2/media/upload lists an OAuth 2.0 user token with the media.write scope, or an OAuth 1.0a user token, as its security schemes. An app-only Bearer token is not among the accepted schemes, so upload always runs on behalf of a specific X user.

StepCallWhat you sendWhat you get
1. InitializePOST /2/media/upload/initializeJSON: media_type, total_bytes, media_categorydata.id, media_key, expires_after_secs
2. AppendPOST /2/media/upload/{id}/appendmultipart: segment_index, media chunkempty success per chunk
3. FinalizePOST /2/media/upload/{id}/finalizenothing but the idsize, optional processing_info
4. StatusGET /2/media/upload?command=STATUS&media_id=...the idprocessing_info.state
5. PostPOST /2/tweetstext, media.media_idsthe new post id

Initialize takes JSON, not form fields. The quickstart says so explicitly, and the request schema is strict (additionalProperties: false). The accepted media_type values in the reference include video/mp4, video/quicktime, video/webm, image/jpeg, image/png, image/gif, image/webp and subtitle types text/srt and text/vtt. total_bytes is capped at 17,179,869,184 bytes (16 GB) in the schema.

Append in chunks of 5 MB or less. X's guide says to keep each segment at or below 5 MB, with a server maximum of 8 MB, and to index segments from 0. The upside the docs list for chunking: uploads survive slow networks, can be paused and resumed, and a failed chunk can be retried on its own.

Finalize, then wait if told to. If the finalize response carries processing_info, the file is still being processed; poll the status call and sleep for check_after_secs between polls. States go pending, in_progress, then succeeded or failed. If finalize returns no processing_info, the media is ready. The example responses show expires_after_secs of 86,400, so an uploaded but unused media_id is not meant to be kept for long.

The simple upload still exists, for small files. POST /2/media/upload accepts a whole file in one request (base64 in a JSON body or raw bytes in multipart). X's media introduction says that simple path is for images and small files only, and that every video should go through the chunked flow.

python
# Official X API v2 chunked upload (api.x.com), OAuth 2.0 user token with media.write.
import math, os, time, requests

X = "https://api.x.com/2"
H = {"Authorization": f"Bearer {os.environ['X_USER_ACCESS_TOKEN']}"}  # user context, not app-only
path = "clip.mp4"
size = os.path.getsize(path)

# 1. INITIALIZE - JSON body, not form fields
init = requests.post(f"{X}/media/upload/initialize", headers=H, json={
    "media_type": "video/mp4", "total_bytes": size, "media_category": "tweet_video"}).json()
media_id = init["data"]["id"]

# 2. APPEND - chunks of at most 5 MB, segment_index from 0
CHUNK = 4 * 1024 * 1024
with open(path, "rb") as fh:
    for i in range(math.ceil(size / CHUNK)):
        requests.post(f"{X}/media/upload/{media_id}/append", headers=H,
                      data={"segment_index": i}, files={"media": fh.read(CHUNK)}).raise_for_status()

# 3. FINALIZE - may return processing_info
fin = requests.post(f"{X}/media/upload/{media_id}/finalize", headers=H).json()["data"]

# 4. STATUS - poll while processing_info says pending / in_progress
info = fin.get("processing_info")
while info and info["state"] in ("pending", "in_progress"):
    time.sleep(info.get("check_after_secs", 1))
    info = requests.get(f"{X}/media/upload", headers=H, params={
        "command": "STATUS", "media_id": media_id}).json()["data"].get("processing_info")
if info and info["state"] == "failed":
    raise SystemExit(f"processing failed: {info}")

# 5. POST - limits are checked again here (403 if the video is too long for this user)
r = requests.post(f"{X}/tweets", headers=H, json={
    "text": "Clip", "media": {"media_ids": [media_id]}})
print(r.status_code, r.json())
02 — Section

X media upload limits by media type

These are the caps from X's media introduction and best-practices pages (docs.x.com/x-api/media/introduction, docs.x.com/x-api/media/quickstart/best-practices). Two rules sit above the table: the limits follow the authenticated X account's Premium / verified status, not your developer API plan, and they are enforced twice, once by the upload endpoints and again when the media_id is attached to a post or DM.

media_categoryUseDefault capPremium / verified cap
tweet_image, dm_imageImage (JPG, PNG, GIF, WEBP)5 MB5 MB
tweet_gif, dm_gifAnimated GIF15 MB15 MB
tweet_video, amplify_videoVideo on a post or ad0.5 s to 20 min, 8 GB0.5 s to 125 min, 16 GB
dm_videoVideo in a DM0.5 s to 140 s, 512 MB0.5 s to 10 min, 1 GB
subtitlesSubtitle file1 MB1 MB

Per post: up to 4 photos, or 1 animated GIF, or 1 video. You cannot mix a video with photos in one post.

GIFs have extra ceilings. The best-practices page recommends at most 1280x1080 resolution, at most 350 frames and at most 300 million pixels in total (width x height x frames) for an animated GIF, and warns that a GIF can fail at post creation even inside the 15 MB limit. Larger GIFs should go through the chunked flow with media_category=tweet_gif, which makes processing asynchronous.

Video recommendations. H.264 High Profile, 30 or 60 FPS, AAC-LC audio (High-Efficiency AAC is not supported), mono or stereo, YUV 4:2:0, progressive scan, no open GOP, aspect ratio between 1:3 and 3:1, and dimensions between 32x32 and 1280x1024 in the advanced list. X's recommended upload sizes are 1280x720 landscape, 720x1280 portrait and 720x720 square; subscribed users can upload 1080p and get 1080p playback.

If you omit media_category, X treats the upload as post media and picks tweet_image, tweet_video or tweet_gif from the content type. That default is fine for posts and wrong for DMs.

03 — Section

Twitter API upload image: the 5 MB rule and how to stay under it

The single most common image failure is size. X's image rule is 5 MB or less for tweet_image and dm_image, in JPG, PNG, GIF or WEBP. A phone photo or an exported PNG screenshot often weighs more than that, and a bot that forwards images from elsewhere will hit the cap sooner or later.

How big are the images organizations actually upload? X keeps the uploaded dimensions in each photo's original_info. Across the 249 photos in our sample, the median long edge was 3,000 pixels and 61.4% of photos were wider or taller than 2,048 pixels. So large originals are normal, and they pass when they are compressed well; the cap is on bytes, not pixels.

Check bytes before you call. Read the file size locally and refuse anything over 5 MB for an image or 15 MB for a GIF. That costs nothing and saves a failed write call.

Re-encode, do not just rename. Converting a large PNG photo to JPEG at quality 85 to 90, or resizing the long edge to about 2,048 to 4,096 pixels, usually brings a photo well under 5 MB. Keep screenshots with text as PNG if they are already small.

Strip what you do not need. Embedded thumbnails and large ICC profiles add bytes. Most image libraries drop them when you save a new copy.

Use the right category. An image meant for a DM should be uploaded as dm_image; X's best-practices page names a wrong category (for example a DM category on a post) as a common reason an upload succeeds and the post then fails.

We did not test which HTTP status X returns for an oversized image, and we are not going to guess one here. Treat any non-success response from the upload as final for that file, shrink it, and upload again.

python
# Shrink an image under X's 5 MB tweet_image cap before uploading (pip install pillow).
import io, os
from PIL import Image

CAP = 5 * 1024 * 1024

def fit_under_cap(path, out="upload.jpg", long_edge=4096):
    if os.path.getsize(path) <= CAP:
        return path
    img = Image.open(path).convert("RGB")
    img.thumbnail((long_edge, long_edge))
    for q in (90, 85, 80, 75, 70):
        buf = io.BytesIO()
        img.save(buf, "JPEG", quality=q, optimize=True)
        if buf.tell() <= CAP:
            open(out, "wb").write(buf.getvalue())
            return out
    raise ValueError("still over 5 MB; lower long_edge")
04 — Section

X media upload failed: the errors and what they mean

Most "media upload failed" reports trace back to a handful of causes that X's own docs describe. The table lists them with the documented signal; where X documents an exact message we quote it, and where it does not we say so.

SymptomLikely causeFix
Initialize rejects the bodyForm fields instead of JSON, or an unknown fieldSend JSON with media_type, total_bytes, media_category only
Upload rejected with an app tokenUpload needs a user context (media.write or OAuth 1.0a user)Use a user access token that carries media.write
processing_info.state is failedCodec, frame rate, audio profile or GIF frame count out of specRe-encode to H.264 High + AAC-LC, 30 or 60 FPS
Upload fine, post fails with 403Video longer than the posting user's capTrim to 20 min (default) or post from a Premium account
Upload fine, post failsWrong media_category for the target, or GIF over pixel/frame limitsMatch the category to post vs DM; shrink the GIF
Image rejectedOver 5 MB, or unsupported formatRe-encode to JPEG or WEBP under 5 MB

The one exact message X documents. When POST /2/tweets rejects a video for length, the response is 403 Forbidden with the detail "This user is not allowed to post a video longer than N minutes.", where N is 20 by default and 125 for Premium / verified post video. X tells you to handle it even after a successful finalize.

Do not mix protocols. The chunked quickstart warns against sending command=INIT, command=APPEND or command=FINALIZE to POST /2/media/upload; those were the previous protocol. Only the status check still uses command=STATUS, on the GET. Older code samples and libraries that still send the command form are a frequent source of confusing failures.

Stuck in processing. If the state stays pending or in_progress, keep honoring check_after_secs rather than polling in a tight loop, and remember the session expiry in the initialize response. A video that never leaves processing is almost always a format problem; re-encode and start a new session.

Large IDs in JavaScript. X's best-practices page notes that media ids are long integers; use the string form of the id in languages that cannot hold a 64-bit integer exactly.

05 — Section

What organizations actually upload: media on 987 posts

Limits tell you what is allowed; a sample tells you what is normal. We read the latest 1280 posts from eight organization accounts through /twitter/user/last_tweets (8 pages each), dropped the 293 reposts because their media was uploaded by someone else, and classified each of the 987 original posts by what it carried in extendedEntities.media.

AccountOriginal postsVideoPhotoLink card
@NBA15678.8%10.9%3.2%
@NASA9217.4%60.9%20.7%
@NatGeo12945.0%22.5%31.0%
@SpaceX15035.3%30.0%14.7%
@XDevelopers2714.8%11.1%40.7%
@BBCWorld1573.8%0.0%96.2%
@nytimes1160.0%0.9%98.3%
@verge1600.0%0.0%99.4%

Video is the most common upload. Across all original posts, 26.3% carried a video and 15.3% carried photos. Not one post in the sample carried an animated GIF (0.0%), which says something about how little GIFs matter for brand publishing next to video.

Half of the posts upload nothing. 52.8% of original posts were link cards: the image you see is pulled from the linked page's preview tags, not uploaded through the media endpoints. News publishers live there (@verge 99.4%, @nytimes 98.3%, @BBCWorld 96.2%). If your goal is a picture next to a link, a card may be all you need, and no upload call is involved.

Four-photo posts are rare. Of the 411 posts with media, 363 carried a single item, 15 carried two, 8 three and 25 the maximum of four (6.1%).

Stacked bar chart of media type share per account. NBA 78.8% video; NASA 60.9% photo; NatGeo 45.0% video and 22.5% photo; SpaceX 35.3% video and 30.0% photo; XDevelopers 40.7% link card; BBCWorld 96.2%, nytimes 98.3% and verge 99.4% link cards. Overall 26.3% video, 15.3% photo, 0.0% GIF.
Media attached to 987 original posts from eight organization accounts, collected in 2026 via /twitter/user/last_tweets. Reposts excluded.
06 — Section

Video length, variants and resolution in the sample

The 268 videos in the sample tell you which video limit you are likely to meet. The median duration was 25.3 seconds and the 90th percentile 119.2 seconds; 92.2% ran 140 seconds or less, which is the default DM video cap, and 99.6% fit the 20-minute default post cap. Only 1 video ran longer than 20 minutes (28.5 minutes), which needs a Premium / verified account on X's rules.

DurationVideosDefault cap that applies
0-15s89fits DM (140 s) and post (20 min)
15-30s72fits DM (140 s) and post (20 min)
30-60s47fits DM (140 s) and post (20 min)
60-140s39fits DM (140 s) and post (20 min)
140s-20min20post only, too long for a default DM
over 20min1needs Premium / verified

What X serves back. Every video in the sample came back with an HLS playlist (100.0%) plus a median of 4 MP4 renditions in video_info.variants. The highest MP4 bitrate had a median of 10.4 Mbps and a maximum of 25.1 Mbps; the most common top resolution was 3840x2160 (53 videos). X transcodes whatever you upload into this ladder, so a clean H.264 master at the recommended settings is worth more than a huge file.

A hint about the upload category. 79.1% of the video thumbnails sat under an amplify_video_thumb path in media_url_https, the rest under media or ext_tw_video_thumb. The path name matches X's amplify_video category, which suggests most of these brand videos were uploaded through ads or media-studio tooling rather than plain post upload. It is a reading of URL paths, not something X documents, so treat it as a hint only.

Bar chart of video durations: 0-15 s 89, 15-30 s 72, 30-60 s 47, 60-140 s 39, 140 s to 20 min 20, over 20 min 1. Dashed lines mark the 140-second default DM video cap and the 20-minute default post video cap.
Duration of 268 videos on eight organization timelines against X's default caps, collected in 2026. 92.2% run 140 seconds or less.
07 — Section

Upload media v2 on TwitterAPI.io: one call, then post

TwitterAPI.io exposes the upload as a single endpoint, POST /twitter/upload_media_v2 (reference: docs.twitterapi.io/api-reference/endpoint/upload_media_v2). It takes a multipart/form-data body and returns JSON with media_id, status and message. You authenticate the API call with your X-API-Key header; the X account that owns the upload comes from login_cookies.

FieldRequiredMeaning
fileyesThe image or video bytes
proxyyesYour proxy in user:pass@host:port form; residential recommended
login_cookiesyes in the schemaFrom /twitter/user_login_v2
is_long_videonoUpload videos longer than 2:20 (Premium accounts only)

Step 1, log in once. POST /twitter/user_login_v2 with user_name, email, password, proxy and, strongly recommended by the docs, totp_secret (the base32 2FA seed). It returns login_cookies and costs 10,000 credits ($0.10). The docs note these cookies only work with the v2-suffixed endpoints and that accounts without 2FA can yield cookies that cannot post. Store them and reuse them; log in again only when a call says the session is no longer valid.

Step 2, upload. Send the file with login_cookies and proxy to /twitter/upload_media_v2. Each call is 60 credits = $0.0006. There is no initialize, append, finalize or status loop on your side.

Step 3, post. POST /twitter/create_tweet_v2 with login_cookies, tweet_text, proxy and media_ids (a list of strings from step 2). Also 60 credits = $0.0006. Optional fields include reply_to_tweet_id, attachment_url for a quote, community_id, and is_note_tweet for long posts on Premium accounts.

X's limits still apply. The file still ends up on X under that account, so the 5 MB image, 15 MB GIF and per-account video caps in the table above are the ones to check before you upload. The is_long_video flag mirrors X's own rule that long video belongs to Premium accounts.

What you are signing up for. Every write call acts as that X account. X can rate-limit, lock or suspend an account that posts at an inhuman rate, so keep a human pace and space posts out. The session can need a fresh login. Billing is per HTTP 200 call, so read status and message on every response and stop retrying a file that keeps failing.

08 — Section

Cost: official X API vs TwitterAPI.io for upload and posting

X's pay-per-usage rate card (docs.x.com/x-api/getting-started/pricing) bills writes per request and reads per resource. It lists Post: Create at $0.015 per request and $0.200 per request when the post contains a URL; there is no separate media upload line on the card, and Media Metadata is $0.005 per request. New paying accounts get $20 in X API credits after saving a card and buying credits, plus a match of the first auto-recharge up to $50.

ActionOfficial X API (pay-per-usage card)TwitterAPI.io
Upload one fileno separate line on the card60 credits = $0.0006
Create a post$0.015 ($0.200 with a URL)60 credits = $0.0006
Session / authOAuth 2.0 user token, media.writeone login, $0.10, reused
Read 1,000 posts back$5.00 ($0.005 per post read)$0.15 (15 credits per tweet)

Reads are where the gap is widest. Confirming what you posted, or auditing a timeline's media like we did here, is a read. At $5 per 1,000 posts on X's card against $0.15 per 1,000 tweets on TwitterAPI.io, that is about 33x. X also caps pay-per-usage at 3 million post reads a month before Enterprise.

Credits and plans. $1 = 100,000 credits, and purchased credits never expire. New accounts that pass the automatic bot check get up to $1 of free credits (at most 100,000 credits, no card, one per person or network, expiring after 31 days). Subscriptions run from Starter at $29 a month to Business at $1,499 a month; see the pricing page for what each includes. Rate limits start at 0.2 requests per second before the first payment and rise to 20 once you have paid.

Which one to use. If you already run an X developer app with OAuth user tokens and need ads media (amplify_video), subtitles or the full DM category set, the official chunked flow is the tool built for it. If you post from a few accounts you control and want one call per file at a fixed price, the upload endpoint above is simpler.

09 — Section

How we collected this

Question. What media do large organization accounts attach to their X posts, and how close do they come to the v2 upload limits?

Accounts and endpoint. @XDevelopers, @NASA, @nytimes, @NatGeo, @SpaceX, @NBA, @verge and @BBCWorld, read with /twitter/user/last_tweets (replies excluded, newest first), 8 pages of 20 posts each, collected in 2026. 64 requests, 1280 posts, about $0.19 at 15 credits per tweet returned. For your own runs, /twitter/user/tweet_timeline (by numeric userId, from /twitter/user/info) is the steadier choice, because last_tweets can come back empty for accounts X keeps out of search.

Method. Reposts (293) were dropped because their media belongs to another account. For the 987 original posts we read extendedEntities.media[] (type, video_info.duration_millis, video_info.variants, original_info) and the card field. Each account's window runs from its oldest to newest post in the sample, so busy accounts cover days and quiet ones weeks.

What the sample cannot show. File sizes in bytes (X does not return them on read), the media_category actually used at upload, failed uploads, and anything about small or personal accounts. Eight brand accounts are not a census; the mix shifts with each organization's content strategy.

Privacy. Aggregates only, organization accounts only. Reposted and quoted authors were not analyzed, and raw pages were kept out of the repository; the published sample holds de-identified per-media rows with no ids, URLs or text.

python
# Measure what an account attaches to its posts (reads: 15 credits per tweet returned).
import collections, os, requests

BASE = "https://api.twitterapi.io"
H = {"X-API-Key": os.environ["TWITTERAPI_IO_API_KEY"]}

def media_mix(user, pages=8):
    mix, cursor = collections.Counter(), ""
    for _ in range(pages):
        r = requests.get(f"{BASE}/twitter/user/last_tweets", headers=H,
                         params={"userName": user, "cursor": cursor}, timeout=30).json()
        data = r.get("data") or {}
        for t in data.get("tweets", []):
            if t.get("retweeted_tweet"):
                continue  # a repost carries someone else's upload
            types = {m["type"] for m in (t.get("extendedEntities") or {}).get("media", [])}
            mix["video" if "video" in types else "animated_gif" if "animated_gif" in types
                else "photo" if "photo" in types else "card" if t.get("card") else "text"] += 1
        if not r.get("has_next_page"):
            break
        cursor = r.get("next_cursor", "")
    return mix

print(media_mix("NASA"))
python
"""x_media_post.py - upload an image or video to X (Twitter) and post it, through TwitterAPI.io.

    pip install requests
    export TWITTERAPI_IO_API_KEY=...        # dashboard at twitterapi.io
    export X_LOGIN_COOKIES=...              # from /twitter/user_login_v2 (log in once, reuse it)
    export X_PROXY=http://user:pass@host:port   # the proxy you supply for write actions
    python x_media_post.py photo.jpg "Launch day"

Cost: upload 60 credits ($0.0006) + post 60 credits ($0.0006) per HTTP 200 call.
Each call acts as the logged-in X account: keep a human pace.
"""
import os
import sys
import time

import requests

BASE = "https://api.twitterapi.io"
HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_API_KEY"]}
COOKIES = os.environ["X_LOGIN_COOKIES"]
PROXY = os.environ["X_PROXY"]

# X's own limits (docs.x.com/x-api/media/introduction). The file still lands on X,
# so check it locally before spending a call.
LIMITS_MB = {".jpg": 5, ".jpeg": 5, ".png": 5, ".webp": 5, ".gif": 15}


def precheck(path):
    ext = os.path.splitext(path)[1].lower()
    size_mb = os.path.getsize(path) / (1024 * 1024)
    cap = LIMITS_MB.get(ext)
    if cap and size_mb > cap:
        sys.exit(f"{path} is {size_mb:.1f} MB; X caps {ext} uploads at {cap} MB. Resize or compress first.")
    return size_mb


def upload(path, long_video=False):
    with open(path, "rb") as fh:
        r = requests.post(
            f"{BASE}/twitter/upload_media_v2",
            headers=HEADERS,
            files={"file": (os.path.basename(path), fh)},
            data={"login_cookies": COOKIES, "proxy": PROXY,
                  "is_long_video": "true" if long_video else "false"},
            timeout=300,
        )
    body = r.json()
    if r.status_code != 200 or body.get("status") != "success":
        sys.exit(f"upload failed: HTTP {r.status_code} {body.get('message') or body}")
    return body["media_id"]


def post(text, media_ids):
    r = requests.post(
        f"{BASE}/twitter/create_tweet_v2",
        headers=HEADERS,
        json={"login_cookies": COOKIES, "proxy": PROXY,
              "tweet_text": text, "media_ids": media_ids},
        timeout=60,
    )
    body = r.json()
    if r.status_code != 200 or body.get("status") != "success":
        sys.exit(f"post failed: HTTP {r.status_code} {body.get('message') or body}")
    return body["tweet_id"]


def confirm(tweet_id):
    # Read the post back (15 credits per tweet returned) and list what X attached.
    r = requests.get(f"{BASE}/twitter/tweets", headers=HEADERS,
                     params={"tweet_ids": tweet_id}, timeout=30)
    for t in r.json().get("tweets", []):
        for m in (t.get("extendedEntities") or {}).get("media", []):
            info = m.get("video_info") or {}
            print(m["type"], m.get("media_url_https"),
                  f"{info.get('duration_millis', 0) / 1000:.1f}s" if info else "")


if __name__ == "__main__":
    files, text = sys.argv[1:-1], sys.argv[-1]
    if not 1 <= len(files) <= 4:
        sys.exit("X allows up to 4 photos, or 1 GIF, or 1 video per post.")
    ids = []
    for f in files:
        precheck(f)
        ids.append(upload(f))
        time.sleep(2)  # human pace between write calls
    tweet_id = post(text, ids)
    print("posted", tweet_id)
    time.sleep(5)
    confirm(tweet_id)
10 — Questions

Questions readers ask

What is the X API media upload v2 endpoint?

On the official X API it is a set of endpoints under /2/media/upload: POST /2/media/upload/initialize, POST /2/media/upload/{id}/append, POST /2/media/upload/{id}/finalize and GET /2/media/upload?command=STATUS, plus a simple one-shot POST /2/media/upload for small images. On TwitterAPI.io the equivalent is POST /twitter/upload_media_v2, one multipart call that returns a media_id.

What is the image size limit for the X API?

5 MB for tweet_image and dm_image, in JPG, PNG, GIF or WEBP, per X's media docs. Animated GIFs uploaded as tweet_gif or dm_gif can be up to 15 MB. The cap is the same for Premium and default accounts.

How long can a video uploaded through the X API be?

For a post: 0.5 seconds to 20 minutes and up to 8 GB on a default account, 125 minutes and 16 GB on an X Premium / verified account. For a DM: 140 seconds and 512 MB by default, 10 minutes and 1 GB for Premium. The limit follows the uploading and posting user, not your developer plan.

Why does my media upload succeed but the post fails?

X checks the media twice: at upload and again when you attach the media_id to POST /2/tweets. A video longer than the posting user's cap fails at post time with 403 and "This user is not allowed to post a video longer than N minutes." A media category that does not match the target (a DM category on a post) and GIFs over the frame or pixel limits are the other documented causes.

Can I still send command=INIT to the upload endpoint?

X's chunked upload guide says not to send command=INIT, command=APPEND or command=FINALIZE to POST /2/media/upload; those belong to the previous protocol. The v2 flow uses dedicated initialize, append and finalize paths. command=STATUS is still used on the status GET.

What chunk size should I use for chunked upload?

X says to keep each appended segment at or below 5 MB, with a server maximum of 8 MB, and to number segments from 0. Its own samples use 4 MB chunks.

How many images can one post have?

Up to 4 photos, or 1 animated GIF, or 1 video, per X's best-practices page. In our sample of organization posts with media, only 6.1% used all four photo slots.

How much does it cost to upload media with TwitterAPI.io?

60 credits ($0.0006) per /twitter/upload_media_v2 call and 60 credits ($0.0006) per /twitter/create_tweet_v2 call, after a one-time /twitter/user_login_v2 login at $0.10. Billing is per HTTP 200 call. Each call acts as the logged-in X account, so keep a human pace; X can rate-limit, lock or suspend accounts that post too fast.

11 — Further reading

Continue

Sources & further reading
More from this series
Build it

Stop reading. Start building.

Starter credits cover real testing on real data. Google sign-in, no card, no application queue.

Get an API key

Already building with TwitterAPI.io? Publish an honest tutorial or build log about it and earn up to $200 a month in API credits.