twscrape for Twitter (X): what the account pool costs you, and how to migrate to an API with no accounts

By TwitterAPI.io Team•22 min read
Dark comparison diagram. Left panel, titled twscrape (free software, your accounts): a vertical chain accounts.txt (user:pass:email:email_pass per line), twscrape add_accounts (or add_cookie with auth_token + ct0), twscrape login_accounts (email verification codes, proxy per account), accounts.db SQLite (sessions, per-endpoint rate-limit state), api.search(q, limit) (rotates accounts, locks the limited one). Beside it a list headed What goes wrong (open issues): programmatic logins refused #278, accounts come back active=false #285, 429 Rate limit exceeded ban detected #274, SearchTimeline stops instead of rotating #351, user_by_id blocked at the edge #337, GraphQL script parsing breaks #320 #330; plus proxies, relogin_failed, reset_locks and X locking the accounts. Right panel, titled twitterapi.io (metered, no X account): X-API-Key header, GET /twitter/tweet/advanced_search?query=rust lang&queryType=Latest&cursor=, then tweets[] has_next_page next_cursor, 20 tweets a page; 15 credits per tweet = $0.15 per 1,000; official X API rate card $0.005 per post read. Footer: measured in 2026, 80 search pages across 3 developer queries returned 1,580 tweets, 19.8 per request, 2.24 s a page, cost $0.24 vs $7.90 at the official rate card.
What you operate to read one search: twscrape's account pool, sessions, proxies and locks on the left, one API key and one GET on the right. The left side is from twscrape's README and issue tracker; the footer numbers are from this page's 2026 study.

twscrape is what the snscrape and twint crowd moved to after X's 2023 login wall killed logged-out scraping. It is honest about how it works: its GitHub description is "Python library and CLI for X/Twitter scraping with multi-account rotation and built-in rate-limit handling", and the first thing its README asks for is a set of authorized X accounts. Everything that is good about it (search operators, GraphQL coverage of profiles, followers, lists, communities, trends, snscrape-shaped data models, an async API and a JSON Lines CLI) sits on top of that pool, and everything that goes wrong with it, judging by its issue tracker, goes wrong in the pool.

This page is for the developer deciding whether to run that pool or to pay someone else for the reads. It reports what twscrape is in 2026 from its README, PyPI page, releases and open issues; what agent-twitter-client, the no-API client AI agents used, has become; and then maps each twscrape call to a GET request against https://api.twitterapi.io with an X-API-Key header and no X account. The twscrape_compat.py at the end keeps twscrape's method names (api.search, api.user_by_login, api.user_tweets, gather) so an existing script changes by one import.

The study in the middle answers the question people have when they set limit=500 and wonder what they got: how many pages, how many hours of history, how many distinct authors, how much time and money. We ran three neutral developer queries through the replacement in 2026, 80 requests and 1,580 tweets in total, and every number on this page comes from that run. Where twscrape does something the API does not, or the other way round, the page says so.

01 — Section

What twscrape is, from its own README: an account pool with a scraper on top

twscrape (GitHub vladkens/twscrape, PyPI twscrape) describes itself as "an async Python library and CLI for X/Twitter Search and GraphQL endpoints. It runs on your own account pool, keeps sessions in SQLite, rotates accounts when an endpoint is rate-limited" and returns "either parsed SNScrape-style models or raw API responses". The PyPI summary is "Twitter GraphQL and Search API implementation with SNScrape data models". As read this year: version 0.20.1, Python 3.10 or newer, MIT license, 2.9k stars, 330 forks, 301 commits, 77 open issues, releases v0.19.2, v0.20.0 and v0.20.1 all shipped in 2026, and the latest commit on main ("fix: retain tweets whose nested authors lack profiles") landed this year. It is maintained, which is the first thing to say about it and the main thing that separates it from snscrape and twint.

The pool comes first. Install is pip install twscrape, but nothing runs until accounts.db has at least one active account. The README gives two ways in. twscrape add_accounts ./accounts.txt username:password:email:email_password takes a text file of credentials, one account per line, and twscrape login_accounts logs each one in, handling the email verification code X sends by reading the mailbox you gave it. Or, the route the README calls the most stable, twscrape add_cookie my_account takes cookies exported from a browser session; an account whose cookies contain auth_token and ct0 is "activated immediately". Proxies are per account (add_account(..., proxy="http://login:pass@host:port")), per API(proxy=...) instance, or global through TWS_PROXY, with the API-level proxy winning. An optional twscrape[curl] extra switches the HTTP backend to curl-cffi for a browser-like TLS fingerprint.

Rotation and locks are the design. From the README: "Rate limits are tracked per account and per endpoint. When an account is limited for one operation, twscrape locks it for that operation until the reset time and tries another active account." That is the whole trick. One account gets a fixed number of search requests per 15-minute window from X; N accounts get N times that; when the pool is exhausted twscrape waits (newer releases added wait_timeout and wait_interval to bound that wait). The account-management CLI tells you what the operational surface looks like: twscrape accounts and twscrape stats to see the pool, relogin user1 user2 and relogin_failed to re-authenticate, reset_locks when locks go stale, delete_inactive and del_accounts to prune.

