XAnswer

Twitter API followers list: what X charges and a script to export it

X's API lists followers at $0.010 each, 1,000 per request. Ours returns 200 per call; we read 20,000 of a big list, no end found. CSV script.

9 min read

Short answer

  • Yes. X's API lists followers: GET /2/users/{id}/followers returns up to 1,000 per request at $0.010 per follower, so 1,000 followers cost $10 and 10,000 cost $100. Your own account's followers cost $0.001 each (docs.x.com, read 2026-10-16).
  • The catch on our side: ToolzerHub pages 200 followers per call. On 2026-10-16 we read 20,000 followers of a 2.7 million account in 100 calls and never reached an end; we did not test deeper. Four small accounts paged to the end, within about 1% of their profile count.
  • 3 credits per page, so 1,000 followers are 15 credits: $0.015 to $0.029. 100 free credits a month. The script below writes followers or following lists to CSV with a page cap.
On this page

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.

python
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

bash
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:

text
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

Get this data from your own code

The X (Twitter) API returns it as JSON with one x-api-key header. 100 free credits monthly.