twitterapi.io is an independent third-party service. Not affiliated with X Corp.

Blogdownload twitter video api

Download Twitter (X) Media — Bulk API Workflow (Photos + Videos)

By Alex Chen6 min read

Downloading Twitter (X) media programmatically comes up in nearly every dev workflow that touches the platform — archive-before-account-delete, brand-content backup, research dataset assembly, media forensics, offline analysis pipelines. The tweet API returns media metadata (URLs, content types, dimensions), but not the bytes; you have to fetch each URL yourself. This guide walks the full bulk workflow with runnable Python.

The complexity comes from video handling. Photos are trivial — a single GET to a pbs.twimg.com URL returns the JPG bytes. Videos have two shapes on X: MP4 direct-download URLs (the common case) and HLS streaming manifests (.m3u8 playlist + .ts segments) that require concatenation. Both are documented below with runnable code, plus the concurrency pattern that keeps bulk downloads fast without tripping the CDN's per-IP rate limits.

01 — Section

The media object — what the API returns

Every tweet response from twitterapi.io (or X official) includes an optional media array. Each element is an object with type (photo / video / animated_gif), media_url_https (photo direct URL), and for videos, a video_info.variants array with bitrate-sorted MP4 and HLS options.

Photo shape: {type: 'photo', media_url_https: 'https://pbs.twimg.com/media/xxx.jpg', width, height} — one URL, one GET.

Video shape: {type: 'video', video_info: {duration_millis, variants: [{content_type: 'video/mp4', bitrate: 2176000, url: '...'}, {content_type: 'application/x-mpegURL', url: '.../playlist.m3u8'}]}, media_url_https: 'preview-thumb.jpg'} — pick the highest-bitrate MP4 variant for direct download, or the HLS URL for adaptive streaming.

Animated GIF shape: same as video (X stores GIFs as MP4 internally) — pick the MP4 variant, save as MP4 or convert with ffmpeg -i in.mp4 -vf 'fps=15' out.gif.

For docs on the media object schema, see docs.x.com/x-api/data-dictionary/object-model/media.

02 — Section

Step 1 — enumerate tweets + extract media URLs

Given a set of target tweets (user timeline, search query, list) enumerate them via twitterapi.io and collect the media URLs. The pattern below handles pagination, filters to photo + video only (skipping text-only tweets), and pre-selects the highest-bitrate MP4 for each video.

python
import os, requests

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

def extract_media_urls(tweet: dict) -> list[dict]:
    """Return list of {kind, url, tweet_id, author, index} for each media in tweet."""
    out = []
    author = tweet.get("user", {}).get("userName", "unknown")
    for i, m in enumerate(tweet.get("media", [])):
        if m["type"] == "photo":
            out.append({
                "kind": "photo",
                "url": m["media_url_https"],
                "tweet_id": tweet["id"],
                "author": author,
                "index": i,
                "ext": m["media_url_https"].rsplit(".", 1)[-1].split("?")[0],
            })
        elif m["type"] in ("video", "animated_gif"):
            variants = m.get("video_info", {}).get("variants", [])
            mp4s = [v for v in variants if v["content_type"] == "video/mp4"]
            if mp4s:
                best = max(mp4s, key=lambda v: v.get("bitrate", 0))
                out.append({
                    "kind": m["type"],
                    "url": best["url"],
                    "tweet_id": tweet["id"],
                    "author": author,
                    "index": i,
                    "ext": "mp4",
                })
            else:
                hls = next((v for v in variants
                            if v["content_type"] == "application/x-mpegURL"), None)
                if hls:
                    out.append({"kind": "video_hls", "url": hls["url"],
                                "tweet_id": tweet["id"], "author": author,
                                "index": i, "ext": "mp4"})
    return out

def enum_user_media(handle: str) -> list[dict]:
    urls, cursor = [], None
    while True:
        params = {"userName": handle}
        if cursor: params["cursor"] = cursor
        r = requests.get(f"{BASE}/twitter/user/last_tweets",
                         headers=HEADERS, params=params, timeout=15)
        r.raise_for_status()
        resp = r.json()
        for t in resp.get("tweets", []):
            urls.extend(extract_media_urls(t))
        cursor = resp.get("next_cursor")
        if not cursor: break
    return urls

urls = enum_user_media("your_handle")
print(f"found {len(urls)} media objects")
print(f"photos: {sum(1 for u in urls if u['kind'] == 'photo')}")
print(f"videos: {sum(1 for u in urls if u['kind'] in ('video', 'animated_gif'))}")
print(f"HLS-only: {sum(1 for u in urls if u['kind'] == 'video_hls')}")
03 — Section