limit counts objects, not pages. The README is explicit: limit "is the target number of parsed objects, not a page size. X/Twitter controls page size per endpoint, so a call can return fewer or more objects than requested." X's search timeline serves about 20 tweets a page, so api.search(q, limit=500) is roughly 25 GraphQL requests spread across your accounts, and limit=20 is the one-page default every README example uses. Keep that arithmetic; the cost section uses it on both sides.

The Python surface is wide and consistent. Everything that returns many items is an async generator you drain with gather(): api.search(q, limit), api.search_user, api.user_tweets(user_id, limit), api.user_tweets_and_replies, api.user_media, api.followers(user_id, limit), api.following, api.verified_followers, api.subscriptions, api.tweet_replies, api.tweet_thread, api.retweeters, api.list_timeline, api.list_members, api.community_tweets, api.trends. Single-object calls are plain awaits: api.tweet_details(tweet_id), api.user_by_id(user_id), api.user_by_login(login), api.user_about, api.community_info. Each has a _raw twin that yields the HTTP responses, and each has a CLI command of the same name that prints JSON Lines. Note that timelines and follower lists take a numeric user_id, so a handle-based script always starts with user_by_login to resolve the id.

python
# twscrape, as the README shows it: the pool first, then the reads
#   pip install twscrape
#   twscrape add_accounts ./accounts.txt username:password:email:email_password
#   twscrape login_accounts            # or: twscrape add_cookie my_account  (auth_token + ct0)
import asyncio
from twscrape import API, gather

async def main():
    api = API()                          # accounts.db in the working directory
    # api.pool.add_account(..., proxy="http://login:pass@example.com:8080")  # one proxy per account
    tweets = await gather(api.search("rust lang", limit=500))   # ~25 GraphQL requests across the pool
    user = await api.user_by_login("rustlang")                   # resolve the numeric id first...
    posts = await gather(api.user_tweets(user.id, limit=100))    # ...timelines and followers take ids
    one = await api.tweet_details(tweets[0].id)
    print(len(tweets), user.followersCount, len(posts), one.likeCount)

asyncio.run(main())
# When every account is rate-limited for SearchTimeline, the call waits for the earliest reset.
# Locked or logged-out accounts: twscrape relogin_failed, twscrape reset_locks, twscrape delete_inactive
02 — Section

The honest cost: what the issue tracker says about accounts, logins and locks

twscrape's code is free. Its inputs are not. You need X accounts you are willing to lose, an email inbox per account that the login flow can read, a residential or mobile proxy per account if you run more than a couple from one address, and someone to look at the pool when it degrades. The library's own CLI (relogin_failed, reset_locks, delete_inactive) exists because that degradation is routine. The open issues, read this year, show the shapes it takes; the titles below are quoted from the tracker.

IssueTitle (quoted)What it means for the pool
#351, open, 2026"SearchTimeline does not rotate to another account after rate limit"The reporter's search "is interrupted and returns no results or an empty result set" at the limit instead of switching accounts; no maintainer reply at the time of reading
#349, open, 2026"Expose why a user lookup returned no user (suspended vs not found)"A suspended target and a missing handle look the same
#337, open, 2026"user_by_id blocked by ..." (the title names the edge network)Single-user lookups refused at the edge for authenticated sessions
#330, open, 2026 and #320, closed, 2026"XClIdParseError: 'X web scripts not found' on authenticated sessions"; "XClIdGen.create() fails ..."The request-signing step parses x.com's own scripts; when they change, every call fails until a release follows
#305, closed, 2026"load_keys() crashes when X returns login/..."X answered with a challenge page instead of JSON
#285, open, 2025"Keep getting active=false when trying to run the script"Accounts added but never usable
#278, open, 2025"Add Playwright support for login. ... is blocking all programmatic logins"The username-password route stopped working for the reporter; cookies from a real browser are the workaround
#274, open, 2025"Ban detected: 429, (88) Rate limit exceeded"twscrape's own ban heuristic firing on a pool
#272, open, 2025"account can not login / All connection attempts failed"Login failures that are network, proxy or account, and hard to tell apart

Three patterns are worth naming. First, the login path is the fragile one: the number of reports where add_accounts plus login_accounts fails and the answer is "use add_cookie from a browser" is why the README now calls the cookie route the most stable. Second, the request-signing dependency on x.com's shipped JavaScript (XClIdGen) means twscrape breaks whenever X's front end changes, independent of your accounts, and stays broken until a release; that is the same maintenance treadmill that ended snscrape's Twitter module, now carried by a maintainer who is still on it. Third, the costs are accounts: every lock, active=false and ban is an account you bought or made, plus the proxy it was tied to, and X's terms give it no protection. None of this is a criticism of the code; it is what a logged-in scraper is.

What twscrape gives you that no hosted API does: zero marginal cost per tweet once the pool exists, access to a few logged-in-only reads (bookmarks, user_about, verified-follower lists), raw GraphQL responses, and no third party in the data path. If you already operate accounts for other reasons and read a few thousand tweets a day, that trade can be right. If what you want is tweets as JSON for a pipeline, the rest of this page is the alternative.

03 — Section

The no-API-key landscape in 2026: twscrape, agent-twitter-client and the dead ones

twscrape is one of a family. The useful comparison is not features but what each tool needs to run and what stops it.

