ofapisv1
guide

Updated

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:

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.

Related guides

All guides
Start free — 250 credits, no card
Generate a token and make your first call in minutes.
Get started