Step 2 — bulk download with concurrency + retry

With the URL list in hand, dispatch downloads. concurrent.futures.ThreadPoolExecutor with 4-8 workers is the sustainable baseline for pbs.twimg.com and video.twimg.com — higher and you'll see connection resets or 429s. The naming convention {author}_{tweet_id}_{index}.{ext} prevents collisions and makes later filtering (by author or date range) trivial.

Retry on transient failures (429, 5xx, connection reset) with exponential backoff. Deleted tweet media returns 404 — treat as terminal and skip. Log all outcomes to a JSONL for later review.

python
import json, time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

OUT_DIR = Path("media_download")
OUT_DIR.mkdir(exist_ok=True)
LOG = open("download_log.jsonl", "a")

def fetch_one(item: dict, retries: int = 3) -> dict:
    fname = f"{item['author']}_{item['tweet_id']}_{item['index']}.{item['ext']}"
    dest = OUT_DIR / fname
    if dest.exists():
        return {"item": item, "result": "skip_exists"}
    for attempt in range(retries):
        try:
            r = requests.get(item["url"], stream=True, timeout=30)
            if r.status_code == 404:
                return {"item": item, "result": "404_deleted"}
            if r.status_code == 429:
                time.sleep(2 ** attempt * 5)
                continue
            r.raise_for_status()
            with open(dest, "wb") as f:
                for chunk in r.iter_content(chunk_size=64 * 1024):
                    f.write(chunk)
            return {"item": item, "result": "ok", "bytes": dest.stat().st_size}
        except Exception as e:
            if attempt == retries - 1:
                return {"item": item, "result": f"fail:{e}"}
            time.sleep(2 ** attempt)
    return {"item": item, "result": "exhausted_retries"}

def bulk_download(urls: list[dict], workers: int = 6):
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = {pool.submit(fetch_one, u): u for u in urls}
        stats = {"ok": 0, "skip": 0, "404": 0, "fail": 0, "bytes": 0}
        for f in as_completed(futures):
            res = f.result()
            LOG.write(json.dumps(res, default=str) + "\n")
            r = res.get("result", "")
            if r == "ok":
                stats["ok"] += 1
                stats["bytes"] += res.get("bytes", 0)
            elif r.startswith("skip"):
                stats["skip"] += 1
            elif r.startswith("404"):
                stats["404"] += 1
            else:
                stats["fail"] += 1
    LOG.flush()
    return stats

# stats = bulk_download(urls, workers=6)
# print(f"downloaded {stats['ok']} files, {stats['bytes'] / 1024**2:.1f} MB")
04 — Section

Step 3 — HLS videos need extra work

When the API returns only an HLS variant (.m3u8 URL), a single GET returns a playlist manifest, not the video. Two approaches:

Option A · yt-dlp shell-out (simplest, most reliable): yt-dlp -o 'out.mp4' handles playlist parsing, segment fetching, and MP4 concatenation. Pre-installed on most dev environments; if not, pip install yt-dlp.

Option B · ffmpeg direct: ffmpeg -i -c copy out.mp4 — same result, ffmpeg parses the manifest and streams segments. Requires ffmpeg binary in PATH.

Option C · pure Python with m3u8 + requests: parse the playlist, fetch each .ts segment, concatenate. Verbose but no external dependencies. Usually not worth it unless you're in a locked-down environment.

python
import subprocess

def fetch_hls(item: dict) -> dict:
    fname = f"{item['author']}_{item['tweet_id']}_{item['index']}.mp4"
    dest = OUT_DIR / fname
    if dest.exists():
        return {"item": item, "result": "skip_exists"}
    # Option A — yt-dlp
    try:
        subprocess.run(
            ["yt-dlp", "-o", str(dest), "--quiet", "--no-warnings", item["url"]],
            check=True, timeout=300,
        )
        return {"item": item, "result": "ok", "bytes": dest.stat().st_size}
    except subprocess.CalledProcessError as e:
        return {"item": item, "result": f"yt_dlp_fail:{e.returncode}"}
    except subprocess.TimeoutExpired:
        return {"item": item, "result": "timeout"}

# For a mixed batch, route HLS separately (yt-dlp is slower per-file):
hls_items = [u for u in urls if u["kind"] == "video_hls"]
direct_items = [u for u in urls if u["kind"] != "video_hls"]
# direct_items → bulk_download() from Step 2
# hls_items → sequential fetch_hls() (yt-dlp handles internal concurrency)
05 — Section

Bulk workflow — end-to-end with cost estimate

The complete pattern for archive-a-full-account or brand-content-backup:

1. Enumerate all target tweet IDs (user timeline, keyword search, list membership)

