How do you bulk delete tweets with an API?
Last updated August 24, 2026
Deleting 5,000 posts is two jobs, not one. First enumerate the account's tweet ids, either a cursor page at a time from user/tweets or in bulk from user/tweets/complete. Then POST each id to tweet/delete, which bills $0.0008 and returns a deleted flag. The delete half of a 5,000 post cleanup comes to $4.00.
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).
Phase one, build the work queue of ids
You cannot remove what you have not listed, so a cleanup opens as an enumeration job. user/tweets/complete takes a numeric user_id, paginates internally, and returns up to max tweets newest first in one premium call; max defaults to 800 and larger values fetch more history and take longer, sometimes tens of seconds, so start small and measure before scaling it up. The cursor route, user/tweets, takes a handle instead and walks the timeline roughly twenty records at a time, following next_cursor until it comes back empty. Either way the field you actually keep is id on every row. The point of the pass is turning a live timeline into a fixed list you control, because the removal loop can only act on what that list contains.
Replies are a separate listing, and that is where cleanups leak
user/tweets returns original posts and retweets. It does not carry the replies the account wrote to other people, which is how a cleanup that looked finished leaves hundreds of rows standing under threads nobody thought to check. user/tweets_and_replies is the route that covers them, taking the same handle and cursor and returning the same tweet objects with reply context attached, including in_reply_to_username on the rows that have it. Decide up front which population you are clearing, because the two listings differ and the removal step cannot tell you about rows it never saw. If the goal is everything the account ever published, enumerate through the replies route and treat the plain timeline as a subset of that result.
Phase two, the delete loop
tweet/delete is a POST carrying a single id. A full status URL is accepted in place of the numeric identifier, which saves a parsing step when the queue was assembled from links rather than from an API listing. The response holds three fields: ok, deleted, and tweet_id echoing the post that went. Only the authenticated account's own posts can be removed, so a session for that account has to be attached before the first call, registered once through the free session route or supplied per request as headers. There is no array form and no filter parameter, so a five thousand post job genuinely is five thousand calls, one id at a time, and the loop, the ordering and the error handling are all yours to write.
Pacing the loop against the ceiling
One key is allowed 600 requests a minute with 20 in flight at once. Five thousand removals therefore have a hard floor a little over eight minutes of wall clock, and a worker pool of twenty is enough to sit on that floor without tripping the limit. Do not fan out wider hoping to finish sooner: past twenty concurrent the extra requests do not run, they queue or fail, and a retry storm then spends the budget twice on the same ids while producing no additional deletions. A short pause between batches costs seconds and buys a clean error signal when something upstream starts refusing, which is far easier to read than a wall of simultaneous failures arriving at once.
Making the run safe to repeat
Treat the queue as a ledger rather than a stream. Write each id to durable storage the moment its response comes back with deleted true, and have the runner skip anything already marked when it restarts. Branch on the deleted flag itself rather than on the HTTP status, because that field is the one that says the post is actually gone rather than that the request was accepted. Writing the id down before the call goes out, then updating it after, is the stricter variant, and it is what stops a crash mid-flight from producing a second attempt. Ids already removed will not reappear in a fresh enumeration either, so re-running phase one before a retry naturally narrows what is left to do.
There is no undo route
Nothing on the write surface restores a removed post, and no read route will hand back text X no longer serves. That makes the enumeration output your only archive. Dump the full tweet objects to disk rather than just the ids, keeping text, created_at, the author block and whatever engagement counts the listing carried, then confirm the file parses and holds the row total you expected before a single removal goes out. An archive written afterwards is not an archive at all. This is also the argument for running enumeration and removal as two separate jobs with a human between them, rather than one script that quietly does both in sequence while nobody is watching the output. Keep that archive somewhere the removal job itself cannot reach, because a script holding write access to its own backup is not really a backup.
The arithmetic on a 5,000 post cleanup
Work the numbers before starting. Enumerating five thousand posts by cursor at roughly twenty rows a page is about 250 calls of user/tweets at $0.0008, which is $0.20. The premium history route reaches the same list in far fewer calls at $0.0024 each, so even a handful of them stays under a nickel. The removal half is fixed: five thousand calls of tweet/delete at $0.0008 comes to $4.00, and no batching discount exists because no batch parameter exists. Total lands near $4.20. The $0.50 signup credit, which is 625 calls at the standard rate, covers the enumeration comfortably and a slice of the removals, so a full clear of that size needs credit on top. Check the balance before starting rather than discovering it mid-run, since a job that stops halfway leaves the account in a state neither the old list nor the new one describes.
The two phases need different credentials
Enumeration and removal authenticate differently, and that catches people out mid-run rather than at setup. The history route is a pooled read: it takes a numeric user_id and answers with no X identity attached, which is why it can list an account you do not control at all. Removal is the opposite. It acts as an account, so it needs a registered session, and pointing it at somebody else's post id fails rather than succeeding quietly. If you are clearing several accounts, the enumeration can run once across all of them while the removal loop swaps the acting session between each account's queue, which is a scheduling decision worth making before you write the runner. The practical check is to run one removal by hand and confirm deleted comes back true before releasing the loop on the whole queue.
What the route will not do for you
It removes one post that you own. It does not take a date range, a keyword, a minimum engagement threshold or a list of ids, so every filter you want lives in your own code between the two phases, which is the right place for it anyway since that is where the decision can be logged and reviewed before anything is destroyed. It will not touch another account's posts, it will not reverse itself afterwards, and it will not report how many are left. Keep a running count against your enumerated total so progress is reported on numbers you measured yourself rather than on how long the job has happened to be running. Nothing in the response describes the queue either, only the single post it just acted on, which is worth remembering whenever you are tempted to ask the API for job state. We bill $0.0008 a call, per our public pricing.
Cost of each step in a 5,000 post cleanup
| Step | Route or call count | Billed |
|---|---|---|
| Pull a large slice of history in one shot | user/tweets/complete | $0.0024 per call |
| Walk the timeline a cursor page at a time | user/tweets | $0.0008 per call |
| Include the account's replies in the sweep | user/tweets_and_replies | $0.0008 per call |
| Attach the X identity that will act | customer/session | Free |
| Enumerate 5,000 posts by cursor, 20 rows a page | about 250 calls | $0.20 |
| Remove one post | tweet/delete | $0.0008 per call |
| Remove all 5,000 posts | 5,000 calls | $4.00 |
The Timelines endpoints let you retrieve Posts from user timelines, mention feeds, and home feeds.
Questions and answers
- Can I remove posts belonging to another account?
- No. The route only touches the authenticated account's own posts, and the acting account is whichever session you registered against your key. Pointing it at somebody else's id fails rather than succeeding quietly, so there is no silent-damage case to worry about. If you manage several accounts, attach the right session before running that account's queue, and swap it between queues rather than between individual calls.
- How do I collect the ids in the first place?
- Two options. user/tweets/complete takes a numeric user_id and returns a large newest-first slice in one premium call, paginating internally so you write no loop of your own. user/tweets takes a handle and a cursor and returns about twenty rows at a time. Both give you the id field the removal step consumes, and both leave every filtering decision to your own code rather than to a query parameter.
- Does this cover the account's replies?
- Only if you enumerate them deliberately. user/tweets returns original posts and retweets and stops there, so replies written to other accounts never enter the queue and never get removed. Use user/tweets_and_replies when the goal is everything the account published. The removal route does not care which listing an id came from, but it can only ever act on ids you actually collected first. Enumerate once, decide which listing you actually needed, and de-duplicate on id before merging the two results.
- How long does a 5,000 post purge take?
- The ceiling is 600 requests a minute per key with 20 concurrent, so five thousand removals cannot finish faster than a little over eight minutes no matter how you write the loop. Budget more in practice, because the enumeration pass runs first and larger history fetches can take tens of seconds each before the removal loop issues its very first call. Measure your own throughput on a first slice of a few hundred before extrapolating to the full run.
- What does the whole job cost end to end?
- About $4.20 for five thousand posts. The removal half is fixed at five thousand calls of $0.0008, which is $4.00 exactly. Cursor enumeration adds roughly 250 calls at the same rate, about $0.20, and the premium history route gets to the same list for less. The session registration that authorises the whole loop costs nothing at all. The $0.50 signup credit is 625 standard calls, which covers the enumeration and a small share of the removals but not a full clear of this size.
- What does the response tell me?
- Three fields: ok, deleted, and tweet_id echoing what you sent. Branch on deleted rather than assuming success from a 200, and record the id in your ledger the moment it reads true. That ledger is the thing that keeps a restarted job from spending a second call on work an earlier run already finished, which on a five thousand row queue is real money. Nothing in the response describes the rest of the queue, only the one post it acted on, so job state stays yours to track.
- Can I delete everything before a certain date?
- Not through the route itself, which takes one id and offers no filters at all. Do the filtering between the two phases: enumerate first, keep created_at on every row, select the ids you want in your own code, and feed only those into the loop. That also leaves you a written record of exactly what the selection rule matched, which matters when the action cannot be undone.
- Is a removed post recoverable?
- Not through this API. There is no restore route anywhere in the write surface, and once a post is gone the read endpoints will not return it either, so nothing on this side can reconstruct it. Export the full objects during enumeration and verify the file before the loop starts, because that export is the only copy of the text you will have afterwards. Verify the export parses and holds the row count you expected before the very first removal goes out.
- Does the enumeration step need a session too?
- No. The history route is a pooled read that takes a numeric user_id and answers without any X identity attached, which is precisely why it works against accounts you do not control. Only the removal half acts as an account and therefore needs a registered session, or the same credentials supplied inline on each call as request headers instead of stored. That split also means you can enumerate a target list well before you are ready to authorise anything destructive.
- Do I have to pass a numeric id?
- A tweet URL works too. The id parameter accepts either the numeric identifier or the full status URL, so a queue assembled from links needs no parsing step before the loop starts. The echoed tweet_id in the response always comes back numeric, which makes it the safer value to write into your ledger and to compare against on a later run. Store both the value you sent and the value echoed back when your queue mixes the two forms.
Keep reading
Start with $0.50 in free credits
No credit card. Roughly 12,500 tweets to test every endpoint.