LibraryStatus as read this yearWhat it needsWhat stops it
twscrape (Python)Maintained: 0.20.1, commits and releases in 2026, 77 open issuesA pool of logged-in X accounts, proxies, email inboxesLocks, refused logins, bans, x.com script changes
agent-twitter-client (TypeScript, ElizaOS / ai16z)Unsupported: last release 0.0.18 in December 2024; every npm version flagged "Package no longer supported"; repository URL returns 404One X account's username, password and email, optionally cookies and a proxyNo maintainer; the ElizaOS Twitter plugin moved to official API keys
snscrape (Python)Dead for X since the June 30, 2023 login wall; blocked (404)Nothing; that was the pointX removed logged-out search
twint (Python)Dead for X; archivedNothingSame
Hosted read API (twitterapi.io)Running; meteredAn API key; no X accountPay per tweet returned: 15 credits, $0.15 per 1,000
Official X API, pay-per-usageRunningDeveloper account, approved app, card$0.005 per post read, 3M post reads a month cap

agent-twitter-client, specifically. This is the client that AI agent frameworks used to post and read X without the official API; on npm it describes itself as a Twitter client designed for agents that does not require the Twitter API and runs in browser and server environments, authenticating with the account's username, password and email, with optional cookies and a proxy. As read this year, the npm registry lists 0.0.18 (December 2024) as the latest version, marks every version with the deprecation notice "Package no longer supported" (followed by a pointer to npm support), and points at a repository URL that returns 404 (so does the old elizaOS/agent-twitter-client address). ElizaOS's current Twitter plugin documentation describes "Comprehensive Twitter/X API v2 integration for elizaOS agents" and requires TWITTER_API_KEY, TWITTER_API_SECRET_KEY, TWITTER_ACCESS_TOKEN and TWITTER_ACCESS_TOKEN_SECRET, with the note "Critical: Use OAuth 1.0a, NOT OAuth 2.0!". In other words, the agent framework that shipped the no-API client went back to API keys. If your agent still imports agent-twitter-client, the reads in it map onto the same endpoints as twscrape's below; the writes (posting as the agent's account) are a separate, authenticated-session product on twitterapi.io and are covered at the end of the migration table.

The pattern across the table is the one snscrape's maintainer predicted in 2021 when closing "Support for authentication" as wontfix: logged-in scraping works until the accounts stop working, and someone has to keep paying for accounts. twscrape is the project that accepted that bill; agent-twitter-client is the one that stopped paying it.

04 — Section

The migration table: every twscrape call, mapped to an endpoint with no accounts

All endpoints are GET against https://api.twitterapi.io with an X-API-Key header. twscrape's limit becomes a page loop: follow next_cursor while has_next_page is true and stop when you have enough rows. Response shapes differ by endpoint, as noted; the twscrape_compat.py at the end normalizes them.

twscrape call (Python / CLI)twitterapi.io requestResponse shape and notes
api.search(q, limit) / twscrape search "q" --limit=NGET /twitter/tweet/advanced_search?query=q&queryType=Latest&cursor=Top-level tweets[], has_next_page, next_cursor; up to 20 a page; X operators (from:, since:, until:, lang:, min_faves:, -filter:replies) go inside query
api.search(q, kv={"product": "Top"})queryType=TopSame shape
api.search_user(q, limit)GET /twitter/user/search?query=qTop-level users[], snake_case fields
api.user_by_login(login) / twscrape user_by_login NAMEGET /twitter/user/info?userName=loginProfile in data (id, userName, followers, following, statusesCount, createdAt, isBlueVerified); unknown user is HTTP 200 with status: "error" and msg: "user not found"
api.user_by_id(user_id) / twscrape user_by_id IDGET /twitter/user/batch_info_by_ids?userIds=id1,id2,...users[] camelCase (userName, followers, canDm, isAutomated); many ids in one request
api.user_tweets(user_id, limit) / twscrape user_tweets IDGET /twitter/user/last_tweets?userName=handle (or userId=)Tweets nested at data.tweets[], 20 a page, newest first, retweets included, replies off by default
api.user_tweets_and_replies(user_id, limit)GET /twitter/user/last_tweets?userName=handle&includeReplies=trueSame shape with replies
api.user_media(user_id, limit)last_tweets, then keep tweets where extendedEntities.media is non-emptyClient-side filter
api.tweet_details(tweet_id) / twscrape tweet_details IDGET /twitter/tweets?tweet_ids=id1,id2,...Top-level tweets[]; batch several ids per request
api.tweet_replies(tweet_id, limit)GET /twitter/tweet/replies?tweetId=idtweets[] with cursor
api.tweet_thread(tweet_id, limit)GET /twitter/tweet/thread_context?tweetId=idThe author's thread around the tweet
api.retweeters(tweet_id, limit)GET /twitter/tweet/retweeters?tweetId=idusers[] with cursor
api.followers(user_id, limit) / twscrape followers IDGET /twitter/user/followers?userName=handleTop-level followers[] snake_case (screen_name, followers_count, created_at), about 200 a page, has_next_page, next_cursor; 1 to 3 credits per user by page tier
api.following(user_id, limit)GET /twitter/user/followings?userName=handleSame shape as followers
api.list_timeline(list_id, limit)GET /twitter/list/tweets?listId=idtweets[], 20 a page
api.list_members(list_id, limit)GET /twitter/list/members?listId=idmembers[] with cursor
api.community_tweets(community_id, limit)GET /twitter/community/tweets?community_id=idtweets[]
api.trends(category)GET /twitter/trends?woeid=1trends[]; one WOEID per location
api.bookmarks(limit), api.user_about, api.verified_followers, api.subscriptionsNo read equivalentThese are logged-in-only views of your own account; twscrape keeps them
Posting, liking, following as an account (agent-twitter-client users)POST /twitter/user_login_v2 once ($0.10, with a proxy you supply) returns login_cookies; then /twitter/create_tweet_v2, /twitter/follow_user_v2 and the other action endpoints at $0.002 per callThe call acts as that account, so X can rate-limit, lock or suspend it; keep a human pace; the session can need a re-login; billed per HTTP 200 call

