The Only API docs

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.

RouteArray key
/subscribers/cached, /subscribers (live)list
/transactions/cached, /fans/{id}/transactions/cachedlist
/claimers/cachedlist
/subscribers/newsubscribers
/fans, /campaigns, /claimers, /purchases, /chats, /messagesnamed 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 += limit

3. 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.

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 back

For 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.

On this page