Export an account's full follower list
Page through /twitter/user/followers_v2 with the cursor until exhausted, write results to CSV or JSON. Full cURL, Python, and Node samples with pagination, error handling, and cost math for a 100K-follower account.
This guide walks you through pulling every follower for a Twitter/X account and writing the results to disk. The pattern is a cursor loop: call the endpoint, collect the page, feed next_cursor back into the next request, repeat until the users array comes back empty.
Cost for a 100,000-follower account: roughly 1,000 pages at $0.0008 per call = $0.80 total. Details in the cost section below.
Which endpoint to use
Two endpoints serve follower lists:
| Endpoint | Use case |
|---|---|
GET /twitter/user/followers_v2 | Preferred for new integrations. More consistent cursor model, extra fields (created_at). |
GET /twitter/user/followers | Original variant. Same data, slightly different cursor behavior. |
This guide uses followers_v2. The pagination loop is identical for the original endpoint; just swap the path.
Parameters
| Name | Required | Description |
|---|---|---|
username | Yes | Handle without the leading @. |
cursor | No | Opaque token from the previous response. Omit on the first call. |
Response shape
{
"users": [
{
"id": "9921",
"username": "devjane",
"name": "Jane",
"description": "Building things.",
"followers_count": 4200,
"following_count": 310,
"is_blue_verified": false,
"profile_image_url": "https://pbs.twimg.com/profile_images/...",
"created_at": "Wed Jan 04 09:11:00 +0000 2023"
}
],
"next_cursor": "DAABCgABF..."
}When the users array comes back empty, you have reached the end of the list. Follower-graph endpoints can keep returning a non-null next_cursor even on the final page, so use the empty users array as the stop signal.
Full samples
The shell script below pages through all followers and appends each user as a JSON line to followers.jsonl. It checks whether the users array is empty with jq to decide whether to continue.
#!/usr/bin/env bash
# export-followers.sh
# Usage: ./export-followers.sh <username>
# Requires: curl, jq
# Cost: ~$0.0008 per page (approximately 20 followers per page).
set -euo pipefail
USERNAME="${1:?Usage: export-followers.sh <username>}"
OUTPUT="followers.jsonl"
ENDPOINT="https://api.twitterapis.com/twitter/user/followers_v2"
: "${TWITTERAPIS_KEY:?Set TWITTERAPIS_KEY environment variable.}"
cursor=""
page=0
total=0
> "$OUTPUT" # truncate / create output file
echo "Fetching followers for @${USERNAME} ..."
while : ; do
page=$((page + 1))
if [ -n "$cursor" ]; then
url="${ENDPOINT}?username=${USERNAME}&cursor=${cursor}"
else
url="${ENDPOINT}?username=${USERNAME}"
fi
resp=$(curl -sf "$url" -H "Authorization: Bearer ${TWITTERAPIS_KEY}")
# Stop when the page comes back empty (the reliable end-of-list signal).
count=$(echo "$resp" | jq -r '.users | length')
[ "$count" -eq 0 ] && break
# Write each user as a JSON line
echo "$resp" | jq -c '.users[]' >> "$OUTPUT"
total=$((total + count))
echo "Page ${page}: ${count} users (total so far: ${total})"
cursor=$(echo "$resp" | jq -r '.next_cursor')
# Optional: sleep between pages to stay well under rate limits.
# Remove or reduce if you are not hitting 429 errors.
sleep 0.2
done
echo "Done. ${total} followers written to ${OUTPUT}."Convert to CSV after the run:
jq -r '
["id","username","name","followers_count","following_count","is_blue_verified","created_at"],
([.id, .username, .name, .followers_count, .following_count, .is_blue_verified, .created_at])
| @csv
' followers.jsonl > followers.csvA self-contained script that pages through all followers with exponential backoff on rate-limit errors, and writes both followers.json (full array) and followers.csv.
#!/usr/bin/env python3
"""
export_followers.py -- Export a full Twitter/X follower list via TwitterAPIs.
Usage:
export TWITTERAPIS_KEY="your_key_here"
python3 export_followers.py naval
Output: followers.json + followers.csv in the current directory.
Cost: $0.0008 per page call.
100K followers = ~1,000 pages = $0.80 total.
10K followers = ~100 pages = $0.08 total.
"""
import csv
import json
import os
import sys
import time
import logging
import requests
# ---- config ----
API_KEY = os.environ.get("TWITTERAPIS_KEY")
if not API_KEY:
sys.exit("Error: set the TWITTERAPIS_KEY environment variable.")
ENDPOINT = "https://api.twitterapis.com/twitter/user/followers_v2"
MAX_RETRIES = 5
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("export-followers")
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {API_KEY}"})
# ---- pagination ----
def fetch_all_followers(username: str) -> list[dict]:
"""Yield all followers for username by walking the cursor chain."""
followers: list[dict] = []
cursor: str = ""
page = 0
while True:
page += 1
params: dict = {"username": username}
if cursor:
params["cursor"] = cursor
data = call_with_backoff(ENDPOINT, params)
users = data.get("users", [])
followers.extend(users)
log.info("Page %d: %d users (total: %d)", page, len(users), len(followers))
if not data.get("users"):
break
cursor = data["next_cursor"]
return followers
def call_with_backoff(url: str, params: dict, attempt: int = 0) -> dict:
"""Call url with params, retrying on 429 and 5xx with exponential backoff."""
try:
resp = session.get(url, params=params, timeout=20)
except requests.RequestException as exc:
if attempt >= MAX_RETRIES:
raise
wait = 2 ** attempt
log.warning("Network error (attempt %d/%d): %s. Retrying in %ds.",
attempt + 1, MAX_RETRIES, exc, wait)
time.sleep(wait)
return call_with_backoff(url, params, attempt + 1)
if resp.status_code == 429:
if attempt >= MAX_RETRIES:
resp.raise_for_status()
wait = 60 * (2 ** attempt)
log.warning("Rate limited. Waiting %ds before retry (attempt %d/%d).",
wait, attempt + 1, MAX_RETRIES)
time.sleep(wait)
return call_with_backoff(url, params, attempt + 1)
if resp.status_code >= 500:
if attempt >= MAX_RETRIES:
resp.raise_for_status()
wait = 10 * (2 ** attempt)
log.warning("Server error %d. Retrying in %ds (attempt %d/%d).",
resp.status_code, wait, attempt + 1, MAX_RETRIES)
time.sleep(wait)
return call_with_backoff(url, params, attempt + 1)
resp.raise_for_status()
return resp.json()
# ---- output ----
CSV_FIELDS = [
"id", "username", "name", "description",
"followers_count", "following_count", "is_blue_verified",
"profile_image_url", "created_at",
]
def write_csv(followers: list[dict], path: str) -> None:
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=CSV_FIELDS, extrasaction="ignore")
writer.writeheader()
writer.writerows(followers)
def write_json(followers: list[dict], path: str) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(followers, f, indent=2, ensure_ascii=False)
# ---- main ----
def main() -> None:
if len(sys.argv) < 2:
sys.exit("Usage: python3 export_followers.py <username>")
username = sys.argv[1].lstrip("@")
log.info("Exporting followers for @%s ...", username)
followers = fetch_all_followers(username)
json_path = "followers.json"
csv_path = "followers.csv"
write_json(followers, json_path)
write_csv(followers, csv_path)
log.info(
"Done. %d followers written to %s and %s.",
len(followers), json_path, csv_path,
)
if __name__ == "__main__":
main()Install the only dependency:
pip install requestsRun it:
export TWITTERAPIS_KEY="your_key_here"
python3 export_followers.py navalA TypeScript/Node script that pages through all followers and writes followers.json and followers.csv. Works in Bun or Node 18+.
// export-followers.ts
// Usage:
// TWITTERAPIS_KEY=your_key bun export-followers.ts naval
// TWITTERAPIS_KEY=your_key npx tsx export-followers.ts naval
//
// Output: followers.json + followers.csv in the current directory.
//
// Cost: $0.0008 per page call.
// 100K followers = ~1,000 pages = $0.80 total.
import { createWriteStream, writeFileSync } from "node:fs";
import { join } from "node:path";
const API_KEY = process.env.TWITTERAPIS_KEY;
if (!API_KEY) {
console.error("Error: set TWITTERAPIS_KEY environment variable.");
process.exit(1);
}
const ENDPOINT = "https://api.twitterapis.com/twitter/user/followers_v2";
const MAX_RETRIES = 5;
// ---- types ----
interface Follower {
id: string;
username: string;
name: string;
description?: string;
followers_count: number;
following_count: number;
is_blue_verified: boolean;
profile_image_url?: string;
created_at?: string;
}
interface PageResponse {
users: Follower[];
next_cursor: string;
}
// ---- API call with exponential backoff ----
async function fetchPage(
username: string,
cursor: string,
attempt = 0
): Promise<PageResponse> {
const url = new URL(ENDPOINT);
url.searchParams.set("username", username);
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${API_KEY}` },
signal: AbortSignal.timeout(20_000),
});
if (res.status === 429) {
if (attempt >= MAX_RETRIES) throw new Error("Max retries exceeded on 429.");
const wait = 60_000 * Math.pow(2, attempt);
console.warn(`Rate limited. Waiting ${wait / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES}).`);
await sleep(wait);
return fetchPage(username, cursor, attempt + 1);
}
if (res.status >= 500) {
if (attempt >= MAX_RETRIES) throw new Error(`Server error ${res.status} after max retries.`);
const wait = 10_000 * Math.pow(2, attempt);
console.warn(`Server error ${res.status}. Retrying in ${wait / 1000}s.`);
await sleep(wait);
return fetchPage(username, cursor, attempt + 1);
}
if (!res.ok) {
const body = await res.text();
throw new Error(`Request failed ${res.status}: ${body}`);
}
return res.json() as Promise<PageResponse>;
}
// ---- paginate ----
async function fetchAllFollowers(username: string): Promise<Follower[]> {
const all: Follower[] = [];
let cursor = "";
let page = 0;
while (true) {
page++;
const data = await fetchPage(username, cursor);
all.push(...data.users);
console.log(`Page ${page}: ${data.users.length} users (total: ${all.length})`);
if (data.users.length === 0) break;
cursor = data.next_cursor;
}
return all;
}
// ---- CSV helpers ----
const CSV_FIELDS: (keyof Follower)[] = [
"id", "username", "name", "description",
"followers_count", "following_count", "is_blue_verified",
"profile_image_url", "created_at",
];
function toCSVRow(row: Follower): string {
return CSV_FIELDS
.map((f) => {
const val = row[f] ?? "";
const s = String(val).replace(/"/g, '""');
return `"${s}"`;
})
.join(",");
}
function writeCSV(followers: Follower[], path: string): void {
const header = CSV_FIELDS.join(",");
const rows = followers.map(toCSVRow);
writeFileSync(path, [header, ...rows].join("\n"), "utf8");
}
// ---- util ----
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// ---- main ----
async function main(): Promise<void> {
const username = (process.argv[2] ?? "").replace(/^@/, "");
if (!username) {
console.error("Usage: TWITTERAPIS_KEY=key bun export-followers.ts <username>");
process.exit(1);
}
console.log(`Exporting followers for @${username} ...`);
const followers = await fetchAllFollowers(username);
const jsonPath = "followers.json";
const csvPath = "followers.csv";
writeFileSync(jsonPath, JSON.stringify(followers, null, 2), "utf8");
writeCSV(followers, csvPath);
console.log(`Done. ${followers.length} followers written to ${jsonPath} and ${csvPath}.`);
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});No dependencies. Run with Bun or Node 18+ (native fetch built in):
export TWITTERAPIS_KEY="your_key_here"
bun export-followers.ts naval
# or
npx tsx export-followers.ts navalCost math
Each call returns one page of followers (roughly 20 accounts). The cost is $0.0008 per call.
| Account size | Pages needed | Total cost |
|---|---|---|
| 1,000 followers | ~50 | $0.04 |
| 10,000 followers | ~500 | $0.40 |
| 100,000 followers | ~1,000 | $0.80 |
| 1,000,000 followers | ~10,000 | $8.00 |
For a 100K account you are looking at $0.80 for the full export. Run it once, cache the result, and refresh only on a schedule rather than on every request.
Check page size before estimating
Page size is not guaranteed to be exactly 20. For accounts with complex follower graphs, some pages may contain fewer users. Use the actual page count from your run to get the precise final cost.
Choosing between v1 and v2
Both endpoints are $0.0008 per call and accept the same username + cursor parameters. The v2 variant adds created_at on the user object and has a more stable cursor model (the cursor does not expire mid-crawl as quickly on large accounts). Use followers_v2 for new builds.
To switch back to the original endpoint, replace:
https://api.twitterapis.com/twitter/user/followers_v2with:
https://api.twitterapis.com/twitter/user/followersThe pagination loop, response shape, and output code are identical.
Rate-aware looping
The API returns HTTP 429 when you are sending requests too fast. The samples above handle this with exponential backoff. A few practical notes:
- A short
sleepbetween pages (100-200 ms) usually prevents hitting the rate limit at all on large exports. - If you do get a
429, wait at least 60 seconds before the first retry. Double the wait on each subsequent retry. - Do not restart from page 1 on a
429. Save the last successfulcursorand resume from there.
The Python and Node samples save the cursor state implicitly (in the in-memory cursor variable). For very large accounts where a job might span multiple runs, write the cursor to disk after each successful page so you can resume after an interruption.
Error handling reference
| Status | Meaning | Action |
|---|---|---|
200 | OK | Collect users; stop when the users array is empty. |
400 | Bad request | username is missing or malformed. |
401 | Invalid API key | Check TWITTERAPIS_KEY. |
402 | Credit balance exhausted | Top up at twitterapis.com. |
404 | Account not found | Handle does not exist or account is private. |
429 | Rate limited | Backoff with exponential retry. Do not restart from page 1. |
5xx | Server error | Retry with backoff. Contact emma@twitterapis.com if it persists. |
Private accounts return 404
A private (protected) account returns 404 even if the account exists. The follower list is not accessible for protected accounts without a customer session (auth_token + ct0). See the Authentication page for details on write and private-endpoint access.
Next steps
User Followers reference
Full parameter table, response schema, and all error codes for the followers endpoint.
Pagination
How cursor-based pagination works across all TwitterAPIs endpoints.
Errors
Status codes, the JSON error shape, and how to handle each.
Build a brand monitor
Poll advanced_search on an interval and surface net-new mentions.
Build a Twitter/X brand monitor
Poll advanced_search on an interval, dedupe by tweet ID, and surface net-new mentions. Full cURL, Python, and Node samples with cost math.
Stream the latest tweets for a keyword
Use advanced_search with product=Latest and a high-water-mark tweet ID to poll new tweets in near real time. Full cURL, Python, and Node samples with cost math.