Three things change in your code. First, twscrape resolves handles to ids and then calls everything by id; here userName= works directly on last_tweets, followers and user/info, and userId= is available where you already have ids. Second, search excludes retweets (0 of 1,500 search tweets in our study were reposts) while last_tweets includes them, as twscrape's user_tweets does, so pick the endpoint by whether reposts matter. Third, there is no pool to wait on: new accounts are held to 0.2 requests per second until the first payment and 20 per second after, and a 429 is a short sleep, not a locked account.

python
# Before: twscrape
#   tweets = await gather(api.search("rust lang", limit=500))
#   user   = await api.user_by_login("rustlang")
#   posts  = await gather(api.user_tweets(user.id, limit=100))

# After: the same three reads over twitterapi.io, no accounts.db
import os, requests

BASE = "https://api.twitterapi.io"
H = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}

def search(query, limit, query_type="Latest"):
    out, cursor = [], ""
    while len(out) < limit:
        r = requests.get(f"{BASE}/twitter/tweet/advanced_search",
                         params={"query": query, "queryType": query_type, "cursor": cursor}, headers=H, timeout=30)
        r.raise_for_status()
        body = r.json()
        out.extend(body.get("tweets") or [])              # top-level tweets[], up to 20 a page
        if not body.get("has_next_page") or not body.get("next_cursor"):
            break                                           # a cursor can end early: handle it, do not assume 25 pages
        cursor = body["next_cursor"]
    return out[:limit]

tweets = search("rust lang", 500)                           # 25 full pages = 7,500 credits = $0.075
user = requests.get(f"{BASE}/twitter/user/info", params={"userName": "rustlang"}, headers=H, timeout=30).json()["data"]
posts = requests.get(f"{BASE}/twitter/user/last_tweets", params={"userName": "rustlang"}, headers=H, timeout=30).json()["data"]["tweets"]
print(len(tweets), user["followers"], len(posts), tweets[0]["likeCount"], tweets[0]["author"]["userName"])
05 — Section

How deep does one query go? 25 pages of three developer searches, measured

The question behind api.search(q, limit=500) is rarely "give me 500 tweets"; it is "give me the last day", or "the last week", of a topic. Pages do not map onto time, so we measured the mapping. In 2026 we ran advanced_search with queryType=Latest for rust lang, kubernetes and postgres, following next_cursor for up to 25 pages each, and recorded tweets per page, the span between the newest and oldest post after each page, authors, post mix and the client's wall-clock per page. The chart is the span; the table is the rest.

Query (Latest)Pages / tweetsPer pageHistory covered after 25 pagesPosts per hourAfter 5 / 10 pagesDistinct authorsClient s per page (median / p90)
rust lang25 / 50020 on every page1,478 h (61.6 days)0.392 h / 460 h352 (top 10 authors wrote 21%)2.17 / 2.62
kubernetes25 / 50020 on every page37 h (1.6 days)13.47.5 h / 11.8 h368 (top 10: 13%)2.14 / 2.50
postgres25 / 50020 on every page40 h (1.7 days)12.66.4 h / 13.6 h405 (top 10: 10%)2.28 / 2.77

Pages are full; time is not. All 75 pages across the three main runs held exactly 20 tweets, so limit arithmetic is clean: 500 tweets is 25 requests and 7,500 credits ($0.075) on each query. What those 25 requests bought in history differed by a factor of 40. rust lang is a quiet two-word query (0.3 matching posts an hour), so 500 tweets ran back 61.6 days and the first 5 pages alone covered almost four days. kubernetes and postgres each produce about 13 matching posts an hour, so the same 500 tweets covered a day and a half, and 5 pages covered 6 to 7 hours. If your job is "everything about kubernetes in the last 24 hours", that is about 16 pages; for rust lang it is one. Put since_time: and until_time: (Unix seconds) in the query and let the API stop, rather than guessing a limit.

Authors spread out fast. 500 tweets came from 352 to 405 distinct authors per query, 1,125 across the three; 83% to 87% of authors appeared once. The ten most frequent authors wrote 21% of rust lang (quiet topics have regulars) and 10% of postgres. There is no author.id pool to dedupe against, as there would be with a timeline pull, so a sentiment or mention dataset built from search is broad by default.

One cursor ended early, and a fresh one did not. The first kubernetes run returned four full pages (80 tweets, 6.3 hours of posts) and then a fifth page with zero tweets, has_next_page: false and next_cursor: null. The 80 tweets were billed (1,200 credits, $0.012); the empty page cost the 15-credit minimum. A second run from an empty cursor minutes later went the full 25 pages. The lesson for a migrated limit loop is in the code above: stop on has_next_page false, log how many rows you have against the target, and if the gap matters, re-issue the query bounded with until_time: set to your oldest createdAt rather than assuming the cursor was exhaustive. twscrape users will recognize this: its search can also return fewer objects than limit, which is why the README says what it says about limit.

