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.
Which route gives you what
| Route | What you give it | What comes back |
|---|---|---|
| tweet/detail | one numeric tweet id | the resolved post with author, counts, quoted tweet and attached media, $0.0008 |
| user/media | a handle and a cursor | a page of media posts, each already carrying extended_entities.media, $0.0008 |
| user/tweets | a handle and a cursor | the whole timeline including posts with no attachment at all, $0.0008 |
| media/upload | base64 image bytes in the JSON body | a media_id for attaching to your own post, $0.0008 |
| media/status | a media_id you pushed yourself | state, progress_percent and check_after_secs, never a download URL, $0.0008 |
| the media host address | nothing, the read already gave you the URL | the file bytes over ordinary HTTPS, not metered |
Each media object may have multiple display or playback variants, with different resolutions or formats.
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 a pooled account, 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.
Keep reading
Start with $0.50 in free credits
No credit card. Roughly 12,500 tweets to test every endpoint.