OnlyFans Subscribers API
The audience read. One paginated endpoint returns who is subscribed to a creator — active, expired, or both — as JSON you can diff, score and store. This is the source every CRM, cohort and churn number on top of it is derived from.
Endpoints
GET /api/public/v1/subscribers
GET /api/public/v1/following
GET /api/public/v1/accounts/{accountId}/subscribers
GET /api/public/v1/accounts/{accountId}/following
/following is the mirror image — accounts the creator subscribes to — and returns the identical page envelope. The unscoped forms resolve to the first ACTIVE linked account; name an accountId explicitly as soon as a second creator exists on the token.
Parameters
| Param | Type | Default | Accepted | Notes |
|---|---|---|---|---|
type |
string | active |
active, expired, all |
/subscribers only |
limit |
integer | 20 |
clamped to 1–100 | out-of-range values are clamped, not rejected |
offset |
integer | 0 |
clamped to ≥ 0 | zero-based |
The type filter has a sharp edge: an unrecognised value is normalised to active, silently. Send type=cancelled and you get a 200 with your active subscribers in it, not a 400. Validate the string on your side before it reaches the wire; expired and all are the only alternatives that mean anything.
One page
curl "https://api.ofapis.com/api/public/v1/accounts/42/subscribers?type=active&limit=100&offset=0" \
-H "Authorization: Bearer ofapis_sk_..."
{
"data": {
"list": [
{
"id": 98765,
"username": "fan_handle",
"name": "Fan Name",
"displayName": "Fan Name",
"avatar": "https://cdn3.onlyfans.com/...",
"isVerified": false,
"isActive": true,
"isBlocked": false,
"isRestricted": false,
"isPerformer": false,
"canTrialSend": true,
"lastSeen": "2025-06-01T09:00:00+00:00",
"subscribedOn": true
}
],
"hasMore": true,
"nextOffset": 20
}
}
| Field | Type | Notes |
|---|---|---|
id |
integer | OnlyFans user id — the key for every other route |
username, name, displayName |
string | handle and display name |
avatar, avatarThumbs |
string, object | CDN URLs |
isActive |
boolean | account not deactivated |
isBlocked, isRestricted |
boolean | relationship state |
isVerified, isPerformer |
boolean | verified badge; is themselves a creator |
canTrialSend |
boolean | eligible for a trial link |
lastSeen |
string | ISO-8601 recency signal |
subscribedOn, subscribedOnExpiredNow |
boolean | subscription state flags |
A list row is an identity plus state flags. What it does not carry is the subscription's lifecycle: subscribedByExpireDate, currentSubscribePrice, subscribePrice, joinDate and about live only on the single-user lookup. So the audience list answers who, and GET /users/{targetUserId} from the profile and user API answers until when and at what price. Enriching all 50,000 fans that way costs 50,000 credits, so enrich the ones you act on, not the ones you store; the fans API page works through which cheaper read already carries the field you were about to pay for.
Paging a large audience
Drive the loop from your own counter and hasMore. nextOffset is a best-effort hint: OnlyFans does not return one, so for the common object-shaped payload it comes back as 0, and only the bare-array variant fills it in. Trusting it will park you on page one forever.
import os, time, requests
BASE = "https://api.ofapis.com/api/public/v1"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['OFAPIS_TOKEN']}"
def subscribers(account_id, type_="active", limit=100):
offset = 0
while True:
r = S.get(f"{BASE}/accounts/{account_id}/subscribers",
params={"type": type_, "limit": limit, "offset": offset}, timeout=30)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 5)))
continue
r.raise_for_status()
body = r.json()["data"]
yield from body["list"]
if not body["hasMore"] or not body["list"]:
return
offset += limit # not body["nextOffset"]
# Nightly delta against yesterday's snapshot.
today = {f["id"]: f for f in subscribers(42)}
joined = today.keys() - yesterday.keys()
lapsed = yesterday.keys() - today.keys()
limit=100 is the ceiling and always the right choice for a full sweep: it is the same one credit per page as limit=20, so a 50,000-fan audience is 500 credits instead of 2,500. Keep the pass well inside your per-token minute budget — 60 requests on Free, 100 on Starter, 500 on Pro — and see rate limits for how the window resets.
Churn without a statistics endpoint
There is no server-side analytics resource. The joined / lapsed sets above are the churn number: snapshot nightly, diff, store the deltas. The joined side doubles as the trigger for a welcome message, which is the cheapest thing you can hang off this endpoint. Running type=expired on the same schedule gives you the win-back pool directly. That pattern, plus the notification feed, is how the statistics API page assembles metrics from read endpoints that actually exist — and the same deltas are what tracking-link attribution measures a channel against.
Once a cohort is scored, push the ids into an OnlyFans list and address it in one send: see fan segments.
The following list
GET /following takes limit and offset only — no type — and returns the same {list, hasMore, nextOffset} envelope with the same row shape. It is the accounts the creator subscribes to, which for a managed roster is mostly collaborators and alt accounts rather than fans. Diff it the same way if you track cross-promotion, and ignore it entirely if you do not: it is a different population from /subscribers and mixing the two inflates every audience number you derive.
Cost and failure modes
One credit per successful page, debited before the handler runs, refunded automatically on 5xx, 424 and 409. A 401, 402, 403 or 429 is rejected by the gateway before any debit. Only the gateway refusals (401, 402, 403, 429 and an idempotency 409) come back as {"error":{"code","message"}}; everything the handler raises is a bare text body, so branch on the HTTP status. Here that means 422 when the account is not linked or its stored authorisation is invalid (re-link it; the credit is charged and not refunded), 404 for an id that is not on this token, and 424 when the creator's OnlyFans session has expired (refunded, safe to retry). Full table on API errors, and there is no scraper to babysit behind any of it — see API without scraping.
FAQ
How do I export every subscriber, including lapsed ones?
Call /subscribers with type=all and page with limit=100, incrementing offset by 100 until hasMore is false. The step-by-step guide walks the same loop with error handling attached.
Why is nextOffset always zero?
Because OnlyFans does not return a next-offset value for the paginated shape, and the field defaults to 0 when absent. It is only populated when the upstream payload arrives as a bare array. Track the offset yourself and use hasMore as the stop condition.
What happens if I pass an invalid type value?
It is coerced to active and the request succeeds, so a typo looks like a correct response with the wrong population in it. Only active, expired and all are distinct; anything else is treated as active.
How do I find out when a subscription expires?
Fetch that fan individually with GET /users/{targetUserId}, which returns subscribedByExpireDate along with currentSubscribePrice and joinDate. Those fields are absent from the list rows, so plan on one extra call per fan you need dated.
Does paging the whole audience count against my rate limit?
Yes — every page is one request against the per-minute ceiling and one credit against the balance. Schedule the full sweep (nightly is usually enough) and keep real-time work on webhooks instead of re-reading the audience; pricing has the per-plan numbers.
Start building: get a token free · notifications API · API reference