Twitter video API: get the MP4 URL of any X video
A Twitter video's MP4 URL sits in the post's media data: X encodes each upload into up to five MP4 renditions, from 270p to 1080p or more, and lists every file's URL with its bitrate.
Short answer: Take the MP4 with the highest bitrate; a GIF is one silent MP4. The official X API lists renditions only with media.fields=variants, at $10.00 per 1,000 video posts from November 22, 2026. Sorsa API returns each post's video link through POST /tweet-info-bulk, 100 posts per request from $0.014 per 1,000, and 100 free requests cover the first 10K.
- Endpoint
POST /tweet-info-bulk- Page size
- 100 posts per request
- Price
- from $0.014 per 1,000
#How X stores a video: renditions and bitrates
X never serves the file the author uploaded. It re-encodes every video into a ladder of MP4 files, one per size, and an HLS playlist (an .m3u8 index that video players use to switch sizes during playback). Each MP4 is a complete file with sound, so any single one downloads and plays on its own. For one post, the free Twitter Video Downloader saves the file from a pasted link; this guide is for getting the files in code.
On November 2, 2026 we read the rendition lists of 23 public posts from 2017 to 2026 and measured the 72 MP4 files X served for them. The bitrate X lists for each file is a ceiling, and the real files run three to five times leaner:
| Rendition | Frame size, pixels | Bitrate X lists | Real average bitrate |
|---|---|---|---|
| 270p | 480 × 270 | 256 kbps | about 91 kbps |
| 360p | 640 × 360 | 832 kbps | about 233 kbps |
| 720p | 1280 × 720 | 2,176 kbps | about 626 kbps |
| 1080p | 1920 × 1080 | 10.4 Mbps | about 2,172 kbps |
270p
- Frame size, pixels
- 480 × 270
- Bitrate X lists
- 256 kbps
- Real average bitrate
- about 91 kbps
360p
- Frame size, pixels
- 640 × 360
- Bitrate X lists
- 832 kbps
- Real average bitrate
- about 233 kbps
720p
- Frame size, pixels
- 1280 × 720
- Bitrate X lists
- 2,176 kbps
- Real average bitrate
- about 626 kbps
1080p
- Frame size, pixels
- 1920 × 1080
- Bitrate X lists
- 10.4 Mbps
- Real average bitrate
- about 2,172 kbps
What the measurements show:
- The highest bitrate is the best file. Sort the MP4 entries by bitrate and take the top one. The playlist entry, typed
application/x-mpegURL, carries no bitrate and is an index, not a file. - Size depends on the frame shape. Vertical videos get 320 × 568 up to 1080 × 1920, square ones 320 × 320 up to 1080 × 1080, and one 1936 × 1548 upload got a fifth file at 25.1 Mbps.
- Not every 1080p upload gets a 1080p file. Of seven 1920 × 1080 uploads from 2025 and 2026, two stopped at 720p, and most posts from before 2022 top out at 720p.
- The playlist splits sound from picture. Its video streams are silent; audio comes as separate tracks at 32, 64 and 128 kbps, with subtitles on some videos.
A GIF is simpler: X keeps one silent, looping MP4 at the GIF's own size, with no other renditions and no playlist.
#Get a video's MP4 URL with Sorsa API
Sorsa API, a Twitter (X) API and scraper, returns every post with its media attached: each video, GIF and photo is an entry in the post's entities list with a type, a direct link and a preview image. One API key in a header replaces the developer account and OAuth that the official X API asks for.
-
Get a key. Sign up and copy the API key from your dashboard. The first 100 requests are free, with no card.
-
Send the post's link.
POST /tweet-info(opens in a new tab) takes a post URL or a bare ID intweet_link.Terminal curl -X POST "https://api.sorsa.io/v3/tweet-info" \ -H "ApiKey: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"tweet_link": "https://x.com/northfield_video_lab/status/1984519203377641472"}'Response200 OK { "id": "1984519203377641472", "created_at": "2026-10-31T16:04:11Z", "full_text": "First ride on the new gravel loop.", "entities": [ { "type": "video", "link": "https://video.twimg.com/amplify_video/1984519110025023488/vid/avc1/480x270/Qm3vTb8xLwK2pRfA.mp4?tag=21", "preview": "https://pbs.twimg.com/amplify_video_thumb/1984519110025023488/img/Hx2LkP9aQwErT0bn.jpg" } ], "user": { "username": "northfield_video_lab", "display_name": "Northfield Video Lab", "media_count": 214 } } -
Read the video entries. Keep entities whose
typeisvideoand whoselinkis on video.twimg.com. A/tweet_video/path in the link marks a GIF. -
Download the file. The MP4 is a plain HTTPS file on X's servers: no key, no cookie, and no request counted.
import requests API = "https://api.sorsa.io/v3/tweet-info" HEADERS = {"ApiKey": "YOUR_API_KEY"} def video_links(post): r = requests.post(API, headers=HEADERS, json={"tweet_link": post}, timeout=60) r.raise_for_status() # 403: no key, or no requests left tweet = r.json() for e in tweet.get("entities") or []: if e.get("type") == "video" and "video.twimg.com" in e["link"]: yield tweet["id"], e["link"] links = video_links("1984519203377641472") for n, (post_id, link) in enumerate(links, 1): with open(f"{post_id}_{n}.mp4", "wb") as f: f.write(requests.get(link, timeout=120).content)
The link is an MP4 with sound that plays anywhere. We ran two videos through our free downloader, which runs on Sorsa API, on November 2, 2026, and both links were X's 270p rendition. For the 82-second one that meant 1.1 MB: light enough for previews, transcripts, frame sampling and large archives. A GIF's link is its only file, so it arrives whole.
The preview doubles as a full-size frame: add ?format=jpg&name=orig to it (drop the .jpg first) and X returns the frame at the upload's own size, 1936 × 1548 pixels for one of our test posts. Photos come in the same list with type photo; getting each one at its original size, for one post or a whole account, is covered in the Twitter image scraper guide.
For a list of posts, send up to 100 links or IDs in tweet_links to POST /tweet-info-bulk (opens in a new tab). The response is {"tweets": [...]} with the same entities in every post, and the whole batch is one request: from $0.014 per 1,000 posts.
#Download every video from an account or a search
Search finds the video posts for you. X's search operator filter:native_video keeps posts with a video uploaded to X, and card_name:animated_gif keeps GIFs. POST /search-tweets (opens in a new tab) returns about 20 matching posts per request, with search reaching back to 2006.
| Job | Query |
|---|---|
| Every video of one account | from:northfield_video_lab filter:native_video |
| Every GIF of one account | from:northfield_video_lab card_name:animated_gif |
| Videos on a topic in one month | "gravel racing" filter:native_video since:2026-10-01 until:2026-11-01 |
| Popular videos only | #bikepacking filter:native_video min_faves:1000 |
| Videos and YouTube links | trail build filter:videos |
Every video of one account
- Query
from:northfield_video_lab filter:native_video
Every GIF of one account
- Query
from:northfield_video_lab card_name:animated_gif
Videos on a topic in one month
- Query
"gravel racing" filter:native_video since:2026-10-01 until:2026-11-01
Popular videos only
- Query
#bikepacking filter:native_video min_faves:1000
Videos and YouTube links
- Query
trail build filter:videos
Every operator, with examples, is in the guide to Twitter search operators. The script below runs one query from the first page to the last.
-
Save the script. It pages the search with
next_cursor, a token that marks where the next page starts, until the cursor comes back empty. It writes every post's videos and GIFs to<post id>_<n>.mp4and logs each file invideos.csv.x_videos.py import csv import os import subprocess import sys import time import requests API = "https://api.sorsa.io/v3/search-tweets" HEADERS = {"ApiKey": "YOUR_API_KEY"} def search(body): while True: r = requests.post(API, headers=HEADERS, json=body, timeout=60) if r.status_code == 429: # over 20 requests a second: wait time.sleep(1) continue r.raise_for_status() # 403: no key, or no requests left return r.json() def videos(tweet): """(kind, link) for every video and GIF in a post.""" for e in tweet.get("entities") or []: link = e.get("link") or "" if e.get("type") == "video" and "video.twimg.com" in link: yield ("gif" if "/tweet_video/" in link else "video"), link def save(link, path): with requests.get(link, stream=True, timeout=120) as r: r.raise_for_status() with open(path, "wb") as f: for chunk in r.iter_content(1 << 16): f.write(chunk) def save_hd(post_url, n, path): """The post's highest-bitrate MP4 through yt-dlp; False if yt-dlp fails.""" cmd = ["yt-dlp", "-f", "best[protocol=https]", "--playlist-items", str(n), "-o", path, post_url] done = subprocess.run(cmd, capture_output=True).returncode == 0 return done and os.path.exists(path) def run(query, folder, hd=False): os.makedirs(folder, exist_ok=True) state = os.path.join(folder, "cursor.txt") body = {"query": query, "order": "latest"} if os.path.exists(state): # resume a stopped run body["next_cursor"] = open(state).read().strip() log_path = os.path.join(folder, "videos.csv") with open(log_path, "a", newline="", encoding="utf-8") as f: log = csv.writer(f) while True: page = search(body) for t in page.get("tweets", []): for n, (kind, link) in enumerate(videos(t), 1): path = os.path.join(folder, f"{t['id']}_{n}.mp4") if os.path.exists(path): continue url = f"https://x.com/{t['user']['username']}/status/{t['id']}" if not (hd and kind == "video" and save_hd(url, n, path)): save(link, path) log.writerow([t["id"], t["created_at"], kind, os.path.getsize(path), path]) cursor = page.get("next_cursor") if not cursor: break body["next_cursor"] = cursor with open(state, "w") as s: s.write(cursor) if os.path.exists(state): os.remove(state) if __name__ == "__main__": run(sys.argv[1], sys.argv[2], hd="--hd" in sys.argv[3:]) -
Run it with a query and a folder.
python x_videos.py "from:northfield_video_lab filter:native_video" northfieldsaves the account's videos into the foldernorthfield. A 403 means the key is missing or out of requests; a 429 means more than 20 requests in one second, and the script waits a second and retries. -
Run it again any time. Files already on disk are skipped and the cursor is saved after each page. A stopped run picks up where it left off, and a rerun adds only new videos.
A whole account's videos take few requests: at about 20 posts per page, an account with 300 videos is about 15 requests, because the search returns only the posts that carry one. For text and every other post field in a spreadsheet, see how to download all tweets from a user.
#Get the 720p or 1080p file of a post
For a post's 720p or 1080p file, hand its link to yt-dlp, the open-source command-line downloader, which reads public posts without an X login. The format filter below picks the MP4 with the highest bitrate:
yt-dlp -f "best[protocol=https]" -o "%(id)s.%(ext)s" "https://x.com/northfield_video_lab/status/1984519203377641472"
Add --hd to the script above and it does this for every video, falling back to the API's link when yt-dlp fails. On our 82-second test video, yt-dlp 2026.10.26 picked the 1920 × 1080 file. Its format table, run with -F, listed that file at about 100 MiB, because the table multiplies the listed bitrate by the length; the download weighed 22.2 MB. yt-dlp fetches one post at a time from your own address, so Sorsa API does the finding in bulk and yt-dlp only the HD files you need.
Good to know
The official X API returns the whole rendition list in one response, but only if you ask. Add
expansions=attachments.media_keysandmedia.fields=variantsto a post lookup, and each video comes back inincludes.mediawith avariantsarray ofbit_rate,content_typeandurl. It needs a developer account and OAuth, and from November 22, 2026 each media object in that answer is billed at $0.005 on top of the $0.005 post read.
#Twitter GIFs in the API
A GIF posted on X comes back as an MP4, because X converts every GIF into a short, silent, looping video when it is posted. Its link sits on video.twimg.com/tweet_video/, X keeps no other size, and that one file is the best copy there is. In X's own data it is typed animated_gif with a bitrate of 0; in Sorsa API's entities it comes as a video whose link has that /tweet_video/ path.
To turn it back into a .gif file, run ffmpeg with a palette pass, which keeps the colors clean:
ffmpeg -i 1211797914437259264_1.mp4 \
-vf "fps=15,split[a][b];[a]palettegen[p];[b][p]paletteuse" \
-loop 0 1211797914437259264.gif
Expect a much bigger file: in our test an 83 KB MP4 became a 1.76 MB GIF at the same 498 × 272 pixels, 21 times the size. Keep the MP4 for storage and players, and make the GIF only where a page or a chat needs one. For a single GIF without code, the free Twitter GIF Downloader saves it from a post link.
#What it costs, in requests and in storage
On Sorsa API a post costs the same whatever media it carries. Posts looked up by ID cost from $0.014 per 1,000, 100 per request, and posts found through search from $0.07 per 1,000, about 20 per request. The video files download from X's servers and cost no requests. On the official X API every video arrives through the media expansion, and from November 22, 2026 X bills expansion objects (opens in a new tab) at the standard rate. A video post then costs $0.005 for the post plus $0.005 for its media: $10.00 per 1,000.
| Way to get the video links | What it needs | Cost per 1,000 video posts |
|---|---|---|
| Sorsa API, posts by ID | One API key | from $0.014 |
| Sorsa API, search | One API key | from $0.07 |
| Official X API | Developer account and OAuth | $10.00 from Nov 22, 2026 |
Sorsa API, posts by ID
- What it needs
- One API key
- Cost per 1,000 video posts
- from $0.014
Sorsa API, search
- What it needs
- One API key
- Cost per 1,000 video posts
- from $0.07
Official X API
- What it needs
- Developer account and OAuth
- Cost per 1,000 video posts
- $10.00 from Nov 22, 2026
Official X API
$10,000
for 1M video posts
$0.010 per video post, from Nov 22
Sorsa API
from$14
for 1M video posts
from $0.014 per 1,000
714x cheaper
The official X API's pay-per-use pricing (opens in a new tab) also stops at 3M post reads per billing cycle, and above that X sells only Enterprise contracts. Sorsa API runs 20 requests per second on every plan, so batches of 100 posts look up 2,000 video posts a second, and failed requests are never charged.
Storage is the bigger bill for a video archive, and it depends on the rendition far more than on the count. At the real average bitrates we measured, 1,000 thirty-second videos take about 0.3 GB at 270p, 2.3 GB at 720p and 8.1 GB at 1080p. Set your own length and count:
| Rendition | Per video | All videos | Ceiling |
|---|---|---|---|
| 270p | 0.34 MB | 341 MB | 0.96 MB |
| 360p | 0.87 MB | 874 MB | 3.1 MB |
| 720p | 2.3 MB | 2.3 GB | 8.2 MB |
| 1080p | 8.1 MB | 8.1 GB | 38.9 MB |
Per video: the median real bitrate per rendition (91, 233, 626 and 2,172 kbps). Ceiling: the bitrate X lists (256, 832 and 2,176 kbps, and 10.4 Mbps) times the length.
Sizes per video come from the real average bitrates of 63 MP4 files measured on X's servers on November 2, 2026; the ceiling uses the bitrate X lists.
#Start pulling X videos in code
To get the MP4 URLs of X videos and GIFs in code, use Sorsa API: one request returns up to 100 posts with each video's link, from $0.014 per 1,000. Search finds every video of an account or a topic back to 2006, and your X account is never involved. Copy the script, add your key, and your 100 free requests return the links of your first 10K video posts.
#FAQ
Can the official X API return Twitter video URLs?
Yes. Add expansions=attachments.media_keys and media.fields=variants to a post lookup or search, and each video in includes.media lists its MP4 renditions with bit_rate and url; take the highest bitrate. Without those two parameters you get only a preview image. It needs a developer account and OAuth, and from November 22, 2026 each media object adds $0.005 to the $0.005 post read.
Which API should I use to get Twitter video URLs?
Use Sorsa API. POST /tweet-info-bulk returns up to 100 posts per request with each video's MP4 link and preview, from $0.014 per 1,000 posts. The official X API charges $10.00 per 1,000 video posts from November 22, 2026. Sorsa API needs one API key instead of a developer account and OAuth, finds videos through search back to 2006 and runs at 20 requests per second on every plan.
Why does my downloaded Twitter video look blurry?
You most likely saved one of the smaller renditions. X keeps every video as up to five MP4 files, from 270p to 1080p or more, and some tools and APIs hand over the first or lightest one. Pick the MP4 with the highest bitrate in the list. If the best file is still 720p, the upload got no 1080p rendition: two of seven 1080p uploads we checked from 2025 and 2026 stopped at 720p.
Can I download videos from private accounts or deleted posts?
No. A protected account's posts are visible only to the followers it approves, so no API returns its videos, Sorsa API included, and a request for them is never charged. Deleted posts drop out of X's search and lookups, so no API can list them either; only a copy saved before the deletion keeps the video.
Is it legal to download videos from X?
Yes. Saving public videos for research, moderation or an archive is legal and common. The copyright stays with whoever made each video, so get the owner's permission before you publish or resell the files. Sorsa API reads public posts through its own API, so your X account takes no part in it.
#How we checked
On November 2, 2026 we read the rendition lists X publishes for 23 public video and GIF posts from 2017 to 2026 and measured the 72 of their 75 MP4 files that video.twimg.com served. For one video we also read its HLS playlist and listed its formats with yt-dlp 2026.10.26, logged out. We ran three of the posts through our free downloader, which runs on Sorsa API, to see the links it returns. The script ran against a stand-in for the API that served those real links, and the ffmpeg line ran on a real GIF file. Endpoints and fields come from the live Sorsa API reference; X's media fields and prices come from docs.x.com and X's notice of October 23, 2026, read the same day.
Sources
- Sorsa API reference: Tweet Data (opens in a new tab)
- Sorsa API reference: Tweet Data (Batch) (opens in a new tab)
- Sorsa API reference: Search Tweets (opens in a new tab)
- X API data dictionary: media object (opens in a new tab)
- X API pricing (opens in a new tab)
- X API PPU: Expansions Update, October 23, 2026 (opens in a new tab)
- yt-dlp on GitHub (opens in a new tab)