Skip to content

How do you download a video from a tweet with an API?

Last updated August 24, 2026

GET tweet/detail resolves one tweet id into its full object, attached media included, for $0.0008. A video attachment arrives as a media entry with playback variants at several bitrates and formats; you read the URL of the variant you want and fetch those bytes yourself over plain HTTPS. To sweep a whole profile, page user/media instead.

Every rate here is the pricing TwitterAPIs publishes. The billed rate is $0.0008 per call; $0.04 per 1,000 tweets is derived from it at a full 20-tweet page, which is the default page size rather than a guaranteed yield (source: twitterapis.com/pricing).

Start from the tweet object

tweet/detail takes a numeric id and returns the resolved post: author, engagement counts, any quoted tweet, and the attached media. It is the starting point for every single-post workflow, and one flat read covers all of it rather than making you stitch two calls together to get the file and the context. If all you have is a link, take the digits after /status/ and pass those. The response nests everything under a tweet key alongside a top-level id that echoes what you asked for, which makes it straightforward to key results by request when you are running a queue of ids through a worker pool and responses come back out of order. Passing a URL fragment or a handle instead of the digits is the usual first mistake, and it fails outright rather than guessing at what you meant.

Finding the media on the object

Attachments live in the extended_entities.media array on the tweet. Each entry declares its type, so a still image and a video are distinguishable before you touch anything else, and a still carries its file directly at media_url_https with no further resolution needed. A post with no attachment simply has no such array, which is worth branching on explicitly rather than assuming every id in your queue has something to fetch. Reading type first also keeps a photo-only sweep from wandering into playback logic it does not need, and it is the field to filter on when a profile mixes images and clips freely, which most active accounts do. The array is also where a multi-image post appears as several entries rather than one, so iterate it instead of reading the first element and moving on.

Choosing a playback variant

Video does not ship as a single file. X publishes the same clip several times over at different bitrates and container formats so that players can pick according to connection quality, which is exactly what a browser does silently when the post is viewed on a phone. Archival work wants the largest bitrate on offer; a preview thumbnail strip is happier with the smallest one available. Sort the variants numerically before choosing, because their order in the array is not a ranking and treating position as quality will eventually hand you a low-resolution file that looked perfectly fine in testing because the one sample post you tried happened to list them favourably. Record which variant you took alongside the file, because two archives built under different selection rules are not comparable to each other later.

You fetch the bytes, we do not

What the API returns is a URL on X's own media hosts. Downloading is then an ordinary HTTPS GET from your side, the same one your HTTP client already makes for anything else, and no call is metered for it. That also means transfer speed, retry policy and disk writes are entirely yours to tune, which is usually what you want for large files and long-running archive jobs. A signed or expiring address should be fetched promptly rather than parked in a queue for hours, so resolve close to the point of download rather than resolving a whole back catalogue up front and working through the list days later. Rate-limiting your own downloads is worth doing as well, since nothing on this side throttles a fetch you make directly against a media host.

Sweeping every clip on a profile

One id at a time does not scale to a back catalogue. user/media takes a handle and returns the posts that actually carry attachments, cursor-paginated, and each row already includes its extended_entities.media array with the media URLs in place. That means the sweep needs no per-post lookup at all: page the handle, filter rows whose media type is video, and queue the playback URLs straight from the listing you already paid for. It is the difference between one read per page of results and one read per clip, which on a profile with hundreds of media posts is effectively the entire cost of the job. The listing is also the cheaper way to discover what a profile even holds, because it leaves out text-only posts before you pay to look through them.

Paging user/media correctly

The listing is cursor-paginated in the same shape as the other timeline reads. Each response carries count for the rows in hand and next_cursor for the page after it, and you pass that cursor straight back on the following request. Stop when next_cursor comes back empty or null rather than when a page looks short, since a page holding fewer rows than you expected is not by itself the end of the timeline and stopping early silently truncates the sweep. Store the cursor alongside your results if the run might be interrupted, so a restart resumes from where it stopped rather than re-reading pages you have already paid for. Log the page number and the cursor together, so an interrupted run tells you where it stopped rather than only that it stopped somewhere.

What else the tweet read hands you

Because tweet/detail returns the whole object, a download job gets metrics for free in the same call. favorite_count, retweet_count, reply_count and view_count all sit on the resolved tweet, alongside created_at and the author block with its id, username and name. If you are archiving clips and also want to rank them later, there is no second read to make and no join to write; capture the counts at fetch time and record when you captured them, since they keep moving on X after your snapshot and a number with no timestamp beside it is not much use to anyone reading the archive months afterwards. The quoted tweet arrives on the same object too, which matters when the clip you actually want is attached to the quoted post rather than the quoting one.

media/status belongs to uploading, not downloading

