How do you download an account's entire tweet history?
Last updated August 24, 2026
user/tweets/complete takes a numeric user_id and a max, paginates inside the request, then returns the posts together rather than one cursor page at a time. It bills $0.0024 against $0.0008 for a standard timeline page, so 800 posts that would take 40 paged reads at $0.032 arrive in a single call for roughly a thirteenth of that.
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).
Three routes, and which one old posts come from
There are three ways to reach an account's back catalogue and they differ in who drives the loop. user/tweets pages a public timeline of original posts and reposts at $0.0008 a page, roughly twenty posts at a time, with your code holding the cursor and deciding when to stop. user/tweets_and_replies pages that same timeline with the account's replies included. user/tweets/complete is the premium route: it walks the history internally and hands back a large batch per call for $0.0024, whatever the volume turns out to be. If the question is how to get old posts specifically rather than recent ones, the premium route is the one that reaches backwards without dozens of round trips, and it reaches as far as X lets any timeline reach: about the most recent 3,200 posts per account. For a quiet account that can be years of history; for a prolific one it may be a few months, and anything older comes through date-window search instead.
What the archive call actually does differently
Instead of returning a cursor and making your code drive the walk, this endpoint paginates inside the request and returns a count alongside a tweets array, newest first. There is no next_cursor in the envelope at all, because there is no second page to fetch and no chain to resume. max defaults to 800 and controls how far back the internal walk reaches, so raising it buys history at the cost of wall clock time. The reference is explicit that large values can take tens of seconds, which makes this a job-shaped call rather than something to drop inside a request handler with a short timeout. It also means a failed call is retried whole rather than resumed, so pick a max you are willing to pay for twice.
It wants a user_id, never a handle
The required parameter is user_id, the numeric identifier such as 44196397, and the reference says plainly that it is not the @handle. If all you hold is a handle, resolve it through user/info first, which returns the full user object with id on it for one standard read. Cache that id against the account, because it is stable in a way the handle is not: someone can rename themselves tomorrow and the numeric id will not move. Every later archive run for that account then skips the lookup entirely and goes straight to the premium call. The reverse lookup exists too, user/info_by_id, for when you have the id from an archived row and want the current profile behind it.
Reaching one old window instead of everything
Sometimes the goal is not the whole history but a specific stretch of it, a launch week two years ago or the run-up to one announcement. That is a search job rather than an archive job. Put a from: clause and a date bound into the query parameter on tweet/advanced_search and you pay $0.0008 a page for exactly that window instead of pulling everything newer to reach it. Post ids are monotonically increasing and therefore time-sortable, so since_id: and max_id: bound the same range when you already know the id sitting at each edge. Pick the archive route when completeness is the requirement and the search route when precision is, and note that the search route lets you apply an engagement floor while the archive route does not.
Decide about replies before you archive
The plain timeline route covers original posts and reposts. Only user/tweets_and_replies is documented as also including the replies an account wrote to other handles, and its rows carry in_reply_to_username so you can separate the two kinds afterwards without guessing. That distinction matters more than it sounds: for a heavy replier, the replies are most of the volume, and an archive missing them is not a public record of anything. If a complete picture of public activity is the goal, plan a second pass rather than assuming one pull already holds it, then merge the two sets on the post id. Deciding this before the first call is cheaper than discovering it after you have stored a partial archive.
The fields you get on every row
Each archived row is a standard post object: id as a string, text with URLs already expanded, created_at in X's date format, and an author block holding id, username, name and the verification flags. Engagement counters ride along where the upstream source exposes them, favorite_count, retweet_count, reply_count and view_count. Those counters are snapshots taken when you fetched rather than values frozen at posting time, so an archive pulled today and the same archive pulled next year will show identical text against different numbers. Store the fetch time next to each row, or any comparison you run later is measuring two different moments and reporting the difference as growth. The text field being pre-expanded means outbound link analysis needs no extra resolution pass.
Arithmetic on a genuinely large account
An account sitting on 5,000 posts is roughly 250 cursor pages through the standard timeline route at about twenty a page, which comes to $0.20 and 250 separate round trips your code has to sequence. The premium route charges $0.0024 per call regardless of what a single call returns, and the documented lever for reaching further back is a larger max rather than more calls. Archiving a hundred accounts at that rate is $0.24 in total, which is less than a single cup of coffee for a dataset that would take an afternoon to assemble by hand. The $0.50 sitting in a new account at signup covers more than two hundred archive calls before a card is ever involved, which is enough to validate the whole pipeline.
Concurrency when archiving many accounts
The ceiling is 600 requests a minute per key with 20 concurrent, and a fleet of archive calls is the one job that genuinely uses that concurrency, because each account is independent of every other. Run twenty in flight and the limiting factor becomes the tens of seconds a large max takes upstream, not the request allowance, which you will never come close to exhausting with calls this slow. Since it needs no session and no registration, there is no credential to manage and nothing to refresh between runs. The worker pool stays as simple as a queue of numeric user ids, a concurrency of twenty, and a retry that repeats the whole call.
Dedupe, re-runs and merging two pulls
Every id arrives as a string precisely so a 19 digit number does not lose its trailing digits to JavaScript precision, and that string is your deduplication key. Sort on created_at rather than on array position when you merge two datasets, because two pulls taken at different times put the same post at different offsets. Overlapping a backfill with an earlier run is normal and safe: duplicate ids collapse cleanly and nothing about the endpoint holds state between calls, so there is no watermark to maintain and no cursor to persist. That is what makes an incremental top-up strategy work with no bookkeeping on the server side at all. Run it monthly, overlap generously, and let the id key absorb the repeats. $0.0008 a call, per our published pricing.
Historical archive search by date
For an account too large or too old to pull comfortably in one archive call, search by date is the other route into its history, and it parallelises well. Split the period you need into windows of a month or a quarter, and send one query per window to tweet/advanced_search: from:handle since:2023-01-01 until:2023-02-01, for example, adding include:nativeretweets if reposts belong in the record. Page each window with its own cursor until it comes back empty, and run the windows side by side, since each one is independent of the others. Because since: is inclusive and until: excludes its own day, consecutive windows written this way neither overlap nor leave gaps. Each page is a standard $0.0008 call of about 20 posts, so a window holding 400 posts is about 20 calls, under two cents. The search route also lets you narrow while you collect, with an engagement floor such as min_faves:50, a media filter, or -filter:replies to keep originals only. Choose the archive call when you want everything in one request, and windowed search when you want a bounded slice, a filtered slice, or a backfill you can resume window by window after an interruption. Merge both kinds of pull on the post id.
Use cases for a full archive
An account's reachable history, about its most recent 3,200 posts, earns its cost in a handful of recurring jobs. Research: a dataset of what a public figure, agency or company posted, back as far as the timeline reaches and further through date-window search, with the fetch time recorded, is the raw material for content analysis and longitudinal studies. Due diligence: before a partnership or a hire, a full pull shows what an account has said over years rather than last week. Competitive review: archiving a competitor's history reveals launch timing, shifts in messaging and which formats drew engagement. Backup and migration: an account owner can keep a structured copy of their own posts outside the platform. Training and evaluation sets: archives of chosen accounts give a model clean, attributed text with timestamps. Brand safety: scanning an influencer's back catalogue for past posts before a campaign launches. Each of these needs completeness more than freshness, which is why the archive route suits them: one call per account at $0.0024, a cursor only when a call stops early, and a result that can be stored once and topped up later with an overlapping pull. Jobs that need only one period or one topic fit windowed search better, and usually cost less, because they skip everything outside the slice.
Connecting to the API and downloading data
The whole download is four steps and needs no SDK. First, sign up and copy your key; every request sends it as Authorization: Bearer to api.twitterapis.com, and the $0.50 starting credit covers more than two hundred archive calls. Second, resolve the account: GET /twitter/user/info with username set to the handle without the @, and store the id from the response. Third, request the history: GET /twitter/user/tweets/complete with user_id set to that id and max set to how far back you want to reach, starting small, around forty, while you check your parser, then raising it. Give the HTTP client a generous timeout, because a large max can take tens of seconds to walk. Fourth, write the result: iterate the tweets array, write each post as one JSON line keyed on its id, and record the fetch time beside it. Add compact=1 if you only need the core fields, or fields with a list of dotted paths for a custom shape; neither changes the price. Keep ids as strings throughout, since 19 digit values lose precision as JavaScript numbers, and re-run the same steps later with an overlapping pull to top the archive up.
Export the archive to CSV
The API answers in JSON, so a CSV is one short step on your side rather than a separate export product. Flatten each post into a row with the columns most spreadsheets need: id, created_at, text, favorite_count, retweet_count, reply_count, quote_count, view_count, is_retweet, is_reply and url. Write the id as text, not as a number, because a 19 digit id loses its last digits the moment a spreadsheet reads it as numeric. Quote the text column, since posts carry commas and line breaks. view_count can be null on older posts from before X showed view counts, so leave that cell empty rather than writing a zero that reads as a real measurement. Python's csv module or pandas handles all of this in a few lines. Keep the raw JSON lines file beside the CSV as the source of truth, because the CSV drops nested fields such as media, entities and the embedded quoted_tweet, and re-deriving it later is free while re-fetching is not.
Can you retrieve deleted tweets or export retweets?
Deleted posts cannot be retrieved. Once an author deletes a post, X stops serving it, and no read route can hand back a post the platform itself no longer has. The only copy of a deleted post is one somebody stored before it went, which is the practical case for archiving an account early and topping it up on a schedule rather than pulling it once when you finally need it. Retweets are a different story: the archive call includes them, flagged is_retweet true, with the original post embedded under retweeted_tweet. Quote posts arrive flagged is_quote true, with the quoted post under quoted_tweet and its id in quoted_status_id. So one pull gives you the original posts, the reposts and the quotes together, and you filter on those flags to keep the slice you want. Replies are flagged is_reply. Protected accounts are out of reach entirely: their posts are not public, so no public read returns them.
Building queries for one account
When you want part of a history rather than all of it, build a search query instead of pulling the archive. tweet/advanced_search takes the same operators as X's own advanced search, so from:handle restricts results to one author, since:2024-01-01 and until:2024-07-01 bound the dates, min_faves:100 keeps only posts with a like floor, filter:media keeps posts with attachments, and -filter:replies drops replies. Combine them freely: from:handle since:2024-01-01 until:2024-04-01 min_faves:50 -filter:replies is one valid query. Each page is a $0.0008 call of about 20 posts, paged with next_cursor until the page comes back empty. A query is the cheaper route whenever the slice is narrow, because you pay only for the posts the operators keep, while the archive call pays for every post back to the point you stop. Test a query in X's own search box first; if it finds the posts there, it will find them here.
Run the archive as a code script
The whole job fits in one short script. Read the key from an environment variable, call GET /twitter/user/info?username=<handle> once to get the numeric id, then loop on GET /twitter/user/tweets/complete with user_id and max. Append each item of the tweets array to a JSON lines file. If next_cursor in the response is a string, pass it back as cursor on the next call; when it comes back null, the reachable history is exhausted and the loop ends. Reachable means about the most recent 3,200 posts, X's ceiling per account; posts older than that need date-window search. That null check is the only stop condition worth trusting, because a call can end early on its server-side time budget and return fewer posts than max while more history remains. Set the HTTP timeout to a minute or more, since a large max walks many upstream pages before it answers. Python with requests, Node with fetch, or a curl loop in a shell script all work the same way, because the contract is plain HTTPS and JSON with a bearer header. There is no SDK to install and no X account to log in with.
Sign up, log in, copy your key
Getting access takes a minute and needs no card and no X developer account. Sign up by logging in with Google, open the dashboard, and copy the personal API key that appears there. New accounts start with a $0.50 credit, which is about 208 archive calls at $0.0024 each, or 625 standard reads at $0.0008, so you can archive a few accounts end to end before you decide whether to top up. The key goes in every request as Authorization: Bearer <key>. Store it in an environment variable or a secrets manager rather than in the script itself, and never commit it to a repository. Billing is per call against the balance, with no monthly plan, so an archive you run once costs once and an idle month costs nothing. The balance and recent charges are visible in the dashboard, and account endpoints that report them are free to call.
Related tasks for archived accounts
An archive is usually the first step of a bigger job, and the neighbouring tasks use the same key. To refresh an archive with only new posts, set up a monitor on the handle, which pushes each new post to your webhook for free instead of re-pulling history. To read the conversation under a post, page tweet/replies. To rebuild a multi-part post the author wrote as a chain, call tweet/thread once. To see who amplified a post, page tweet/retweeters and tweet/quotes. To profile the audience behind the account, page user/followers. To save every clip the account posted, page user/media and read the video variants from each post. Each of these has its own answer page linked below, with the parameters and the cost of a full pull worked out, so an archive project that grows into a research dataset stays on one billing line and one contract.
Three routes to an account's posts
| Endpoint | Per call | What one call returns |
|---|---|---|
| user/tweets | $0.0008 | About twenty timeline posts plus a cursor |
| user/tweets_and_replies | $0.0008 | The same page, with the account's replies included |
| user/tweets/complete | $0.0024 | Up to max posts, default 800, in one response |
| user/info | $0.0008 | The user object, which is where the numeric id comes from |
| tweet/advanced_search | $0.0008 | One page of a from: query bounded to a date window |
| tweet/detail | $0.0008 | A single post resolved by id, with its counters |
The Timelines endpoints let you retrieve Posts from user timelines, mention feeds, and home feeds.
Questions and answers
- How far back can I pull an account's posts?
- The archive endpoint fetches up to whatever max you request, defaulting to 200, and larger values fetch more history and take longer to return. A non-null next_cursor means more remains, so pass it back as cursor until it comes back null, which means the reachable history is exhausted. X caps that at about the most recent 3,200 posts per account; older posts come through date-window search with from: and since: or until:. Start with a small max, around forty, to see the shape of the response and confirm your parser handles it, then raise it once the pipeline is stable. Opening with the largest number you can think of just makes the first failure slower and more expensive to diagnose.
- How do I get old tweets from a specific year?
- Use search rather than the archive route. Put a from: clause together with since: and until: bounds into the query parameter on tweet/advanced_search and page the cursor at $0.0008 a page. That returns exactly the window you asked for instead of everything newer as well, which is usually cheaper when the target is one stretch of history. It also lets you add an engagement floor, which the archive route has no way to express.
- Why does it reject the @ handle?
- The parameter is user_id and expects the numeric identifier, which stays constant even when someone renames their account. Resolve a handle into that id through user/info once, store it against the account record, and every later archive run skips the lookup entirely. Handles change and get recycled by other people; the numeric id does not, which is exactly why the premium route insists on it rather than accepting the friendlier-looking input.
- Does the archive include the account's replies?
- Treat that as a separate pass. The plain timeline route covers original posts and reposts, and user/tweets_and_replies is the endpoint documented as also carrying the replies an account sent to other handles. Run both if the goal is a full public record, then merge on the post id, using in_reply_to_username to label which rows came from the reply side. For a heavy replier the two datasets differ by most of the total volume.
- Is one big call cheaper than paging?
- For a full history, clearly. Eight hundred posts through the standard timeline is roughly forty cursor pages, near $0.032, while a single archive call is $0.0024 regardless of how much comes back. Paging still wins when you only want the newest handful, since one timeline page is $0.0008 and returns immediately instead of taking tens of seconds. Pick by what you need, not by which number looks smaller in isolation.
- Why is the post id a string and not a number?
- Post ids run to 19 digits, which overflows the precision an ordinary JavaScript number carries, so they are serialised as strings to keep the trailing digits intact. Store and compare them as text throughout your pipeline, including in whatever database column holds them. A silently rounded id is the classic cause of duplicate rows in an archive, and it stays invisible until two records that should be distinct collide on the same key.
- Do I need a logged-in X account for this?
- No. The archive endpoint needs no session, so no registration is involved and your key alone authorises the call. Sessions matter for write actions and for the routes that read private surfaces, not for pulling a public timeline. That keeps a bulk archive job simple, with no cookie to register and nothing to refresh partway through a long run.
- How long does a large archive call take?
- The reference warns that larger max values can take tens of seconds, because the endpoint is doing internally the pagination you would otherwise be driving yourself. Give the request a generous client timeout, run it from a worker rather than a web handler, and treat a single archive call as a background job that produces a result. There is no cursor to resume from, so a timeout means repeating the whole call and paying for it again.
- Can I archive many accounts at once?
- Yes, and it is the job that best fits the allowance. Each account is independent, so twenty concurrent calls stay inside the 20 concurrent and 600 per minute ceilings while overlapping the upstream wait that dominates the wall clock. A hundred accounts at $0.0024 each is $0.24 in total. Since these reads need no session there is no credential to manage, so the worker pool is just a queue of numeric ids.
- What happens if I run the same archive twice?
- Nothing breaks. The endpoint holds no state between calls, so a second run simply returns the history again, newest first. Duplicate ids collapse against your existing rows if you key on the id string, which makes an overlapping incremental top-up the safe default rather than something to engineer around. Expect the engagement counters to differ between runs, since those are read at fetch time rather than frozen when the post was published.
Keep reading
Start with $0.50 in free credits
No credit card. Roughly 12,500 tweets to test every endpoint.