Pace. The 80 timed requests took 185 s, median 2.24 s and 90th percentile 2.70 s per page, through a client that spaces requests at least 0.2 s apart and was shared with other jobs on the same machine; treat it as what a careful script sees, not an API benchmark. On a new account held to 0.2 requests per second, 25 pages take about two minutes; after the first payment, at 20 per second, the client's own latency is the floor.

Line chart on a light background titled 'How far back does one Latest search reach? History covered per page, three queries'. X axis: pages fetched 1 to 25 (20 tweets each; twscrape limit = pages x 20), with a secondary top axis in cumulative tweets 100 to 500. Y axis, log scale: 1 h, 6 h, 1 day, 1 week, 30 days, 60 days. Blue line for rust lang climbs from about 1 day at page 1 to 1,478 h (61.6 days) at page 25. Orange kubernetes and green postgres lines climb from 1 to 3 hours at page 1 to 37 h (1.6 days) and 40 h (1.7 days) at page 25. Subtitle: every page held 20 tweets; 25 pages (500 tweets, $0.075) reached 37 h back on kubernetes but 1,478 h on rust lang.
Hours of history covered after N pages of `/twitter/tweet/advanced_search` (queryType=Latest), collected in 2026. Every page held 20 tweets; the same 500 tweets covered 61.6 days of `rust lang` and about a day and a half of `kubernetes` or `postgres`.
06 — Section

What the pages contained: post mix, language and text length

