Pagination
Three conventions coexist. /chats ignores limit, and a short page is not the end.
Three pagination conventions coexist in this API, inherited from the two platforms underneath. Getting these wrong silently loses data — usually most of it — so this page is worth reading in full before you write a walker.
1. Offset + limit, with a total
The /cached routes and other server-side reads.
GET /accounts/{of_user_id}/subscribers/cached?limit=100&offset=0{ "success": true, "list": [ … ], "count": 100, "total": 412,
"limit": 100, "offset": 0, "hasMore": true }Walk until hasMore is false. This is the only convention where total is
trustworthy.
The array key is `list`, not the resource name
The high-traffic paginated reads return their rows under a generic list, not
under a key named after the resource. Getting this wrong yields zero rows silently.
| Route | Array key |
|---|---|
/subscribers/cached, /subscribers (live) | list |
/transactions/cached, /fans/{id}/transactions/cached | list |
/claimers/cached | list |
/subscribers/new | subscribers |
/fans, /campaigns, /claimers, /purchases, /chats, /messages | named after the resource |
Per-route limits: /subscribers/cached and /fans max 500; /transactions/cached,
/fans/{id}/transactions/cached and /claimers/cached max 1000;
/subscribers/new defaults to 50; /notifications defaults to 20, max 100;
/events defaults to 100, max 500. Exceeding a max is a 400, not a clamp.
2. Offset + limit, no total
Live reads that proxy a platform which does not report a total — /campaigns,
/claimers, /purchases, live /subscribers.
{ "success": true, "campaigns": [ … ], "hasMore": true }There is no total — and these routes do not echo limit or offset back either.
Walk on hasMore alone, tracking the offset yourself.
Follow a server-supplied cursor when there is one
GET /accounts/{of_user_id}/subscribers returns nextOffset. Always pass that
back. Do not compute offset + len(list) there: on Fansly we request an
unfiltered page and filter it server-side, so the stream consumed more rows than you
received — advancing by len(list) silently skips every filtered-out subscriber.
Only on routes that emit no cursor (/chats, /campaigns, /claimers) do you
advance yourself, and there you must use the page length, because a short page does
not mean the end:
offset += len(page) # NOT offset += limit3. Cursor (marker / id)
The OnlyFans transactions walker uses an opaque marker:
GET /accounts/{of_user_id}/purchases?limit=100&marker=<from previous response>{ "success": true, "purchases": [ … ], "marker": "eyJ…", "hasMore": true }The response carries both marker (the one you sent) and nextMarker — pass
nextMarker back on the next request. Do not construct or parse it.
Message history uses an id cursor — but only on the passthrough:
GET /api/crm/{crm_id}/api2/v2/chats/{fan_id}/messages?id=<oldest id seen>The CRM /messages route is offset-paged and cannot take a cursor
GET /accounts/{of_user_id}/chats/{with_user_id}/messages accepts only limit
(max 100, default 50) and offset; an id parameter is silently dropped. Offset
paging over this platform endpoint is lossy — it both skips and repeats messages.
Use the CRM route to read a recent window. To walk a full history, go through
the passthrough with the id= cursor, dedupe on message id, and stop when a page
yields nothing new.
The two traps
These are the specific behaviours that break naive walkers.
GET /accounts/{of_user_id}/chats accepts a limit parameter and then largely
disregards it — a request for 50 typically returns 10 to 14 conversations.
Do not treat "fewer than limit returned" as the end of the list, and do not
size your loop by dividing a total by limit. Walk on hasMore, advancing
offset by the number of items you received.
GET /accounts/{of_user_id}/chats/{with_user_id}/messages needs the id=
cursor, and it will regularly return a short page — sometimes a nearly empty one
— in the middle of a conversation that has thousands more messages.
Terminate on the platform's own hasMore signal, or on receiving a genuinely
empty page, and never on len(page) < limit. A walker that stops at the first
short page can lose the overwhelming majority of a message history.
hasMore is camelCase
Note the casing. Bodies are otherwise snake_case (of_user_id, created_at,
api_calls_used), but hasMore is camelCase because it comes straight from
OnlyFans. total, limit and offset are lowercase.
A correct walker
import requests
def walk(session, url, key, params=None, item_key="items", page_size=100):
"""Walk an offset-paginated CRM route without losing short pages."""
offset = 0
while True:
r = session.get(
url,
headers={"X-API-Key": key},
params={**(params or {}), "limit": page_size, "offset": offset},
timeout=60,
)
r.raise_for_status()
body = r.json()
page = body.get(item_key) or []
yield from page
# Advance by what we actually received — these offsets are per item.
offset += len(page)
# Trust the platform's own signal, not the page length.
if not body.get("hasMore"):
return
if not page:
return # defensive: hasMore true but nothing coming backFor cursor routes, carry the marker (or id) forward instead of an offset, and
stop when hasMore is false.
Routes that take only limit
GET /events (default 100, max 500) and
GET /accounts/{of_user_id}/notifications (default 20, max 100) accept
limit with no offset. /events is filtered by types,
of_user_id, since and until instead — see Events.