TwitterAPIs Docs
Guides

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:

EndpointUse case
GET /twitter/user/followers_v2Preferred for new integrations. More consistent cursor model, extra fields (created_at).
GET /twitter/user/followersOriginal 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

NameRequiredDescription
usernameYesHandle without the leading @.
cursorNoOpaque 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.csv

A 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 requests

Run it:

export TWITTERAPIS_KEY="your_key_here"
python3 export_followers.py naval

A 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 naval

Cost math

Each call returns one page of followers (roughly 20 accounts). The cost is $0.0008 per call.

Account sizePages neededTotal 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_v2

with:

https://api.twitterapis.com/twitter/user/followers

The 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 sleep between 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 successful cursor and 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

StatusMeaningAction
200OKCollect users; stop when the users array is empty.
400Bad requestusername is missing or malformed.
401Invalid API keyCheck TWITTERAPIS_KEY.
402Credit balance exhaustedTop up at twitterapis.com.
404Account not foundHandle does not exist or account is private.
429Rate limitedBackoff with exponential retry. Do not restart from page 1.
5xxServer errorRetry 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

On this page