A twscrape search and this endpoint read the same X search timeline, so what comes back is a property of the query, not the client. Across the 1,500 tweets in the three main runs: 56% original posts, 38% replies (isReply true), 6% quotes (quoted_tweet present) and 0 reposts (retweeted_tweet was never present; Latest search does not return retweets, which matches twscrape's behavior and differs from user_tweets, where reposts appear).

QueryOriginal / reply / quote / repostEnglish (lang)Text chars, median / p90Over 280 chars
rust lang53% / 42% / 5% / 0%55%201 / 69931%
kubernetes73% / 23% / 4% / 0%80%229 / 49527%
postgres43% / 49% / 8% / 0%88%223 / 41725%

Replies are a large share of a topic search: nearly half of postgres, a quarter of kubernetes. If your twscrape pipeline post-filtered them, add -filter:replies to query and pay for fewer tweets instead; if it kept them, the inReplyToId, inReplyToUsername and conversationId fields are present on each reply for threading. rust lang was only 55% English because the two words appear in several languages' posts; lang:en in the query fixes that at the source. A quarter to a third of tweets exceeded 280 characters (text carries the full long-form post; snscrape-era VARCHAR(280) columns truncate), with a 90th percentile of 417 to 699 characters.

The field set per tweet is the same as on every tweet endpoint: id, url, text, createdAt (classic Fri Oct 16 18:13:45 +0000 2026 format), lang, likeCount, retweetCount, replyCount, quoteCount, viewCount, bookmarkCount, conversationId, isReply, inReplyToId, entities.hashtags / urls / user_mentions, extendedEntities.media[], quoted_tweet, retweeted_tweet, and a full author object (id, userName, name, followers, following, isBlueVerified, createdAt). twscrape's snscrape-shaped Tweet model maps onto that one to one (rawContent is text, date is createdAt, user.username is author.userName); the full field-by-field table lives on the snscrape page, since twscrape deliberately reused those models.

Horizontal stacked bar chart on a light background titled 'What 25 pages of a Latest search contain: post mix and author spread, per query'. Three bars: rust lang 53% original, 42% reply, 5% quote; kubernetes 73% original, 23% reply, 4% quote; postgres 43% original, 49% reply, 8% quote; reposts 0% on all. Right margin: rust lang 352 authors / 500 tweets, top 10 authors 21%, en 55%; kubernetes 368 authors / 500 tweets, top 10 13%, en 80%; postgres 405 authors / 500 tweets, top 10 10%, en 88%. Subtitle: across 1,500 tweets, 56% originals, 38% replies, 6% quotes, 0% reposts; 1,125 distinct authors.
Post mix and author spread for 25 pages (500 tweets) of each Latest search, collected in 2026. Reply = `isReply`; quote = `quoted_tweet` present; repost = `retweeted_tweet` present (none in search).
07 — Section

Cost: twscrape's limit arithmetic against $0.15 per 1,000 tweets and the official rate card

twscrape's marginal cost per tweet is zero and its fixed cost is the pool: accounts, proxies, inboxes and your time, none of which this page can price for you. The hosted API inverts that: nothing fixed, 15 credits per tweet returned ($1 is 100,000 credits, so $0.15 per 1,000 tweets), a 15-credit minimum per request so an empty page costs $0.00015, profiles 18 credits each ($0.18 per 1,000), followers 1 to 3 credits per user. The official X API's pay-per-usage rate card (docs.x.com) is $0.005 per post read, $0.010 per user read, $0.010 per follower read, capped at 3 million post reads a month. Both metered columns below use the study's measured 20 tweets a page; the history column uses the study's posts-per-hour rates and is an extrapolation past 500.

twscrape limit (search)Requests at 20 a pagetwitterapi.ioOfficial X APIHistory reached: kubernetes / rust lang
1005$0.015 (1,500 credits)$0.507.5 h / 14 days
500 (this study, per query)25$0.075$2.501.6 days / 61.6 days
1,00050$0.15$5.00 (about 33x: $5.00 / $0.15)about 3 days / about 4 months
5,000250$0.75$25.00about 2 weeks / beyond the sample
10,000 a day, every day500 a day$1.50 a day, $45 a month$50 a day, $1,500 a month
The whole study: 1,580 tweets in 80 requests80$0.24 (23,700 credits)$7.90

The row that matters for a twscrape user is the daily one. A pool that reads 10,000 tweets a day needs enough accounts to absorb 500 search requests a day across X's per-account 15-minute windows, plus proxies; the same reads are $1.50 a day here, or $45 a month, with no accounts or proxies to buy, and the official route is $1,500 a month. For user_by_login lookups the arithmetic is 18 credits each: 1,000 profiles is $0.18, against $10.00 at the official rate. For followers, a 200-user page is billed per user at 1 to 3 credits by tier; the pricing page has the tiers.

New accounts that pass the automatic bot check get $1 of free credits (100,000 credits, no card; one per person or network, others get $0.10; the credits expire after 31 days), which is about 6,600 search tweets: enough to rerun your twscrape job through the compat module and diff the output before paying. Purchased credits do not expire. Subscriptions start at Starter $29 a month with bonus credits; the pricing page lists Builder, Pro, Scale and Business. Rate: 0.2 requests per second before the first payment, 20 once paid, 50 on Scale, 500 on Business.

08 — Section

Running twscrape_compat.py, and how we collected this

The main code block at the end of this page is twscrape_compat.py: a synchronous API class with twscrape's method names (search, search_user, user_by_login, user_by_id, user_tweets, user_tweets_and_replies, tweet_details, followers, following) and the same limit semantics, plus a gather() that accepts either a list or a generator so await gather(api.search(...)) becomes gather(api.search(...)). Objects come back as the API's JSON dicts rather than twscrape's dataclasses, so attribute access (t.rawContent) becomes key access (t["text"]); the mapping is the snscrape one. There is no accounts.db, no login_accounts, no proxy and no lock table, and no equivalents for bookmarks, user_about, verified_followers or subscriptions, which are views of your own logged-in account.

How we collected this. Question: how deep does one api.search(q, limit) go through the replacement, in pages, hours of history, authors and post mix, and what does it cost? Queries: rust lang, kubernetes, postgres, each queryType=Latest, following next_cursor for up to 25 pages of /twitter/tweet/advanced_search. Window: the most recent results at collection time; per query, the span from the oldest to the newest createdAt in the pages collected (37 hours to 61.6 days). Sample: 1,580 tweets in 80 requests, of which 1,500 tweets in the three 25-page runs and 80 in a first kubernetes run whose cursor ended after four pages. Collected in 2026 through a read-only client capped at 5 requests per second; per-page wall-clock measured around each client call. Hours after N pages = newest createdAt minus oldest createdAt across the first N pages. Reply = isReply; quote = quoted_tweet present; repost = retweeted_tweet present.

What the sample cannot show. Three developer-topic queries are not all of X: a consumer topic, a hashtag or a from: query has a different posts-per-hour rate, language mix and reply share, and the history-per-page numbers are specific to these queries at this time. The early cursor end happened once in four runs; we cannot say how often it happens. The pace figure reflects a shared rate limiter, not the API's ceiling. We did not exercise user_tweets, followers, tweet_details or user_by_id in this study; their rows in the migration table come from the endpoint reference and the response shapes we use elsewhere, not from this run. Nothing on this page measures twscrape itself; its account-side numbers (requests per account per window, lock frequency) depend on your accounts and are not reproduced here.

Aggregates only. Topic searches return posts from private individuals, so the raw pages were kept only in a temporary directory and were not committed; the study folder holds the protocol, the analysis script, results.json with the aggregates, and sample.json with one de-identified row per page (page index, tweet count, time offsets, post mix, authors in page, seconds), with no ids, text, URLs or handles.

python
# twscrape --limit arithmetic on both sides, for a daily job
PAGES_PER_DAY = 10_000 / 20                # 500 requests a day
# twscrape: 500 SearchTimeline requests a day spread over your accounts' 15-minute windows,
#           plus a proxy per account, plus relogin / reset_locks when the pool degrades
# twitterapi.io: 10,000 tweets x 15 credits = 150,000 credits = $1.50 a day, $45 a month
# official X API pay-per-usage: 10,000 x $0.005 = $50 a day, $1,500 a month
print(PAGES_PER_DAY, 10_000 * 15 / 100_000, 10_000 * 0.005)
python
"""twscrape_compat.py - twscrape's read methods, re-implemented over twitterapi.io (no accounts.db).

    pip install requests
    export TWITTERAPI_IO_KEY=...                  # key from twitterapi.io/dashboard; no X account needed

    from twscrape_compat import API, gather        # was: from twscrape import API, gather
    api = API()
    tweets = gather(api.search("rust lang", limit=500))   # was: await gather(api.search(...))
    user   = api.user_by_login("rustlang")                # was: await api.user_by_login(...)
    posts  = gather(api.user_tweets(user["id"], limit=100))

Same method names and `limit` semantics (limit = tweets, not pages). Items are the API's JSON dicts:
tweet keys id, url, text, createdAt, lang, likeCount, retweetCount, replyCount, quoteCount, viewCount,
conversationId, isReply, inReplyToId, entities, extendedEntities, quoted_tweet, retweeted_tweet,
author{id, userName, name, followers, following, isBlueVerified, createdAt}.
Cost: 15 credits per tweet returned ($0.15 per 1,000); a full 20-tweet page is $0.003; an empty page
is billed the 15-credit minimum ($0.00015). Profiles 18 credits each. Followers 1 to 3 credits per user.
No equivalents for bookmarks, user_about, verified_followers, subscriptions (logged-in-only views).
"""
import os
import time

import requests

BASE = "https://api.twitterapi.io"
HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}