It is easy to mistake this route for part of a download path given the name, and worth stating plainly that it is not. media/status reports whether a media_id you pushed with media/upload has finished processing, returning state as pending, in_progress, succeeded or failed, plus progress_percent from 0 to 100, a check_after_secs interval to wait before polling again, and an error string present only when the state is failed. It answers a question about your own pending upload and never about somebody else's published clip, and it returns no download address of any kind at any point in that cycle. Poll it on the interval it gives you rather than on a timer of your own, since check_after_secs drops to zero once processing has finished.

What a profile sweep actually costs

Both reads bill at the standard rate of $0.0008. A single tweet/detail lookup is one call. A profile sweep is one call per page you turn, whatever number of media rows that page happens to hold, so twenty pages costs $0.016 and a hundred pages costs $0.08. The byte transfer that follows is unmetered because it happens directly between your machine and X, with nothing sitting in the middle. That makes the arithmetic unusually simple: your bill is the number of pages you turned, and the file sizes never enter into it at all, however large the clips turn out to be. Working from the media listing rather than from per-post lookups is the single decision that moves a back-catalogue job from dollars down to cents. That figure is ours: $0.0008 a call, per our pricing page.

Process the video in three steps

The download is three steps. First, resolve the post: GET /twitter/tweet/detail with url set to the post link or id set to its number returns the tweet object for $0.0008. Second, pick the rendition: read tweet.media, keep entries whose type is video or animated_gif, and from video_info.variants keep those whose content_type is video/mp4, then take the one with the highest bitrate. Third, fetch the bytes: a plain HTTPS GET on that variant's url returns the MP4 file, which you write to disk. Steps one and two are API work, step three is ordinary HTTP to X's media host, and none of it needs a browser, a login or a headless scraper. Check the host of the variant URL before fetching, as the reference advises, and handle a post with no media key by skipping it rather than failing the batch.

The quality ladder: which variant you get

A video post carries several renditions, and the variants array is the quality ladder. You typically see one HLS playlist with content_type application/x-mpegURL and no bitrate, plus a few MP4 files at rising bitrates, each sized for a different screen. Bitrate is in bits per second, so sort the MP4 entries by it and take the top one for the best quality, or a middle one when file size matters more than sharpness. The HLS playlist is for streaming players; for a single file you can save, use an MP4 variant. Audio is included in the MP4 renditions of a video. An animated_gif is delivered as a silent MP4 with one variant, so the same code path saves it. duration_millis tells you the clip length before you download anything, which is handy for skipping long videos in a batch job.

Downloading media from many posts with the API

One post at a time is fine by hand, but a list of hundreds is where an API earns its keep. For a list of links, call tweet/detail once per link and run the calls in parallel within the key's ceiling of 600 requests a minute and 20 concurrent. For everything one account posted, page user/media, which returns the posts from the account's Media tab about 20 at a time, each with its media attached. For a topic, run tweet/advanced_search with filter:videos or product=Videos to find posts carrying video, then read the variants straight from the results. Every route returns the same tweet object, so one parser handles all three. Store the post id with each file name, so you can trace any clip back to its source and skip ones you already have on a re-run.

Free tools versus a paid API

Free web downloaders are the right tool for one clip: paste a link, click, save. They stop being the right tool when the job repeats, because they are built for a person at a browser, not for code. Most have no documented interface, change without notice, cap usage, or wrap the download in ads. A paid API trades that for a stable contract: a JSON response you can parse, a key, and a known price. Here that price is $0.0008 for one tweet/detail call, so a thousand clips cost about 80 cents in API reads, and the file transfer itself costs nothing extra because you fetch the bytes yourself. New accounts get a $0.50 credit, enough for 625 lookups, so a small batch costs nothing to try.

Copy the post link and pass it as url

You do not need to extract the numeric id yourself. Copy the post link from the share menu in the X app or the address bar, in the form https://x.com/<handle>/status/<id>, and pass it as the url parameter on tweet/detail. The endpoint accepts either id or url, so the copied link works as is. Links that still say twitter.com carry the same status id, which you can pass as id instead. If you collect links from users, strip tracking parameters such as ?s=20 before storing them, since the status id is all the endpoint needs. A link to a quote post returns the quoting post, and if the video is attached to the quoted post instead, you will find it under quoted_tweet.media rather than at the top level, so check both places.

A Twitter MP4 downloader in 20 lines

Put together, a working Twitter MP4 downloader is about twenty lines of Python. Read TWITTERAPIS_KEY from the environment. Call https://api.twitterapis.com/twitter/tweet/detail with params url=<post link> and the header Authorization: Bearer <key>. From the JSON, take tweet.media, or tweet.quoted_tweet.media when the top level is empty. For the first item whose type is video or animated_gif, filter video_info.variants to content_type video/mp4 and pick max by bitrate. Stream that url to a file named after the post id with requests.get(url, stream=True) and iter_content. Wrap it in a loop over a text file of links and you have a batch downloader. Keep the response JSON next to each file if you want the caption, author and posting date later, since they came back in the same call at no extra cost.

