How to Get Your OnlyFans Subscribers List via API
Pulling a subscriber list is two jobs, not one. The first is a backfill: walk the whole roster once and land it in your own store. The second is a delta: keep that store current without paying to re-read it. Most integrations only build the first, run it nightly, and wonder why their credit balance drains.
This guide builds both, in order. It assumes you have a linked account — if not, start with how to authenticate.
Step 1 — Read one page and look at what comes back
curl "https://api.ofapis.com/api/public/v1/accounts/42/subscribers?type=active&limit=100&offset=0" \
-H "Authorization: Bearer $OFAPIS_TOKEN"
{
"data": {
"list": [
{
"id": 98765,
"username": "fan_handle",
"name": "Fan Name",
"avatar": "https://cdn3.onlyfans.com/...",
"subscribedOn": true
}
],
"hasMore": true,
"nextOffset": 20
}
}
Five fields per fan. That is the whole record, and the gap in it is what to plan around: no spend, no lifetime value, no renewal date, no join date. subscribedOn is a boolean flag, not a timestamp — it says the relationship exists, not when it started. So you cannot sort by spend, filter by signup week, or build a cohort from this endpoint alone. Money has to be derived elsewhere; see earnings for where it comes from.
What you do get is the id — the fan's numeric OnlyFans user id. Every other route keys off it, including a single-fan lookup and the one you will send messages to.
Step 2 — Pick the right type
| Value | Population | Use it for |
|---|---|---|
active |
currently subscribed (default) | the live roster |
expired |
lapsed subscriptions | win-back campaigns |
all |
both | a complete history backfill |
One sharp edge: anything that is not one of those three is silently rewritten to active. Send type=cancelled or type=ACTIVE_ONLY and you get a 200 with the wrong population in it, never a 400. Pin the value to a constant in your code and never build it from user input.
Step 3 — Back-fill with a resumable loop
limit defaults to 20 and is clamped server-side to 1–100 — out-of-range values are clamped, not rejected, so limit=500 quietly becomes limit=100. Always ask for 100: a page costs one credit whether it holds 20 rows or 100, so a 40,000-fan roster is 400 credits at the ceiling and 2,000 at the default.
Advance offset yourself by the page size. Do not feed nextOffset back into the loop — for the common upstream payload shape it comes back as 0, and a loop that trusts it re-reads page one until the credits run out.
const BASE = "https://api.ofapis.com/api/public/v1";
const HEAD = { Authorization: `Bearer ${process.env.OFAPIS_TOKEN}` };
const LIMIT = 100;
// `checkpoint` is a row in your own DB: { offset, seen: Set<number> }
async function backfill(accountId, type, checkpoint, save) {
let { offset } = checkpoint;
for (;;) {
const url = `${BASE}/accounts/${accountId}/subscribers` +
`?type=${type}&limit=${LIMIT}&offset=${offset}`;
const res = await fetch(url, { headers: HEAD });
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? 5);
await new Promise(r => setTimeout(r, wait * 1000));
continue; // same offset, nothing consumed
}
if (res.status === 424) throw new Error("session expired — re-link account");
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const { list, hasMore } = (await res.json()).data;
await save(list, offset + LIMIT); // upsert rows AND the new offset, one txn
if (!hasMore || list.length === 0) return;
offset += LIMIT;
}
}
Three things that code does on purpose:
- Persists the offset with the rows, in the same transaction. A sweep of a large roster takes minutes and will eventually be interrupted. Restarting from a checkpoint costs nothing; restarting from zero costs the whole sweep again.
- Retries a 429 at the same offset. A rate-limited request never reached the handler, so no page was consumed and no credit was spent.
Retry-Afteron the 429 tells you exactly how long to wait, andX-RateLimit-Reseton every response gives you the window boundary in unix seconds. - Upserts by
idrather than appending. Offset paging over a list that people are joining and leaving will show you the same fan twice and skip another entirely. Deduplicating by primary key makes that harmless. More on both in rate limits.
Stop on hasMore === false, or on an empty list — the belt-and-braces check that ends the loop even if the upstream flag misbehaves. If you want this wired into a real client rather than a snippet, the Node.js quickstart builds one with timeouts, and the Python subscriber sync writes the same offset-paged walk as a nightly job.
Step 4 — Stop re-reading the roster
Once the store is populated, a nightly full sweep is almost always the wrong tool. It costs one credit per page every single night to discover a handful of changes, and it eats your per-minute budget while it runs.
The cheap alternative is the notification feed:
curl "https://api.ofapis.com/api/public/v1/accounts/42/notifications?type=subscribed&limit=50" \
-H "Authorization: Bearer $OFAPIS_TOKEN"
One credit returns the most recent subscription events, each with a createdAt. Poll it every few minutes, stop at the newest createdAt you already stored, and fetch the full record only for ids you have never seen. On a roster of any size that is a fraction of the cost of a sweep, and it is nearer to real time.
Know its limits. /notifications takes type and limit only — no offset, no cursor — so if you poll less often than your audience churns, events fall off the end of the page. And there is no subscription webhook to lean on: the only event ofapis pushes is message.received, covered on webhook events explained.
The production shape: full sweep weekly as reconciliation, type=subscribed polling continuously, and a type=expired sweep on demand when you want a win-back pool.
Step 5 — Budget it honestly
| Roster | Pages at limit=100 |
Credits per full sweep |
|---|---|---|
| 2,000 | 20 | 20 |
| 20,000 | 200 | 200 |
| 100,000 | 1,000 | 1,000 |
Against Starter's 30,000 monthly credits, a 20,000-fan roster swept nightly is 6,000 credits a month — survivable, but it is 20% of your budget spent learning almost nothing. The same roster swept weekly plus five-minute delta polling is well under half that and far fresher. Plan sizes are on pricing.
Failures are not uniformly free. A 429 or 403 is refused before metering and costs nothing; a 424 or 5xx is debited and refunded; a 404 from a wrong accountId is charged and not refunded. A misconfigured sync job is an expensive sync job.
Next: act on the list
A stored roster is inert until you do something with it. The ids you just persisted are exactly the chatId values the messaging route takes, so the natural next step is sending a message — or, once you want to address a cohort rather than a person, push those ids into an OnlyFans list and use fan segments.
FAQ
Can I sort subscribers by how much they have spent?
No. A subscriber record contains id, username, name, avatar and subscribedOn and nothing financial, so there is no spend field to sort on and no server-side sort parameter. Spend has to be assembled from your own records of what you sent and what unlocked.
Why does my loop keep returning the same first page?
Almost certainly because it is reading nextOffset from the response. That field is 0 for the usual payload shape, so the loop never moves. Track the offset in your own code and increment it by limit after each page.
How long is a full sweep allowed to take?
As long as your rate limit dictates — 60 requests per minute on Free, 100 on Starter, 500 on Pro. At limit=100 and the Starter ceiling that is 10,000 fans a minute, so even a six-figure roster finishes in minutes. Honour Retry-After rather than pacing with a fixed sleep.
Does the list include fans who cancelled?
Only if you ask. The default is type=active. Pass type=expired for lapsed subscriptions alone, or type=all for both in one pass, which is the right choice for a first backfill.
What should my job do on a 424?
Stop that account's sync and raise an alert — the stored OnlyFans session is dead and retrying cannot revive it. The call is refunded, and the subscribers API reference and API errors cover the rest of the codes you may see.