def gather(items):
    """twscrape's gather() drained an async generator; here it just materializes a list."""
    return list(items)


class API:
    def __init__(self, session=None):
        self.s = session or requests.Session()

    def _get(self, url, params):
        for attempt in range(5):
            r = self.s.get(url, params=params, headers=HEADERS, timeout=30)
            if r.status_code == 429:           # 0.2 req/s before the first payment, 20 after; no account to lock
                time.sleep(2 ** attempt)
                continue
            r.raise_for_status()
            body = r.json()
            if body.get("status") == "error":  # user/info and last_tweets wrap errors; msg has the reason
                raise RuntimeError(body.get("msg") or "twitterapi.io error")
            return body
        raise RuntimeError("rate limited after 5 attempts")

    def _paginate(self, url, params, items_of, limit):
        """Follow next_cursor like twscrape's limit loop. Stops at limit, an empty page, or has_next_page false."""
        n, cursor = 0, ""
        while n < limit:
            body = self._get(url, {**params, "cursor": cursor})
            items = items_of(body)
            if not items:
                return
            for it in items:
                yield it
                n += 1
                if n >= limit:
                    return
            if not body.get("has_next_page") or not body.get("next_cursor"):
                return                         # a cursor can end before your limit; check n against limit
            cursor = body["next_cursor"]

    # ---- search -------------------------------------------------------------------------
    def search(self, q, limit=20, product="Latest"):
        """api.search(q, limit) -> /twitter/tweet/advanced_search (top-level tweets[]). X operators go in q."""
        return self._paginate(f"{BASE}/twitter/tweet/advanced_search", {"query": q, "queryType": product},
                              lambda b: b.get("tweets") or [], limit)

    def search_user(self, q, limit=20):
        """api.search_user(q, limit) -> /twitter/user/search (users[], snake_case)."""
        return self._paginate(f"{BASE}/twitter/user/search", {"query": q},
                              lambda b: b.get("users") or [], limit)

    # ---- users --------------------------------------------------------------------------
    def user_by_login(self, login):
        """api.user_by_login(login) -> /twitter/user/info. Unknown handle: HTTP 200, status error, msg 'user not found'."""
        return self._get(f"{BASE}/twitter/user/info", {"userName": login})["data"]

    def user_by_id(self, user_id):
        """api.user_by_id(id) -> /twitter/user/batch_info_by_ids (users[], camelCase). Pass a list to batch."""
        ids = user_id if isinstance(user_id, (list, tuple)) else [user_id]
        users = self._get(f"{BASE}/twitter/user/batch_info_by_ids", {"userIds": ",".join(map(str, ids))}).get("users") or []
        return users if isinstance(user_id, (list, tuple)) else (users[0] if users else None)

    def user_tweets(self, user_id, limit=20, include_replies=False):
        """api.user_tweets(id, limit) -> /twitter/user/last_tweets (tweets nested at data.tweets, reposts included)."""
        return self._paginate(f"{BASE}/twitter/user/last_tweets",
                              {"userId": str(user_id), "includeReplies": str(include_replies).lower()},
                              lambda b: (b.get("data") or {}).get("tweets") or [], limit)

    def user_tweets_and_replies(self, user_id, limit=20):
        return self.user_tweets(user_id, limit, include_replies=True)

    def followers(self, user_id, limit=20):
        """api.followers(id, limit) -> /twitter/user/followers (followers[], snake_case, about 200 a page)."""
        return self._paginate(f"{BASE}/twitter/user/followers", {"userId": str(user_id)},
                              lambda b: b.get("followers") or [], limit)

    def following(self, user_id, limit=20):
        return self._paginate(f"{BASE}/twitter/user/followings", {"userId": str(user_id)},
                              lambda b: b.get("followings") or b.get("followers") or [], limit)

    # ---- tweets -------------------------------------------------------------------------
    def tweet_details(self, tweet_id):
        """api.tweet_details(id) -> /twitter/tweets?tweet_ids= (top-level tweets[]). Pass a list to batch."""
        ids = tweet_id if isinstance(tweet_id, (list, tuple)) else [tweet_id]
        tweets = self._get(f"{BASE}/twitter/tweets", {"tweet_ids": ",".join(map(str, ids))}).get("tweets") or []
        return tweets if isinstance(tweet_id, (list, tuple)) else (tweets[0] if tweets else None)