Which route gives you what

RouteWhat you give itWhat comes back
tweet/detailone numeric tweet idthe resolved post with author, counts, quoted tweet and attached media, $0.0008
user/mediaa handle and a cursora page of media posts, each already carrying extended_entities.media, $0.0008
user/tweetsa handle and a cursorthe whole timeline including posts with no attachment at all, $0.0008
media/uploadbase64 image bytes in the JSON bodya media_id for attaching to your own post, $0.0008
media/statusa media_id you pushed yourselfstate, progress_percent and check_after_secs, never a download URL, $0.0008
the media host addressnothing, the read already gave you the URLthe file bytes over ordinary HTTPS, not metered
Each media object may have multiple display or playback variants, with different resolutions or formats.
X Developer Platform, media upload documentation. Source

Questions and answers

Which endpoint gives me a playable video URL?
tweet/detail for a known id, user/media for a whole profile. Both return the tweet object with its extended_entities.media array, and the playback addresses sit on the media entry itself. Neither route transcodes nor proxies anything; they hand you the locations X already serves, which your own client then requests directly over ordinary HTTPS with no further involvement from this side. Both bill at the standard read rate, so choosing between them is a question of how many posts you need rather than of price.
How do I pick between the variants?
By bitrate. The same clip is published several times over at different qualities and container formats, so decide what the file is actually for before choosing one. Keep the top bitrate for archival or re-editing, take a lower one when a preview is all you need, and never rely on array order to tell you which entry is which, because position is not a ranking. Write the chosen bitrate into your own records so a later comparison is against a known quality rather than a guess.
What if the post has no attachment at all?
Then there is no extended_entities.media array on the object, and code that reaches straight into it will fail rather than return nothing useful. Branch on the array being present before you look for a type, especially when the queue came from a general timeline read rather than from the media listing, which only ever returns posts that actually carry attachments. That check also catches the quoted-post case, where the media sits on the quoted tweet rather than on the one you asked for.
Does the response tell me photo or video?
Yes, each media entry declares its type, so you can separate stills from clips before touching any playback logic at all. A still also carries its file directly at media_url_https, which means a photo-only job never needs variant selection in the first place. Filtering on type is the cheapest possible first step in any mixed sweep of an active profile. Filter on it before anything else and the rest of the job only ever sees rows it can actually handle.
Does TwitterAPIs host the file?
No. The URLs point at X's own media hosts, and the actual transfer happens between your machine and them with nothing in between. Nothing about that download is metered here, so the only spend is the read that surfaced the address in the first place. Bandwidth, retries and storage all stay firmly on your side of the wire. Throttle your own fetching accordingly, because nothing here limits a request you make straight to a media host.
Is media/status part of downloading?
No, it belongs to the upload path despite the name. It reports whether a media_id you pushed with media/upload has finished processing, returning state, progress_percent and a check_after_secs interval to wait before polling again. It answers a question about your own pending upload, never about somebody else's published clip, and it returns no download address at any stage. Poll it on the check_after_secs interval it hands back rather than on a timer you picked yourself.
How do I grab every clip from an account?
Page user/media with the handle, following next_cursor until it comes back empty, and collect the rows whose media type is video. Because the listing already embeds the media array, no follow-up lookup per post is needed, which keeps a back-catalogue sweep at one read per page of results rather than one read per individual clip you end up downloading. Store the cursor with each batch so an interrupted run is resumable rather than merely restartable.
How many calls does a profile sweep take?
One per page you turn, at the standard $0.0008 rate. Twenty pages is $0.016 regardless of how many media rows each page happened to hold, and the byte transfer afterwards is unmetered entirely. Store next_cursor alongside your results so an interrupted sweep resumes at the right page instead of re-reading and re-paying for earlier ones you already have. The only thing that grows the bill is the page count, never the size of the clips you download.
Do I get engagement numbers in the same call?
Yes. tweet/detail returns the whole object, so favorite_count, retweet_count, reply_count and view_count arrive together with the media, alongside created_at and the author block. Record the moment you captured those numbers, because they keep moving on X afterwards and a count with no timestamp beside it is hard to reason about when you revisit the archive. That makes one read enough for both the archive and the ranking pass that usually follows it.
Can I re-post a clip I downloaded?
Uploading runs through a different route entirely. media/upload accepts base64-encoded image bytes in the JSON body and returns a media_id, and for video or GIF you poll media/status until state reads succeeded before attaching that id to a post. Both act as your own registered session rather than as a session-free read, so a session has to be in place first. Attaching before state reads succeeded is the common error, and it fails at the post rather than at the upload.

Start with $0.50 in free credits

No credit card. Roughly 12,500 tweets to test every endpoint.