2. Extract media URLs from each tweet (Step 1)

3. Split by kind: direct URLs (photos + MP4) → concurrent bulk, HLS-only → sequential yt-dlp

4. Dispatch both queues in parallel (main script handles direct, subprocess pool handles HLS)

5. Merge audit logs; retry any transient failures once

Cost math for a full account backup at 10K tweets with 30% media rate (3K media objects, avg 6 MB each = 18 GB):

- Read: 10,000 × $0.00015 = $1.50 (twitterapi.io enumeration)

- Bandwidth: 18 GB × your egress rate (typically $0.05-0.10/GB on cloud, free on residential) = $0-1.80

- Compute: ~1-2 hours of wall-clock at 6 concurrent workers

- Total: ~$2-4 for a full account media archive

06 — Section

Storage + naming — patterns that scale

For anything past 1K files, flat directory hierarchies choke your filesystem tools (ls slows, tab-completion stalls, Finder rendering hangs on macOS). Two patterns:

By author + date shard: //_. — good for multi-account backups, easy to browse a specific author's timeline.

By hash prefix shard: /_. — evenly distributes files across ~256 subdirs, better for single-account archives with 100K+ media.

Include a manifest.jsonl at the archive root with {tweet_id, author, url, local_path, downloaded_at, bytes, sha256} per file — makes future dedup + integrity check trivial. Compute sha256 incrementally during download to avoid a second read pass.

Compression note: JPGs and MP4s are already compressed; a tar.gz of a media archive typically saves <5%. Not worth the CPU unless you're shipping the archive over a slow link. For long-term cold storage, consider tar (no gzip) with xz -0 or just leave uncompressed.

07 — Section

Rate limits + polite behavior

pbs.twimg.com and video.twimg.com are CDN-fronted and generally lenient — X wants their media loading fast. But sustained aggressive concurrency will trip protections:

4-8 concurrent workers is the safe baseline for a single IP. 16+ starts seeing connection resets on some regions.

429 response = back off 5-30 seconds + retry with reduced concurrency. Rare but happens on 10K+ file batches.

Connection reset (RST) without 429 = your ISP or the CDN's edge is throttling. Sleep 10 seconds, drop concurrency to 2, resume.

Distributed download (spreading over multiple IPs via VPN rotation or cloud workers) is over-engineered for <100K files. Save it for petabyte-scale research datasets where a single IP would take weeks.

Deleted-tweet 404s are terminal — the media is gone from the CDN. Log the 404 outcome; don't retry.

python
# End-to-end: enumerate + download all media from a user's timeline.
import os, json, time, subprocess
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}
BASE = "https://api.twitterapi.io"
HANDLE = os.environ["X_HANDLE"]
OUT_DIR = Path(f"backup_{HANDLE}")
OUT_DIR.mkdir(exist_ok=True)
LOG = open(OUT_DIR / "manifest.jsonl", "a")

def enumerate_media() -> list[dict]:
    items, cursor = [], None
    while True:
        params = {"userName": HANDLE}
        if cursor: params["cursor"] = cursor
        r = requests.get(f"{BASE}/twitter/user/last_tweets",
                         headers=HEADERS, params=params, timeout=15)
        r.raise_for_status()
        resp = r.json()
        for t in resp.get("tweets", []):
            for i, m in enumerate(t.get("media", [])):
                if m["type"] == "photo":
                    items.append({"kind": "photo", "url": m["media_url_https"],
                                  "tweet_id": t["id"], "index": i, "ext": "jpg"})
                elif m["type"] in ("video", "animated_gif"):
                    variants = m.get("video_info", {}).get("variants", [])
                    mp4s = [v for v in variants
                            if v["content_type"] == "video/mp4"]
                    if mp4s:
                        best = max(mp4s, key=lambda v: v.get("bitrate", 0))
                        items.append({"kind": "mp4", "url": best["url"],
                                      "tweet_id": t["id"], "index": i,
                                      "ext": "mp4"})
                    else:
                        hls = next((v for v in variants
                                    if v["content_type"] ==
                                    "application/x-mpegURL"), None)
                        if hls:
                            items.append({"kind": "hls", "url": hls["url"],
                                          "tweet_id": t["id"], "index": i,
                                          "ext": "mp4"})
        cursor = resp.get("next_cursor")
        if not cursor: break
    return items