if __name__ == "__main__":
    api = API()
    tweets = gather(api.search("rust lang", limit=100))          # 5 pages = 1,500 credits = $0.015
    print(len(tweets), "tweets;", len({t["author"]["id"] for t in tweets}), "authors;",
          sum(1 for t in tweets if t.get("isReply")), "replies")
    u = api.user_by_login("rustlang")
    print(u["userName"], u["followers"], "followers;", u["statusesCount"], "posts")
    posts = gather(api.user_tweets(u["id"], limit=40))          # 2 pages of data.tweets
    print(len(posts), "timeline posts; newest:", posts[0]["createdAt"])
    print(f"about ${(len(tweets) + len(posts)) * 15 / 100_000 + 18 / 100_000:.4f} in API reads")
09 — Questions

Questions readers ask

Does twscrape still work in 2026?

Yes, when its account pool does. As read this year, twscrape is at 0.20.1 on PyPI (Python 3.10+), MIT-licensed, with releases and commits in 2026 and 77 open issues. It needs logged-in X accounts loaded through twscrape add_accounts or add_cookie, stores sessions in accounts.db, and rotates accounts when one is rate-limited. The open issues are mostly about that pool: refused programmatic logins (#278), accounts stuck at active=false (#285), Ban detected: 429 (#274) and a search that stops instead of rotating (#351).

How many accounts does twscrape need?

twscrape does not publish a number, because it depends on how many requests you make and on X's per-account, per-endpoint limits in each 15-minute window. The README's model is "one account limited for an operation gets locked until its reset time and another active account takes over", so throughput scales with the number of active accounts and you need enough of them to cover your request rate, plus spares for the ones that get locked or logged out. Each one typically needs its own proxy and an email inbox the login flow can read.

Why do twscrape accounts get locked or come back active=false?

Three different things show up under that symptom in the issue tracker: X rate-limiting the account for an endpoint (twscrape locks it until the reset and moves on; reset_locks clears stale locks), the login flow failing (programmatic logins refused, email verification not completing; add_cookie from a real browser session is the usual workaround), and X locking or suspending the account itself, which twscrape cannot undo (delete_inactive removes it). The XClIdParseError issues are a fourth cause unrelated to accounts: twscrape derives a request signature from x.com's shipped scripts and breaks when they change until a release follows.

What is the twscrape alternative that needs no accounts?

A hosted read API. Every twscrape read maps to a GET on https://api.twitterapi.io with an X-API-Key header: api.search to /twitter/tweet/advanced_search, api.user_by_login to /twitter/user/info, api.user_by_id to /twitter/user/batch_info_by_ids, api.user_tweets to /twitter/user/last_tweets, api.tweet_details to /twitter/tweets, api.followers to /twitter/user/followers. No X account, no cookies, no proxy; you pay 15 credits per tweet returned ($0.15 per 1,000). The twscrape_compat.py on this page keeps twscrape's method names and limit semantics. The official X API is the other no-maintenance option at $0.005 per post read.

Is agent-twitter-client still maintained?

No, as far as its public record shows this year. The npm registry lists 0.0.18 (December 2024) as the latest version, marks every version "Package no longer supported", and its repository URL returns 404 (as does the old elizaOS/agent-twitter-client address). ElizaOS's current Twitter plugin is documented as an X API v2 integration that requires TWITTER_API_KEY, TWITTER_API_SECRET_KEY, TWITTER_ACCESS_TOKEN and TWITTER_ACCESS_TOKEN_SECRET (OAuth 1.0a), so the framework that used the no-API client moved to official keys.

How does twscrape's limit translate to pages and cost?

twscrape's README says limit is "the target number of parsed objects, not a page size" and X controls the page size. Search pages are about 20 tweets, so limit=500 is about 25 requests. In our 2026 study every one of 75 search pages held exactly 20 tweets, so on twitterapi.io 500 tweets is 25 requests and 7,500 credits ($0.075); 1,000 tweets is $0.15 against $5.00 at the official X API's $0.005 per post read. How much history that buys depends on the topic: 500 tweets covered 61.6 days of rust lang and 1.6 days of kubernetes.

What does api.search return that api.user_tweets does not, and vice versa?

Search (both twscrape's and /twitter/tweet/advanced_search) returns originals, replies and quotes but no reposts: 0 of 1,500 search tweets in our study had retweeted_tweet. Timelines (api.user_tweets and /twitter/user/last_tweets) include the account's reposts and, with includeReplies=true, its replies. Search accepts X operators in the query (from:, since:, until:, lang:, min_faves:, -filter:replies); last_tweets takes none. Pick by whether reposts and operators matter.

Can I post or follow from a migrated agent, as agent-twitter-client did?

Writes are a separate, authenticated-session product. One POST /twitter/user_login_v2 ($0.10, with a proxy you supply) returns login_cookies for the account; each subsequent post, like, follow or unfollow (/twitter/create_tweet_v2, /twitter/follow_user_v2 and the other action endpoints) is $0.002 and is billed per HTTP 200 call. The call acts as that account, so X can rate-limit, lock or suspend it; keep a human pace, and expect the session to need a re-login. Reads need no X account at all.

10 — Further reading

Continue

Sources & further reading
More from this series
Build it

Stop reading. Start building.

Starter credits cover real testing on real data. Google sign-in, no card, no application queue.

Get an API key