GUIDE
Exporting Twitter Followers With an API in 2026: A Build Guide
A working engineer's guide to pulling any public X account's followers through a REST API at $0.0008 per call. Cursor pagination, checkpointed resume, incremental sync, storage, consent, and full Python plus Node code.

Exporting Twitter followers with an API means calling a REST endpoint that takes a public account's handle and streams back its follower list as structured JSON, one paginated page at a time, so you can store, filter, and synchronize that audience inside your own database instead of scrolling the web app or negotiating an enterprise data contract. The rest of this guide treats that definition as an engineering problem: how the endpoint behaves under real load, how to page it without losing your place, where to put the data, how to keep it fresh, and how to stay on the right side of consent while you do it.
TL;DR: Call
GET /twitter/user/followerswith a bearer token and a handle. Each request returns about 70 follower records on the first page and fewer after that for $0.0008 and hands you anext_cursorto walk the rest. Persist the cursor so you can resume, store records in a keyed table withfirst_seenandlast_seenso daily runs become clean deltas, wrap requests in backoff-with-jitter, and name a lawful basis before you use the data. A million-follower account exports for roughly $16.00, and the $0.50 signup credit covers about 31,000 records before any card is needed, per our pricing page.
Most follower-export tutorials stop at "here is a for-loop that appends to a list." That works for a screenshot and falls apart the first time you point it at an account with two million followers, lose network partway through, or try to run it every morning without re-pulling the entire graph. This guide is written for the version of the task that ships to production, so it spends most of its length on the parts that break: cursor stability, resumable checkpoints, storage that supports incremental sync, retry math, and the lawful-use questions that a real data pipeline has to answer.
The pain that sends engineers looking for a REST route in the first place is that the official platform gate is closed for this exact data. A developer answering a question about pulling an account's followers put it plainly:
https://x.com/thegrif/status/2023154151320887619
That is the whole problem in one reply. The /followers collection on the official X API is reserved for the most expensive access tier, so a hobby script or a lean startup pipeline cannot touch it at any reasonable price. The full endpoint reference for the drop-in alternative lives on the Twitter followers API page, and this post is the build walkthrough that sits underneath it.
Why a follower list is worth engineering around
A follower list is worth the effort because it is a behavioral dataset, not a vanity metric. When an account chooses to follow a competitor, a category leader, or a conference organizer, that choice is a first-person signal of interest that no purchased firmographic list captures with the same freshness. The engineering payoff is that this signal is cheap to collect and trivially re-collectible, which turns a static contact list into a living feed of who is paying attention to whom.
Three patterns recur once teams have the raw export in hand, and each one is a query on top of the same table rather than a separate scraping project.
The first is overlap mapping. Pull the followers of three or four direct competitors, load them into one keyed store, and the accounts that appear in more than one list are the people actively evaluating your category. They followed multiple vendors on purpose, and that intersection is a far tighter buying signal than any single follow. The intersection query is a GROUP BY id HAVING count(*) > 1, and it costs nothing beyond the original pulls.
The second is audience verification before spend. Before paying for a placement with a large account, export that account's followers and measure the shape of the audience: how many records are recently created and near-empty, how the reach distribution looks, how many carry real bios. A 100,000-follower account whose followers are mostly dormant shells is worth a fraction of a 20,000-follower account whose audience posts and gets listed. The same fields that power an export also drive a standalone Twitter bot detection guide workflow, so the verification is a filter, not a new integration.
The third is timed outreach. New followers are warm by definition, because following is a voluntary, recent action. A daily pull that surfaces who just followed a competitor gives you a same-day list of people whose interest signal is still hot, which is the entire premise behind recency-weighted lead scoring. A practitioner who scrapes followers specifically to retarget them elsewhere described exactly this motion in a community thread:
https://www.reddit.com/r/webscraping/comments/jw5ejc/scraping_twitter_followers_retargeting_them_on/
The three ways to pull followers in 2026, and where two of them fail
There are three realistic paths to a follower list in 2026, and only one of them holds up as a maintained pipeline. Understanding why the other two break is what stops you from rebuilding this every quarter.
The official X API is the first path and the one the reply above already ruled out. Reading followers through the v2 route is capped and gated: the documented limits allow only a small number of follower IDs per call in a tight window, and unbounded access to profile-level follower data sits behind the enterprise tier whose commercial floor runs into tens of thousands of dollars per month. According to X's own developer documentation, even the accessible tier returns identifiers rather than full profiles by default, so you pay for volume and still have to enrich afterward. For a breakdown of what shifted on the official side, the 2026 X API pricing change post tracks the moving parts.
Browser automation is the second path. A headless browser logged into a session can scroll a follower list and scrape the DOM, and for a one-off pull of a small account it technically works. It stops working the moment you need scale or reliability. The follower list in the web app lazy-loads and stops surfacing records after a few thousand, so most of a large audience is simply unreachable by scrolling. Sessions get challenged, layouts get redesigned, and residential proxies turn a "free" scrape into an infrastructure bill. If you are weighing that route anyway, the best residential proxies for Twitter scraping comparison covers the tradeoffs honestly, and the best Twitter API for scraping piece explains why most teams end up abandoning the browser stack.
The REST adapter is the third path and the subject of the rest of this guide. One authenticated GET returns about 70 full profiles, paginates with a stable cursor, and carries no session state to babysit. The tradeoff is that you are trusting a third party to maintain the collection layer, which is exactly the point: the maintenance treadmill becomes someone else's problem, and your code stays a plain HTTP client. A video walkthrough of the browser-tool approach makes the contrast concrete, showing how much manual scrolling and per-account setup the non-API path demands:
https://www.youtube.com/watch?v=qxm-nyOTe0Y
The endpoint contract
The endpoint is GET https://api.twitterapis.com/twitter/user/followers, it authenticates with a bearer token, it takes a handle and an optional cursor, and it returns about 70 follower records on the first page and fewer after that, ordered newest-follow-first, for $0.0008 per call. That single sentence is the whole contract, and everything downstream is a consequence of it. Here is the smallest possible working call:
curl -s "https://api.twitterapis.com/twitter/user/followers?userName=supabase" \
-H "Authorization: Bearer $TWITTERAPIS_KEY" \
| jq '{count: (.followers | length), more: (.next_cursor != null), cursor: .next_cursor}'
The userName parameter is the screen name with no leading @. The optional cursor parameter is the pagination token you carry forward from the previous response. Authentication is a single bearer token in the Authorization header, so there is no app registration and no OAuth handshake. If you have not created a key yet, the how to get a Twitter API key walkthrough covers the signup-to-first-call path, and the is the Twitter API free breakdown explains where the $0.50 starter credit fits against paid usage.
Each request costs $0.0008 and returns about 70 records on the first page and fewer on the pages after it, which is the number every cost and timing estimate in this guide traces back to. The count parameter is currently ignored, so page size is fixed regardless of what you request. The starter credit of $0.50 covers about 625 calls, or roughly 31,000 follower records, which is enough to page several small accounts before you add funds.
The response envelope, field by field
Every response is a JSON object with three top-level keys plus an array, and knowing the shape up front means your parser never has to guess. The envelope carries userName (the account you queried), user_count (records in this page), next_cursor (the token for the next page, null or empty once you reach the end), and followers (the array of records).
{
"userName": "supabase",
"user_count": 70,
"next_cursor": "cur-4f9a2b...",
"followers": [
{
"type": "user",
"id": "1592217614...",
"userName": "backend_bee",
"name": "Priya, building infra",
"url": "https://twitter.com/backend_bee",
"isVerified": false,
"isBlueVerified": true,
"description": "Platform engineer. Postgres, queues, tracing.",
"location": "Berlin",
"followers": 2140,
"following": 512,
"tweets": 3380,
"listed": 17,
"createdAt": "2019-08-03T09:12:44.000Z",
"canDm": true
}
]
}
Each record exposes sixteen fields. Four describe identity (id, userName, name, url), two describe status (isVerified, isBlueVerified), two describe imagery (profilePicture, coverPicture), and the rest describe substance you actually filter on: description for bio-keyword matching, location for geography, followers and following for reach and ratio, tweets for activity, listed for peer recognition, createdAt for tenure, and canDm for whether an outbound message can even land. The last one matters more than its size suggests, because DM eligibility is not returned by the official API through any tier, so having it inline is what lets you scope a reachable audience without a second lookup.
Rather than pass raw dictionaries around your codebase, it pays to parse each record into a typed structure once at the boundary. A slotted dataclass keeps memory low on large exports and gives every downstream function a stable shape:
from dataclasses import dataclass
@dataclass(slots=True)
class Follower:
id: str
handle: str
name: str
bio: str
followers: int
following: int
tweets: int
listed: int
verified: bool
blue: bool
can_dm: bool
created_at: str
location: str
def to_follower(raw: dict) -> Follower:
return Follower(
id=raw["id"],
handle=raw["userName"],
name=raw.get("name", ""),
bio=raw.get("description", ""),
followers=raw.get("followers", 0),
following=raw.get("following", 0),
tweets=raw.get("tweets", 0),
listed=raw.get("listed", 0),
verified=raw.get("isVerified", False),
blue=raw.get("isBlueVerified", False),
can_dm=raw.get("canDm", False),
created_at=raw.get("createdAt", ""),
location=raw.get("location", ""),
)
How cursor pagination actually behaves
Cursor pagination on this endpoint is opaque and position-stable, which is the property that makes large exports safe. The cursor is not an offset you can compute; it is a token the server hands you, and you send it back verbatim to get the next page of records. Because the server anchors the cursor to a position in the follow-time-ordered list rather than to a numeric index, follows that arrive between two of your calls do not shift records you have already read, so you neither skip nor double-count during a long pull.
The practical shape of a paginated pull is a loop that starts with no cursor, reads the page, and continues while next_cursor keeps coming back non-empty. The subtle part is that the cursor is just a string, which means it is serializable. You can write it to a file, kill the process, and resume the export tomorrow from the exact page you stopped on. That single fact is the difference between an export that fails permanently on a network blip at page 4,900 of 5,000 and one that shrugs the blip off. Treat the cursor as durable state, not as a loop variable, and every other reliability property follows.
One more behavior worth internalizing: the ordering is newest-follow-first. The very front of the list is the account's most recent followers, which is also the freshest, warmest slice for outreach. When you only need recent joiners rather than the whole graph, you can stop after the first few pages instead of paging to exhaustion, which is both faster and cheaper.
A resumable exporter in Python
A production exporter is a small class that owns its cursor, writes it to a checkpoint file after every page, and deletes the checkpoint only when the account is fully paged. That structure gives you crash-safe resume for free, and it is barely longer than the naive for-loop it replaces. This version uses httpx as the HTTP client and hands each page to a callback so the caller decides where records land.
import os
import json
import time
from pathlib import Path
import httpx
KEY = os.environ["TWITTERAPIS_KEY"]
ROOT = "https://api.twitterapis.com/twitter"
class FollowerExport:
def __init__(self, handle: str, checkpoint: str):
self.handle = handle
self.checkpoint = Path(checkpoint)
self.cursor = None
if self.checkpoint.exists():
self.cursor = json.loads(self.checkpoint.read_text()).get("cursor")
def _save(self):
self.checkpoint.write_text(json.dumps({"cursor": self.cursor}))
def run(self, client: httpx.Client, on_page) -> int:
pulled = 0
while True:
params = {"userName": self.handle}
if self.cursor:
params["cursor"] = self.cursor
resp = client.get(
f"{ROOT}/user/followers",
params=params,
headers={"Authorization": f"Bearer {KEY}"},
timeout=60,
)
resp.raise_for_status()
page = resp.json()
on_page(page["followers"])
pulled += len(page["followers"])
self.cursor = page.get("next_cursor")
if not self.cursor:
self.checkpoint.unlink(missing_ok=True)
return pulled
self._save()
time.sleep(0.1)
with httpx.Client() as client:
job = FollowerExport("supabase", checkpoint="supabase.cursor")
total = job.run(client, on_page=lambda rows: print(f"page: {len(rows)}"))
print(f"done: {total} followers")
Because the cursor is persisted after each page, re-running the same script after a crash picks up where it left off rather than restarting from the top. When the export completes cleanly the checkpoint file is removed, so the next scheduled run starts fresh. For endpoint-specific patterns beyond followers, the Python Twitter API tutorial walks through the same client style across search and user lookups.
Retry, backoff, and the rate-limit math
The single most important reliability addition is retry-with-backoff-and-jitter around every request, because transient failures and occasional throttling are normal at volume and should never abort a job. The rule of thumb is simple: on a 429, wait an exponentially growing interval plus a small random offset, then try again; on a transport error, retry a few times before giving up; on anything else, fail loud. The jitter matters because it stops many workers from retrying in lockstep and re-colliding.
import random
import time
import httpx
def get_json(client, url, params, headers, tries=5):
for attempt in range(tries):
resp = client.get(url, params=params, headers=headers, timeout=60)
if resp.status_code == 429:
wait = min(30.0, 2 ** attempt) + random.random()
time.sleep(wait)
continue
resp.raise_for_status()
return resp.json()
raise RuntimeError(f"gave up after {tries} attempts on {url}")
The timing math is easier than it looks. Follower pages are independent of each other within an account, so a small pool of concurrent workers moves fast, but the safe ceiling is a handful of parallel requests against one account, not dozens. A workable model is a pool of about five workers with the backoff above: a one-million-follower account is 5,000 sequential calls, and a five-way pool brings the wall-clock time down to roughly a fifth of the sequential pass while staying comfortably inside throttle limits. If you genuinely need more throughput, spread it across multiple target accounts rather than piling workers onto a single one, which is the pattern the Twitter API rate limit guide covers in depth. For production hardening beyond retries, the best practices guide collects the cost-metering and observability patterns worth adding early.
Where the data should live: CSV, JSONL, and SQLite
Raw records need a home, and the right home depends on whether you are exporting once or syncing forever. For a one-time hand-off, flat files are perfect; for anything that repeats, a keyed database is what turns re-pulls into deltas instead of duplicates. The three formats cover almost every case.
A single-run export to CSV is the classic spreadsheet or CRM-import format, and Python's csv module writes it in a few lines with DictWriter so column order stays explicit and extra fields never break the writer. JSONL (one JSON object per line) is the better choice when you want to keep every field, including nested imagery, and stream the file without loading it whole. But the moment the same account gets pulled twice, flat files start accumulating duplicate rows and you lose the ability to answer "who is new," which is where a keyed store earns its place.
SQLite is the pragmatic default for that keyed store: no server, one file, and a real PRIMARY KEY that makes upserts idempotent. Modeling the follower id as the primary key and adding first_seen and last_seen timestamps is what unlocks incremental sync, because the database itself now tracks the history you would otherwise have to diff by hand.
import sqlite3
def open_store(path="followers.db"):
db = sqlite3.connect(path)
db.execute(
"""
CREATE TABLE IF NOT EXISTS followers (
id TEXT PRIMARY KEY,
handle TEXT,
name TEXT,
bio TEXT,
followers INTEGER,
following INTEGER,
tweets INTEGER,
listed INTEGER,
can_dm INTEGER,
created_at TEXT,
location TEXT,
first_seen TEXT,
last_seen TEXT
)
"""
)
return db
def upsert_page(db, rows, seen_at):
db.executemany(
"""
INSERT INTO followers
(id, handle, name, bio, followers, following, tweets, listed,
can_dm, created_at, location, first_seen, last_seen)
VALUES
(:id, :userName, :name, :description, :followers, :following,
:tweets, :listed, :canDm, :createdAt, :location, :seen, :seen)
ON CONFLICT(id) DO UPDATE SET
last_seen = :seen,
followers = :followers,
bio = :description
""",
[{**r, "seen": seen_at, "canDm": int(r.get("canDm", False))} for r in rows],
)
db.commit()
The ON CONFLICT clause is the whole trick: a follower seen for the first time gets a first_seen stamp, and a follower seen again just refreshes last_seen and a few volatile fields. Exporting a clean CSV out of that store when someone downstream needs a spreadsheet is then a single query:
import csv
def dump_csv(db, path="followers.csv"):
cols = ["id", "handle", "name", "followers", "following", "tweets",
"listed", "can_dm", "created_at", "location"]
cur = db.execute(f"SELECT {', '.join(cols)} FROM followers")
with open(path, "w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(cols)
writer.writerows(cur.fetchall())
Start building with TwitterAPIs
$0.04 per 1,000 tweets. $0.50 free credits. No credit card required.
Incremental sync: turning a re-pull into a delta
Once records live in a keyed store, keeping a follower list current stops being a re-export and becomes a diff the database computes for you. The pattern is to run the same paged pull on a schedule, upsert every record with the current timestamp, and then ask the store two questions: which ids were first seen in the last window, and which known ids failed to appear this run. The first set is new follows, the second is unfollows.
Because first_seen is written only on the initial insert, "who just followed" is a plain filter on that column, and it is by far the highest-value query in the whole pipeline. New followers are the warmest possible audience, which a marketer surfacing exactly this data called out in a follower-export request thread:
https://www.reddit.com/r/Twitter/comments/1tmtp9x/download_exporting_following_list/
The delta query itself is trivial once the schema carries timestamps:
-- accounts first captured in the last 24 hours = brand-new followers
SELECT handle, name, followers, can_dm
FROM followers
WHERE first_seen >= datetime('now', '-1 day')
ORDER BY followers DESC;
The economics reward the pattern. A daily refresh of a 100,000-follower account is about 2,000 calls, roughly $1.60 per run, and running it against five competitor accounts is around $8.00 a day, per our pricing page. That daily $8.00 buys a same-day feed of decision-makers who just followed your rivals, already scored and DM-checked, which is a fundamentally different asset than a stale bulk file. Two operational notes make the deltas trustworthy: run the pull at a consistent hour so the comparison window is stable, and treat the newest-follow-first ordering as a shortcut, because a fresh follower feed rarely needs a full re-page to exhaustion.
Scheduling the daily sync
A follower delta is only useful if it runs on a dependable clock, so the sync belongs in a scheduler that fires at the same hour every day and is safe to retry without side effects. Because the store upserts on the follower id, re-running the job is idempotent by construction: a duplicated run refreshes last_seen and changes nothing else, which means a retry after a failure never corrupts the dataset. That property is what lets you schedule aggressively instead of nervously.
A plain cron entry covers most single-box deployments, and the consistency of the run time is the part that actually matters, because a stable window is what makes the day-over-day delta comparable:
# /etc/cron.d/follower-sync (runs 06:00 UTC daily as the deploy user)
0 6 * * * deploy cd /opt/pipelines && /usr/bin/python3 sync.py >> /var/log/follower-sync.log 2>&1
Two guardrails make the scheduled job production-grade. First, take a lock (a lock file or a flock wrapper) so a slow run cannot overlap the next scheduled start and double-page the same account. Second, alert on a non-zero exit rather than on log volume, because a silent failure is the way a delta feed quietly goes stale for a week before anyone notices. A sync.py that opens the store, runs the checkpointed exporter per account, and prints the count of brand-new followers is a natural place to emit that exit status. On a managed platform, a scheduled function or a systemd timer gives you the same behavior with better observability than raw cron.
Cleaning a raw export before you trust it
Every large follower list contains a meaningful fraction of dormant, duplicate, or low-quality accounts, so a cleaning pass sits between the raw pull and any downstream use. Two problems dominate: literal duplicates from overlapping pulls, and shell accounts that inflate a list without ever converting.
Deduplication is already solved if you stored records under a primary key, because the id conflict handles it at write time. If you are working from stacked flat files instead, deduping on id (never on handle, which can be reused) is the correct key. The harder pass is quality. A compact heuristic scores each account against a handful of shell-account signals and flags the ones that fail several at once:
from datetime import datetime, timezone
def is_low_quality(row) -> bool:
flags = 0
# near-empty activity
if row["tweets"] < 10:
flags += 1
# follow-farming ratio
if row["followers"] and row["following"] / max(row["followers"], 1) > 10:
flags += 1
# no peer recognition
if row["listed"] == 0 and row["tweets"] == 0:
flags += 1
# no bio at all
if not (row.get("bio") or "").strip():
flags += 1
# very fresh account
created = row.get("created_at", "")
if created[:4].isdigit():
age_years = datetime.now(timezone.utc).year - int(created[:4])
if age_years < 1:
flags += 1
return flags >= 3
Applying a "flag three or more signals" rule to a typical export usually removes somewhere in the 15 to 35 percent range as low quality, leaving a substantially cleaner remainder for outreach or analysis. The point is not perfect classification; it is raising the signal-to-noise ratio before you spend enrichment budget or sending reputation on the list. Filtering out the obvious shells also protects deliverability, because you stop sending to accounts that were never going to respond.
Segmenting the cleaned list into an audience
A cleaned list is still just rows until you slice it into the specific audience you can act on, which is a filter plus a ranking rather than a new data source. The filter narrows to your ideal profile; the ranking orders what survives so the best prospects sit at the top.
Filtering is a query over the fields the export already carries: a minimum reach threshold, a bio keyword, a location match, and a DM-open flag cover the majority of real segments.
def segment(db, min_followers=1000, keyword=None, dm_only=False, place=None):
clauses = ["followers >= ?"]
args = [min_followers]
if dm_only:
clauses.append("can_dm = 1")
if keyword:
clauses.append("lower(bio) LIKE ?")
args.append(f"%{keyword.lower()}%")
if place:
clauses.append("lower(location) LIKE ?")
args.append(f"%{place.lower()}%")
sql = "SELECT * FROM followers WHERE " + " AND ".join(clauses)
return db.execute(sql, args).fetchall()
On a B2B account, a typical founder-shaped filter (reach above 1,000, a role keyword in the bio, DMs open) tends to match a small single-digit percentage of the raw list, so a 100,000-follower export yields a few thousand qualified profiles. Ranking what remains keeps a human from reading all of them. A weighted priority score built from reach, activity, peer recognition, tenure, and reachability puts the strongest accounts first:
def priority(row) -> int:
pts = 0
pts += min(25, row["followers"] // 400) # reach
pts += min(15, row["tweets"] // 300) # activity
pts += min(20, row["listed"] * 2) # peer recognition
if row["can_dm"]:
pts += 15 # reachable
created = row["created_at"] or ""
if created[:4].isdigit() and int(created[:4]) <= 2022:
pts += 10 # tenure
return min(100, pts)
Tune the weights to the campaign: an enterprise motion should lean on listed and tenure, a creator collaboration should lean on reach. The scoring is deliberately transparent so you can defend why a given account ranks where it does, which matters when the list feeds a sales team rather than a dashboard.
Mapping competitor overlap across accounts
Overlap mapping is the single highest-signal thing you can do with several exports at once, and it collapses into one SQL query the moment you record which account each follower came from. An account that follows two or more of your competitors did so on purpose, and that intersection is a far stronger buying signal than any single follow, because it captures active category evaluation rather than passive interest.
The only schema change required is a source column, so instead of one follower per row you store one edge per follow. A follower who appears in three competitor lists becomes three rows sharing a follower_id:
def open_edges(path="edges.db"):
db = sqlite3.connect(path)
db.execute(
"""
CREATE TABLE IF NOT EXISTS follower_edges (
source TEXT, -- the account whose followers we pulled
follower_id TEXT,
handle TEXT,
followers INTEGER,
can_dm INTEGER,
PRIMARY KEY (source, follower_id)
)
"""
)
return db
With edges stored, the intersection is a group-by on follower_id with a HAVING clause that keeps only accounts seen following more than one source. Ordering by the overlap count surfaces the people evaluating the whole category first:
SELECT follower_id, handle, max(followers) AS reach,
max(can_dm) AS reachable,
count(DISTINCT source) AS follows_n_competitors
FROM follower_edges
GROUP BY follower_id
HAVING follows_n_competitors >= 2
ORDER BY follows_n_competitors DESC, reach DESC;
Reading the result is straightforward: an account near the top follows several of your rivals, has real reach, and is often DM-reachable, which is about as qualified as a cold prospect gets. Pulling the union of all competitor followers gives you a big, noisy list; pulling the intersection gives you a short, high-conviction one. The whole exercise is a few exports feeding one query, which is why storing the source column from the first pull is worth the trivial extra byte per row.
Handing the segment to a CRM without duplicates
Pushing a segment into a CRM cleanly is a dedupe-on-a-stable-key problem, so the rule is to carry the follower id as an external key and upsert against it instead of inserting blindly. The follower id never changes even when the handle does, which makes it the correct idempotency key for the whole hand-off, and using it means a nightly push can run repeatedly without ever creating a second copy of the same person.
The hand-off is four decisions rather than four steps. The first is what to key on: map the follower id to a custom external-id field on the CRM contact and configure the import as an upsert, so a contact that already exists is updated rather than duplicated. The second is when to enrich: turning a handle into a business email through a data provider is the expensive part, so enrich only the segmented, ranked slice, not the raw export, and cache the result against the follower id so you never pay to enrich the same account twice. The third is which fields carry signal: the values a CRM rarely has on an imported contact are the social-graph metrics (reach, listed), the behavioral timestamp (first_seen, which says how recently they followed), and DM reachability, and those are exactly what a lead-scoring model wants as input. The fourth is how it triggers: a webhook or scheduled export that fires after the delta job means new warm followers land in the sequence the same day they follow, closing the loop between signal and outreach.
Keeping the follower id as the join key everywhere, from the local store to the enrichment cache to the CRM external id, means the entire pipeline is idempotent end to end. Records deduplicate themselves at every stage, a suppression flag propagates cleanly, and a re-run is always safe, which is the property that separates a pipeline you can trust from a script you have to babysit.
Reading the shape of an audience
Once a follower list is stored, a handful of aggregate queries turn it from a contact list into an audience profile, which is what you actually need when the question is "is this audience worth engaging" rather than "who is in it." The same sixteen fields that drive filtering also describe the crowd in aggregate: how verified it is, how much reach it carries, how reachable it is, and how much of it is dead weight.
A single query over the stored table answers most of those questions at once:
SELECT
count(*) AS total,
round(avg(followers)) AS avg_reach,
sum(CASE WHEN can_dm = 1 THEN 1 ELSE 0 END) * 100 / count(*) AS dm_open_pct,
sum(CASE WHEN tweets < 10 THEN 1 ELSE 0 END) * 100 / count(*) AS dormant_pct,
sum(CASE WHEN listed > 5 THEN 1 ELSE 0 END) * 100 / count(*) AS recognized_pct
FROM followers;
The numbers read as a health report. A high dormant percentage means a large slice of the audience never posts, which discounts the account's real influence. A high DM-open percentage means the audience is directly reachable, which matters if the follow-up is outbound. A meaningful recognized percentage (accounts other people add to lists) means the audience contains real practitioners rather than passive lurkers. Comparing these numbers across two accounts is a far better basis for a placement decision than raw follower count, because it separates a big-but-hollow audience from a smaller-but-engaged one.
The same shape lets you cohort by account age using createdAt, which is the cleanest single anti-bot signal available: a spike of accounts created in the same recent window is the fingerprint of a bulk follow-back campaign, and it shows up immediately when you bucket the stored records by creation year. Audience analysis, in short, is a reporting query on the store you already built, not a separate integration, which is why persisting the full record instead of just the handle pays off the first time someone asks whether an account is worth the spend.
The Node and TypeScript path
The same export reads cleanly in TypeScript, and an async generator is the idiomatic shape because it lets a caller stream followers with a for await loop instead of buffering the whole graph in memory. Native fetch has been stable in Node since v18, per the Node.js release notes, and query building uses the standard URLSearchParams interface.
const KEY = process.env.TWITTERAPIS_KEY!;
const ROOT = "https://api.twitterapis.com/twitter";
interface FollowerRow {
id: string;
userName: string;
name: string;
followers: number;
canDm: boolean;
createdAt: string;
}
async function* followers(handle: string): AsyncGenerator<FollowerRow> {
let cursor: string | undefined;
do {
const qs = new URLSearchParams({ userName: handle });
if (cursor) qs.set("cursor", cursor);
const res = await fetch(`${ROOT}/user/followers?${qs}`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) throw new Error(`followers ${res.status}`);
const page = await res.json();
for (const row of page.followers as FollowerRow[]) yield row;
cursor = (page.next_cursor as string | null) ?? undefined;
} while (cursor);
}
// stream without buffering: count DM-open accounts as they arrive
let reachable = 0;
for await (const f of followers("supabase")) {
if (f.canDm) reachable += 1;
}
console.log(`reachable followers: ${reachable}`);
The generator yields records lazily, so the memory footprint stays flat no matter how large the account is, and the for await of consumer reads like a plain loop. For the wider TypeScript surface across other endpoints, the Twitter API Node.js tutorial mirrors these patterns.
Pulling several accounts at once
Exporting a handful of accounts in one job is a concurrency problem, and the right amount of concurrency is "a small pool, bounded by a semaphore," not "everything at once." Each account's pages are independent, so a pool that pulls several accounts in parallel finishes in roughly the time of the single largest account rather than the sum of all of them, while a semaphore keeps you from opening an unbounded number of connections.
import asyncio
import httpx
async def pull_one(client, handle):
out, cursor = [], None
while True:
params = {"userName": handle}
if cursor:
params["cursor"] = cursor
resp = await client.get(
f"{ROOT}/user/followers",
params=params,
headers={"Authorization": f"Bearer {KEY}"},
)
resp.raise_for_status()
page = resp.json()
out.extend(page["followers"])
cursor = page.get("next_cursor")
if not cursor:
return out
async def pull_many(handles, concurrency=5):
limits = httpx.Limits(max_connections=concurrency)
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(limits=limits, timeout=60) as client:
async def guarded(h):
async with sem:
return h, await pull_one(client, h)
pairs = await asyncio.gather(*(guarded(h) for h in handles),
return_exceptions=True)
return {h: r for h, r in pairs if not isinstance(r, Exception)}
data = asyncio.run(pull_many(["huggingface", "ollama", "duckdb"]))
for handle, rows in data.items():
print(f"{handle}: {len(rows)} followers")
The return_exceptions=True on gather means one account failing does not abort the batch, and the dictionary comprehension drops the failures so the caller only sees clean results. That is the behavior you want for a nightly job pulling ten competitor accounts, where a single timeout should not lose the other nine.
The cheapest pay-as-you-go Twitter API. Try it free.
$0.04 per 1,000 tweets. $0.50 free credits. No credit card required.
Consent, lawful basis, and using the data responsibly
Public follower data is visible to anyone loading a profile, but "public" is not the same as "unregulated," so the responsible default is to treat an export as personal data subject to the same rules as any other contact list. When the records identify people in the EU or UK, data-protection law such as the GDPR applies to what you do with them, and building that in from the start is cheaper than retrofitting it after a complaint.
Four design constraints keep a follower pipeline on solid ground. First, name a lawful basis before you collect: for B2B prospecting, legitimate interest is commonly the basis, and the UK regulator's lawful-basis guidance sets out how to document it. Second, minimize what you keep: store the fields a campaign actually uses and drop the rest, rather than warehousing every profile image and cover URL indefinitely. Third, honor objections and deletion: a last_seen column plus a suppression table makes it straightforward to stop processing and remove a record on request. Fourth, respect the boundary of the data: a follower export is for relevance and outreach fit, not for sensitive-attribute targeting or any use the person could not reasonably expect.
None of this is legal advice, and jurisdictions differ, so a real deployment should get a proper review. The engineering takeaway is narrow and durable: consent and lawful basis are a schema and a policy decision you make on day one, not a warning label you bolt on after the pipeline is live. Building the suppression table and the minimization step into the first version costs almost nothing; adding them under pressure after the fact costs a lot.
Metering spend against a budget ceiling
Because every call has a fixed, known price, you can meter spend exactly by counting calls, which is more reliable than parsing an invoice after the fact and lets a job stop itself before it overshoots a budget. Counting is trustworthy in a way that estimating is not: the bill for a run is precisely calls * $0.0008, so a small accounting layer around the client turns a vague "this should be cheap" into a hard ceiling the code enforces.
class Budget:
def __init__(self, ceiling_usd, price_per_call=0.0008):
self.ceiling = ceiling_usd
self.price = price_per_call
self.calls = 0
@property
def spent(self) -> float:
return round(self.calls * self.price, 4)
def charge(self):
self.calls += 1
if self.spent > self.ceiling:
raise RuntimeError(
f"budget ceiling ${self.ceiling} hit at {self.calls} calls"
)
def metered_get(client, budget, url, params, headers):
budget.charge()
resp = client.get(url, params=params, headers=headers, timeout=60)
resp.raise_for_status()
return resp.json()
Two habits make metering genuinely useful. The first is a dry-run estimate before any large pull: since an account of known size costs roughly (followers / 50) * $0.0008, you can print the projected spend and refuse to start if it exceeds the ceiling, which catches a fat-fingered target before it runs. The second is per-account accounting: logging budget.spent after each account gives you a running ledger, and emitting it as a metric lets you alert when a scheduled job's cost drifts, which usually means an account grew or a retry loop is spinning. The point is that cost here is a first-class, controllable number rather than a surprise, so treat it like one.
The cost model and how it scales
The cost of a follower export scales linearly with account size, because the price is per call and the page size is fixed, so you can predict the bill for any account from a single multiplication. At $0.0008 per call, about 70 records on the first page and fewer after that, the per-account cost is roughly (followers / 50) * $0.0008, which is why even very large accounts stay inexpensive.
| Follower count | Calls needed | Cost |
|---|---|---|
| 1,000 | 20 | $0.016 |
| 10,000 | 200 | $0.16 |
| 100,000 | 2,000 | $1.60 |
| 1,000,000 | 20,000 | $16.00 |
| 10,000,000 | 200,000 | $160.00 |
Expressed as a rate, that read pricing is about $0.04 per 1,000 tweets and roughly $0.016 per 1,000 follower records, per our pricing page, and the $0.50 signup credit front-loads about 31,000 records before any spend. The contrast with the gated official route is the entire value proposition: the same follower-level data through the enterprise tier starts in the tens of thousands of dollars per month, so the adapter path is orders of magnitude cheaper across every account size. For the full per-endpoint breakdown see the pricing page, and for how the per-call rate stacks up against the market, the Twitter API cost explainer and the cheapest Twitter API provider ranking both put $0.0008 in context.
Enrichment is the one cost that dwarfs the API spend, and it is worth sizing honestly. Turning a few thousand qualified handles into business emails through a data provider runs a few cents to about half a dollar per contact, so 10,000 enriched records is a few hundred dollars in enrichment against a couple of dollars in API calls. The follower export is not competing with the enrichment tool; it is feeding it a far better-targeted input than a generic purchased list, which is where the real savings show up.
Circleboom, a tool in this space, markets the same core promise of turning a follower list into a downloadable export, which is a useful reminder that the demand for this data is broad and well established:
https://x.com/circleboom/status/1889109601104105596
Migrating off the official X v2 followers endpoint
Moving an existing v2 integration over is a mechanical three-change edit, not a rewrite, because the request and response shapes line up closely once you account for the naming differences. The three changes are the base URL, the identifier parameter, and the pagination token.
# BEFORE: official X API v2, follower profiles gated to the enterprise tier
old_url = f"https://api.x.com/2/users/{user_id}/followers"
# numeric id in the path, paginates via "next_token", ~100 records/page
# AFTER: TwitterAPIs, $0.0008 per page, bearer token only
new_url = "https://api.twitterapis.com/twitter/user/followers"
new_params = {"userName": handle} # handle in a query param, not an id in the path
# ~70 records on page one, fewer after it, paginates via "next_cursor"
The field mapping is direct: username becomes userName, public_metrics.followers_count becomes followers, public_metrics.tweet_count becomes tweets, and the pagination token renames from next_token to next_cursor. Page size lands near 70 on the first page and drops on the pages after it, and several fields the v2 route hides behind explicit user.fields expansions (including DM eligibility, which no official tier exposes) arrive by default. A codebase already calling the v2 followers route usually finishes the swap in well under an hour. For the full side-by-side, the Twitter API v2 vs TwitterAPIs breakdown covers the field-level differences, teams coming off another adapter can follow the migrate from twitterapi.io guide, and anyone weighing a marketplace listing against a direct endpoint should read the RapidAPI Twitter alternative comparison. The client-library churn on the official side is a recurring tax, and the official X developer samples show how often wrapper updates ship alongside tier restructuring, which a maintained adapter absorbs on your behalf.
Followers versus following: both sides of the graph
The follower endpoint has a mirror, GET /twitter/user/following, that returns the accounts a user follows, and pulling both sides of a target's graph answers two different questions with the same code. The follower list tells you who is interested in an account; the following list tells you who that account pays attention to. Both return the same sixteen-field record, paginate with the same cursor, and cost the same $0.0008 per call, so anything you built for followers works unchanged for following by swapping one path segment.
The two directions serve distinct jobs. A follower export builds outreach lists, because the people following a target have self-selected into interest and are the natural audience for a competitive or category play. A following export builds an influence map, because who a founder or a company account chooses to follow reveals their advisors, their suppliers, the tools they use, and the communities they participate in. For competitive intelligence, the following list of a rival's leadership is often more revealing than their follower list, since it exposes the accounts shaping their thinking rather than the crowd watching them.
The two-hop version is where this gets powerful. Pull a target's following list to find the accounts they trust, then pull the followers of those trusted accounts to find a warm audience that shares the target's influences. That composed query, trust map plus audience of the trusted, is a few sequential exports stored in the same edge table, and it produces a prospect list that is qualified by proximity to the target rather than by a keyword guess. Because the endpoints are symmetric, the checkpointed exporter, the store, and the segmentation all carry over without a rewrite; only the path changes.
Snapshot semantics and data freshness
A follower export is a snapshot taken at the moment you page the account, not a live subscription, so every record carries an implicit age and any pipeline acting on the data needs an explicit freshness policy. Between the instant you start a large pull and the instant it finishes, some accounts will follow and some will unfollow, which is normal and fine for most uses as long as you stop pretending the file is a live view.
The last_seen timestamp the store already writes is your freshness clock, and a simple threshold turns it into a policy. For warm outreach, skipping records whose most recent sighting is older than about a week keeps you from messaging people whose interest signal has gone cold, and sorting the segment by first_seen so the most recent followers surface first consistently beats working a stale list from the oldest end. For a genuinely point-in-time question, such as whether one specific person still follows a target before you take an action, do not trust the bulk snapshot at all: re-query that single account, because a per-record read is current where a two-day-old export is not.
The operational consequence is that very large accounts should be treated as continuously maintained datasets rather than one-shot jobs. Cursor stability is what makes that practical: you can pause a million-follower pull, persist the cursor, and resume it in the next window without gaps or duplicates, then keep the store fresh with the incremental delta job rather than re-paging the whole graph. Freshness, in other words, is a property you design for with timestamps and a resumable cursor, not something you hope the data still has.
A complete pipeline, wired together
Everything above composes into one small orchestration function, and seeing the pieces wired together is the fastest way to appreciate how little code a production follower pipeline actually is. The function opens the keyed store, meters its own spend, runs the checkpointed export while upserting each page, reports the delta, and returns the top reachable prospects, all from the building blocks already defined.
from datetime import datetime, timezone
def sync_account(handle, ceiling_usd=1.0, db_path="followers.db"):
db = open_store(db_path)
budget = Budget(ceiling_usd)
stamp = datetime.now(timezone.utc).isoformat()
job = FollowerExport(handle, checkpoint=f"{handle}.cursor")
with httpx.Client() as client:
def store_page(rows):
budget.charge() # one page is one call
upsert_page(db, rows, stamp)
job.run(client, on_page=store_page)
new_today = db.execute(
"SELECT count(*) FROM followers WHERE first_seen = ?", (stamp,)
).fetchone()[0]
print(f"{handle}: {new_today} new followers, spent ${budget.spent}")
return db.execute(
"""
SELECT handle, name, followers FROM followers
WHERE followers >= 1000 AND can_dm = 1
ORDER BY followers DESC LIMIT 25
"""
).fetchall()
for target in ["huggingface", "ollama", "duckdb"]:
sync_account(target)
Every run is idempotent because the store upserts on id, metered because the budget counts pages, and resumable because the exporter checkpoints its cursor. Extending it is additive rather than structural: add the source column and the edge table for overlap mapping, bolt on the enrichment and CRM push keyed by follower id, or drop the whole thing behind the daily scheduler. The endpoint stays a plain GET the entire way up, which is exactly why follower export scales from a fifteen-minute script to a standing pipeline without a rewrite.
A troubleshooting checklist for follower exports
Most production follower-export incidents fall into a short list of known failure modes, and recognizing them by symptom saves hours of guessing. Keep this list next to the pipeline.
- An empty result on a real account. The account is protected, so its follower list is hidden at the platform layer. There is no workaround; public-graph coverage only.
- Records that point to now-missing accounts. The export is a snapshot from follow time, and some accounts get suspended afterward. Filter on a presence heuristic such as
tweets > 0before acting on stale records. - Sudden 429s during a large pull. You stacked too many parallel requests on one account. Drop the pool size, confirm your backoff-with-jitter is actually firing, and spread concurrency across accounts instead of one target.
- A resumed job re-reading old pages. Your checkpoint is not being written after each page, or you are reusing a stale cursor file. Persist
next_cursoron every page and delete the checkpoint only on clean completion. - Duplicate rows piling up across runs. You are appending to flat files instead of upserting on
id. Move to a keyed store so re-pulls dedupe automatically. - A follower who unfollowed still in your list. Follower data is a snapshot, not a live subscription. Tag records with
last_seenand treat anything missing from the latest run as a departure.
For pipelines that lean on a proxy-based scraping stack rather than the API, the same symptoms show up with different root causes, and the best Twitter API for scraping piece explains why the failure surface shrinks once the browser layer is gone.
Where to take it from here
The fastest path to a working export is short: create a key, run the resumable Python class against one public account you care about, and point the pages at a SQLite store so your second run becomes a delta instead of a duplicate pull. From signup to a first clean export is about fifteen minutes, and the $0.50 starter credit covers enough calls to page several mid-sized accounts before you decide it is worth funding.
From there the build compounds naturally. Add the daily delta job for a warm-follower feed, layer the segmentation and priority score to rank what the delta surfaces, wire the consent and suppression tables in before the list touches any outreach tool, and only then scale concurrency across multiple target accounts. Every one of those steps is a query or a small function on top of the same endpoint, which is the whole reason to treat follower export as a pipeline rather than a one-off script. Start with one account and the checkpointed exporter above, and grow the rest as the data proves its worth.
// sources
Where these numbers come from
Each row is a figure in this post and the artefact it was read from. Prices and limits on this platform move, so check the date on the source before you plan against it.
- X API reference for GET users followers
- Backs the constraint that ruled out the official route: the documented limits allow only a small number of follower IDs per call, and the accessible tier returns identifiers rather than full profiles by default.
- httpx documentation
- The HTTP client the production exporter class in the Python section is built on.
- Python sqlite3 module documentation
- Backs the storage recommendation: a single-file keyed store with a real PRIMARY KEY, which is what makes follower upserts idempotent and incremental sync possible.
- Node.js v18 release announcement
- The source for the claim that native fetch has been stable in Node since v18, which the TypeScript async-generator exporter depends on.
- GDPR Article 6, lawfulness of processing
- Backs the consent section: an export that identifies people in the EU or UK is personal data subject to data-protection law, so a lawful basis has to be named before collection.
- UK Information Commissioner guidance on lawful basis
- The regulator guidance the post points to for documenting legitimate interest, the basis it names as common for B2B prospecting.
Frequently Asked Questions
It means calling a REST endpoint that accepts a public account handle and returns that account's follower list as structured JSON, page by page, so you can persist and query the audience yourself. TwitterAPIs exposes this at /twitter/user/followers, which hands back about 70 follower records on the first page and fewer on the pages after it for $0.0008, walked with an opaque cursor until the account is fully paged. The result is a local dataset you own rather than a screen you scroll, which is the difference between a one-time glance and a syncable pipeline.
No. The endpoint authenticates with a single bearer token sent in the Authorization header. There is no developer-app registration, no client ID and secret pair, and no OAuth callback to host. You create an account, copy the key, set one header, and start paging. That removes the multi-day approval loop and the token-refresh plumbing that the official X app flow requires before the first byte of data arrives.
Each record carries sixteen fields: id, userName, name, url, isVerified, isBlueVerified, profilePicture, coverPicture, description, location, followers, following, tweets, listed, createdAt, and canDm. That is enough to segment an audience by reach, activity, tenure, verification, and whether direct messages are open, all without a second enrichment call. The canDm flag in particular is not exposed by the official API and is what makes the raw export directly actionable for outreach.
Store each record in a keyed table with first_seen and last_seen timestamps, then run a scheduled pull that upserts on the follower id. New ids that appear are new follows, ids that stop appearing are unfollows, and the first_seen column gives you a clean daily delta of who just joined. For a 100,000-follower account a daily refresh is about 2,000 calls, roughly $1.60, which is far cheaper than re-processing the whole list from scratch every run.
Follower pages are independent, so a few concurrent workers move quickly, but stacking many parallel requests against a single account invites throttling. A practical setup is a small connection pool (around five workers) with exponential backoff and jitter on any 429, which keeps a million-follower export to roughly the wall-clock time of a single sequential pass sped up by the pool. Spreading concurrency across several target accounts is safer than piling it onto one.
Each call is $0.0008 and returns about 70 followers on the first page and fewer after that, so a 1,000-follower account is roughly 20 calls at about $0.016, a 10,000-follower account is roughly 200 calls at about $0.16, a 100,000-follower account is roughly 2,000 calls at about $1.60, and a 1,000,000-follower account is roughly 20,000 calls at about $16.00. Put differently, that works out to about $0.016 per 1,000 follower records. New accounts start with $0.50 in free credit, which covers roughly 625 calls or about 31,000 follower records before any card is added.
Every response carries a next_cursor string. You send the first request with only the handle, read the records it returns, and if next_cursor comes back non-empty you send the next request with cursor set to next_cursor. The cursor is opaque and position-stable, so follows that arrive between two calls do not reshuffle records you have already read. Because the cursor is just a string, you can persist it to disk, stop, and resume a large export exactly where it left off.
No. The endpoint only returns follower lists for public accounts, because a protected account hides its followers at the platform layer and no third party can surface data the platform itself withholds from a logged-out request. If an account was public and recently switched to protected, any records you hold are a snapshot from when it was still visible, not a live read. The correct expectation is public-graph coverage only.
Public follower data is visible to anyone loading the profile, but downstream use still sits under data-protection law such as the GDPR when the records identify people in the EU or UK. In practice that means naming a lawful basis (often legitimate interest for B2B outreach), minimizing what you keep, honoring objections and deletion requests, and never using the export for prohibited targeting. This is engineering guidance, not legal advice; treat consent and lawful basis as a real design constraint, not an afterthought.
Swap the base URL to api.twitterapis.com/twitter/user/followers, change the numeric id path parameter to a userName query parameter, and rename the pagination token from next_token to next_cursor. Field names map with light renaming, page size lands near 70 records on the first page and fewer after it, and the auth model drops from OAuth to a static bearer token. Most codebases already calling the v2 followers route finish the swap in under an hour.
Check out similar blogs
More guides on the Twitter/X API, scraping, and pricing.







