X's API can list an account's followers and the accounts it follows. It returns up to 1,000 per request and charges $0.010 for each follower returned, so 1,000 followers cost $10 and 10,000 cost $100. Your own account's followers cost $0.001 each. We read X's documentation on 2026-10-16. ToolzerHub lists followers 200 per call; we read 20,000 of one large account without reaching an end, and we did not test past that.
What X's API returns and charges
X lists followers with GET /2/users/{id}/followers and the accounts a user follows with GET /2/users/{id}/following. Both take max_results from 1 to 1,000 and a pagination_token. X's rate-limit table gives both 300 requests per 15 minutes, per app and per user.
| Item | Value |
|---|---|
| Followers path | /2/users/{id}/followers |
| Following path | /2/users/{id}/following |
| Per request | 1 to 1,000 |
| Rate limit | 300 per 15 minutes |
| Price | $0.010 per follower |
| Your own list | $0.001 per follower |
X's pricing page lists "Following/Followers: Read" at "$0.010 per resource" and says reads are "Charged per resource returned in the response." The id in the path is numeric (pattern ^[0-9]{1,19}$), so starting from a handle you first look the user up, which X lists as "User: Read" at $0.010.
Two pricing rules matter. "All resources are deduplicated within a 24-hour UTC day window", so the same follower read twice on one UTC day is charged once, though "Deduplication is a soft guarantee." And "Pay-per-usage plans are capped at 3 million Post reads per monthly billing cycle": that names Post reads, and the page states no cap for follower reads. It also says "Prices are subject to change."
Your own account's list costs a tenth of that. X's pricing page: "Owned Reads are requests made by your own developer app for your own data (posts, bookmarks, followers, likes, lists, and more). These endpoints are priced at $0.001 per resource (1,000 resources for $1)." The rate applies when "{id} matches the authenticated user and that user is the owner of the developer app".
List followers with ToolzerHub
We make ToolzerHub. Our X API lists followers by handle: GET /v1/x/profile/followers and GET /v1/x/profile/followings cost 3 credits per call and take a cursor. /v1/x/profile costs 1 credit and returns followers_count. /v1/x/profile/verified-followers costs 1 credit and takes a numeric user_id; it returned 20 profiles per page for NASA (credits from our OpenAPI file, 2026-10-16).
We ran 10 live calls on NASA, GitHub, nytimes and BBCWorld (profiles, follower pages and following pages). All 10 returned 200 in 0.5 to 1.4 seconds, and each follower page held 200 profiles with has_more: true and a cursor. A profile has 20 fields, among them id, handle, name, followers_count, following_count, created_at and protected. Over 1,000 SymPy follower rows, 638 had a description, 505 a location and 289 a website, and 134 were marked protected.
On the followers endpoint a missing account returned HTTP 200 with an empty profiles list, and so did two protected accounts (data: null is also possible; the script handles both). A handle with a space or over 15 characters returns 400.
How deep the list goes
On a large account we did not find where the followers list stops. On small accounts it ended within about 1% of the profile count. All numbers are from 2026-10-16.
| Account | Profile count | Rows read | Pages | End |
|---|---|---|---|---|
| SymPy | 4,327 | 4,299 | 22 | reached |
| Tidelift | 2,886 | 2,870 | 15 | reached |
| MathJax | 2,358 | 2,338 | 12 | reached |
| Astropy | 4,199 | 4,169 | 21 | reached |
| GitHub | about 2.72M | 20,000 | 100 | not reached |
| NASA | about 92.3M | 5,000 | 25 | not reached |
"Reached" means has_more turned false. On GitHub and NASA it stayed true on every page and we stopped at our own page caps, so we can't say what happens past 20,000. Every id was unique within each run, with no repeated cursor. A page took 1.1 to 2.1 seconds on average; the 100-page GitHub run took 164 seconds. We don't know why the small accounts came back 16 to 30 rows short of their profile counts. Profile counts on big accounts moved by 30 to 46 between two reads, so some of the gap may be churn.
The following lists matched their profile counts exactly: SymPy 96, Tidelift 858.
Export followers to CSV in Python
The script takes handles, a page cap and followers, following or both. It follows the cursor, de-duplicates by account id and writes eight columns.
import csv
import os
import sys
import time
import requests
BASE = "https://api.toolzerhub.com/v1/x/profile"
HEADERS = {"x-api-key": os.environ.get("TOOLZERHUB_API_KEY", "")}
FIELDS = ["account", "list", "id", "handle", "name", "followers_count", "following_count",
"created_at"]
PATH = {"followers": "followers", "following": "followings"}
calls = 0
BAD = object() # marks a 400, so the run can tell "skipped" from "empty"
def cell(value):
# Spreadsheets run text that starts with = + - or @ as a formula; an apostrophe keeps it text.
if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t", "\r"):
return "'" + value
return value
def get(kind, params, tries=5):
# 429 and 503 are retryable; the wait comes from the error body or the Retry-After header.
global calls
for attempt in range(tries):
r = requests.get(f"{BASE}/{PATH[kind]}", headers=HEADERS, params=params, timeout=90)
calls += 1
if r.status_code == 400: # malformed handle: skip it, keep the rest of the run
print("bad request:", r.json()["error"]["message"].splitlines()[0])
return BAD
if r.status_code not in (429, 503) or attempt == tries - 1:
r.raise_for_status()
return r.json().get("data") # None when there is nothing to return
try:
wait = r.json()["error"]["details"]["retryAfter"]
except (ValueError, KeyError, TypeError):
wait = None
try:
wait = int(wait or r.headers.get("Retry-After") or 5 * (attempt + 1))
except ValueError:
wait = 5 * (attempt + 1)
time.sleep(min(wait, 60))
def export_list(writer, account, kind, max_pages):
params, seen, pages = {"handle": account}, set(), 0
while pages < max_pages:
data = get(kind, params)
if data is BAD:
return 0
if not data or not data.get("profiles"):
break # null data, a protected or unknown account, or an empty list
pages += 1
for u in data["profiles"]:
if u["id"] in seen: # keep one row per account id
continue
seen.add(u["id"])
writer.writerow({
"account": account, "list": kind, "id": u["id"], "handle": cell(u.get("handle")),
"name": cell((u.get("name") or "").replace("\n", " ")),
"followers_count": u.get("followers_count"),
"following_count": u.get("following_count"),
"created_at": u.get("created_at"),
})
if not data.get("has_more") or not data.get("cursor"):
break
params["cursor"] = data["cursor"]
time.sleep(0.2)
stop = "" if pages else " (nothing returned: unknown, protected or follows no one)"
print(f"{account} {kind}: {len(seen)} accounts, {pages} pages{stop}")
return len(seen)
def main(accounts, max_pages="5", which="followers", out="x_followers.csv"):
kinds = ["followers", "following"] if which == "both" else [which]
total = 0
with open(out, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
for account in [a.strip().lstrip("@") for a in accounts.split(",") if a.strip()]:
for kind in kinds:
total += export_list(writer, account, kind, int(max_pages))
print(f"wrote {total} rows to {out} in {calls} calls")
if __name__ == "__main__":
if len(sys.argv) < 2 or (len(sys.argv) > 3 and sys.argv[3] not in ("followers", "following", "both")):
sys.exit("usage: python x_followers.py tidelift,sympy [max_pages]"
" [followers|following|both] [file.csv]")
if not HEADERS["x-api-key"]:
sys.exit("set TOOLZERHUB_API_KEY first")
main(*sys.argv[1:])
A 400 skips that handle with a message, an empty or unknown account is reported and skipped, and 429 and 503 are retried. We tested the retry branch against a stand-in server, not live. cell() puts an apostrophe in front of text that starts with =, +, - or @, so a spreadsheet shows it as text instead of running it as a formula.
Run the script
export TOOLZERHUB_API_KEY=your_key
python x_followers.py "tidelift, @sympy ,nobody_here_qq9,,bad handle" 30 both x_followers.csv
Output on 2026-10-16, with a stray space, an @, an empty item and an invalid handle added to test the handling:
tidelift followers: 2870 accounts, 15 pages
tidelift following: 858 accounts, 5 pages
sympy followers: 4299 accounts, 22 pages
sympy following: 96 accounts, 1 pages
nobody_here_qq9 followers: 0 accounts, 0 pages (nothing returned: unknown, protected or follows no one)
nobody_here_qq9 following: 0 accounts, 0 pages (nothing returned: unknown, protected or follows no one)
bad request: ✖ handle must be 1-15 characters of letters, digits or underscore, e.g. "nasa", "@nasa", or "https://x.com/nasa"
bad request: ✖ handle must be 1-15 characters of letters, digits or underscore, e.g. "nasa", "@nasa", or "https://x.com/nasa"
wrote 8123 rows to x_followers.csv in 47 calls
It took 121 seconds and at most 141 credits, $0.14 to $0.27. We don't show rows from the file, since follower lists are ordinary people. A follower list is personal data about people who never signed up for your project, so collect only the fields you need and delete the file when the job is done.
What 1,000 and 10,000 followers cost
On our side, 1,000 followers are 5 pages at 3 credits, 15 credits. Credits cost $0.99 to $1.90 per 1,000: $0.99 on the $495 plan, $1.45 on the $29 plan, $1.90 on the smallest pack. The free tier is 100 credits a month, enough for 33 pages, about 6,600 followers.
| Job | X, other account | ToolzerHub |
|---|---|---|
| 1,000 followers | $10.00 | $0.015 to $0.029 |
| 10,000 followers | $100.00 | $0.15 to $0.29 |
| Your own 1,000 | $1.00 | $0.015 to $0.029 |
| Numeric id from a handle | $0.010 | 1 credit, $0.001 to $0.002 |
The 10,000 row is inside what we measured: GitHub's list gave 20,000 unique rows in 100 pages, 300 credits. We did not price beyond that. X's column assumes first reads that day.
Where X's API is the better pick
- Your own account's followers, at $0.001 each.
- Anything that writes, such as following or posting. Our API reads public data only.
- Bigger pages. X returns up to 1,000 per request and we return 200, so 100,000 followers take 100 requests on X; on ours that would be 500 pages, but we did not test past 20,000.
- Compliance, enterprise access and a contract with X.
For X's other prices and free credits, see is the Twitter API free. For another platform where the official API stops short of a list, see Instagram API get followers list.
We are not affiliated with X Corp.
Sources
- X, Get users' followers, https://docs.x.com/x-api/users/get-followers, read 2026-10-16
- X, Get users' following, https://docs.x.com/x-api/users/get-following, read 2026-10-16
- X, Pay-per-usage pricing and credits, https://docs.x.com/x-api/getting-started/pricing, read 2026-10-16
- X, Rate limits, https://docs.x.com/x-api/fundamentals/rate-limits, read 2026-10-16
- ToolzerHub OpenAPI file, https://api.toolzerhub.com/openapi.json, checked 2026-10-16
- ToolzerHub pricing, https://toolzerhub.com/pricing, checked 2026-10-16