def fetch_direct(item: dict) -> dict:
    fname = f"{item['tweet_id']}_{item['index']}.{item['ext']}"
    dest = OUT_DIR / fname
    if dest.exists():
        return {"item": item, "result": "skip", "bytes": dest.stat().st_size}
    for attempt in range(3):
        try:
            r = requests.get(item["url"], stream=True, timeout=30)
            if r.status_code == 404:
                return {"item": item, "result": "404_deleted"}
            r.raise_for_status()
            with open(dest, "wb") as f:
                for chunk in r.iter_content(64 * 1024):
                    f.write(chunk)
            return {"item": item, "result": "ok",
                    "bytes": dest.stat().st_size}
        except Exception as e:
            time.sleep(2 ** attempt)
    return {"item": item, "result": "exhausted"}

def fetch_hls(item: dict) -> dict:
    fname = f"{item['tweet_id']}_{item['index']}.mp4"
    dest = OUT_DIR / fname
    if dest.exists():
        return {"item": item, "result": "skip", "bytes": dest.stat().st_size}
    try:
        subprocess.run(["yt-dlp", "-o", str(dest), "--quiet",
                        "--no-warnings", item["url"]],
                       check=True, timeout=300)
        return {"item": item, "result": "ok",
                "bytes": dest.stat().st_size}
    except Exception as e:
        return {"item": item, "result": f"hls_fail:{e}"}

items = enumerate_media()
print(f"found {len(items)} media objects")
direct = [i for i in items if i["kind"] != "hls"]
hls = [i for i in items if i["kind"] == "hls"]
total_bytes = 0
with ThreadPoolExecutor(max_workers=6) as pool:
    for f in as_completed({pool.submit(fetch_direct, i): i for i in direct}):
        res = f.result()
        LOG.write(json.dumps(res, default=str) + "\n")
        total_bytes += res.get("bytes", 0)
for item in hls:
    res = fetch_hls(item)
    LOG.write(json.dumps(res, default=str) + "\n")
    total_bytes += res.get("bytes", 0)
LOG.close()
print(f"total downloaded: {total_bytes / 1024**2:.1f} MB")
08 — Questions

Questions readers ask

How do I download a specific Twitter video by URL?

Given a tweet URL like https://x.com/handle/status/123456, extract the tweet ID 123456, then call /twitter/tweet/lookup?ids=123456 on twitterapi.io. Parse the response's media[].video_info.variants, pick the highest-bitrate MP4 variant, and GET that URL to save the bytes. Full code in Step 1 above. For one-off downloads from the shell, yt-dlp handles the full workflow.

Why does the API return an m3u8 URL for some videos but MP4 for others?

X's video encoding pipeline outputs both MP4 variants (multiple bitrates for direct download) and an HLS manifest (adaptive streaming). Newer or longer videos sometimes ship HLS-only; older / shorter videos have MP4 variants. When both are present, prefer the highest-bitrate MP4 for local storage. When only HLS is present, use yt-dlp or ffmpeg -i url.m3u8 -c copy out.mp4 to concatenate segments into a single MP4.

What's the safe concurrency for bulk-downloading from pbs.twimg.com?

4-8 concurrent workers per IP is the safe baseline. Higher works sometimes but sees connection resets and occasional 429 responses on sustained batches. If you're downloading 10K+ files and want to go faster, use a distributed pool with worker-per-IP rotation — but for anything under 10K, single-IP 6 workers gets you full account backup in 1-2 hours.

Can I download media from private accounts?

Same rules as reading their tweets — the API respects X's visibility model. Private-account media is only reachable if you're authenticated as a user who follows the private account. twitterapi.io read enumeration + subsequent CDN fetches both work under this constraint. Public accounts + your own account = fully open.

How do I keep media downloads incremental (not re-download files I already have)?

Check dest.exists() before fetching (code above shows this). For cross-run dedup, compute sha256 during download and store in a manifest.jsonl at the archive root. Before adding a new file, check the manifest for an existing hash match. This catches the case where the same media appears in multiple tweets (retweets, quotes) — save the file once, reference it multiple times in the tweet-level manifest.

What happens if a tweet is deleted between enumeration and download?

The CDN URL returns 404 for deleted-tweet media. The code above catches this as result: '404_deleted' and logs it — no retry, no exception, next file. Deleted-tweet 404s in the log are useful signal: they tell you what disappeared during your archival run. Rare in a 30-minute run; more common if enum + download are split over days.

Does downloading Twitter media at scale violate any terms?

Downloading media from tweets you have API access to view is standard supported usage. The CDN is public infrastructure for a reason — media loads fast because X wants it fast. Terms concerns arise when: (a) you're re-hosting media commercially without licensing (that's a copyright issue, not a rate-limit one), (b) you're aggressively scraping via non-API paths that hammer pbs.twimg.com without going through the enumeration API. Stay on the API path + reasonable concurrency and you're on solid ground.

09 — 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
    Download Twitter (X) Media — API Bulk Guide | TwitterAPI.io