OnlyFans API Pagination
There is no single paging convention in this API. Three models are in use, one feed has no second page at all, one read pages itself server-side, and no response carries a total. None of that raises when you get it wrong — you get a 200 with the wrong rows in it, or a billed call that returns half a month.
This page is the paging reference: which model each route uses, its real stop condition, and what a walk costs. Row shapes stay on the endpoint pages, which the guide index lists.
Which model a route uses
| Route | Paging input | Page boundary comes from | Cannot do |
|---|---|---|---|
/subscribers, /following, /chats, /chats/users, /mentions |
limit (20), offset (0) |
page length, hasMore, nextOffset |
— |
/accounts/{id}/lists, /accounts/{id}/lists/{listId}/users |
limit (10), offset (0) |
page length, hasMore |
— |
/chats/{chatId}/messages |
limit (20), olderThanMessageId |
hasMore, nextMessageId |
jump to page five |
/accounts/{id}/scheduled/posts |
limit (20), required publishDate + publishDateEnd |
nothing | page — shard the range |
/notifications |
limit (20) |
nothing | read past the newest 100 |
/vault/media |
none | n/a, the server walks it | ask for part of it |
limit is clamped to 1–100 wherever it is accepted, by coercion, not rejection: limit=5000 becomes 100 and limit=0 becomes 1. The second bites a loop sizing pages as min(rows_remaining, 100) whose counter reaches zero — it succeeds, returns one row, and never terminates. offset is not clamped at all, just forwarded upstream verbatim.
Model one: offset, and a stop condition that holds
The envelope is {"data": {"list": [...], "hasMore": bool, "nextOffset": int}}, and the two hint fields are the trap. Upstream returns either an object page or a bare array, and both are normalised into that shape. On an object page, hasMore is upstream's value and nextOffset is absent, defaulting to 0. On a bare array, nextOffset is computed as offset + len(list) and hasMore is forced to false.
So nextOffset is populated only where hasMore already says stop, and hasMore means something only where nextOffset is 0. They are never both useful in one response, so a loop driven off either alone parks on page one forever or quits after it. The signal that holds in both shapes is page length:
offset += limit
stop when len(list) < limit # a short page is the last page
stop when len(list) == 0
Keep hasMore == false as an early exit on top of that, never as the only one. Row contents are on the subscribers API page.
Model two: the two routes that default to 10
/accounts/{id}/lists and /accounts/{id}/lists/{listId}/users default limit to 10; everything else defaults to 20. The clamp is the same 1–100, so the fix is passing the parameter — but omitting it costs 2.5× the calls you budgeted, each one a credit. A 2,000-member list swept at the default is 200 requests; at limit=100 it is 20. Rely on no default anywhere: send limit explicitly on every list read. Route details are on the lists API page.
Model three: the message-history cursor
GET /chats/{chatId}/messages takes no offset. It pages backwards with olderThanMessageId, and the envelope returns nextMessageId — the id of the oldest message in the page you just got — to feed back on the next call.
Message lists are newest-first and a conversation grows at the newest end, so an offset into one is a moving target. Walk it at limit=100 and suppose three messages arrive while you process page one:
Page 1 (offset=0) |
Page 2 (offset=100) |
|
|---|---|---|
| Asked for | the newest 100 | rows 101–200 |
| Actually got | rows 1–100 as the list then was | the old 98th through 197th |
| Net effect | — | three rows delivered twice |
Three deletions instead of three arrivals shifts the list the other way and you skip three messages, with nothing in the response to show it. The cursor removes the class: it anchors on an immutable id, so anything arriving above the anchor is outside the window and the next page starts where the last ended. The cost is that it is strictly sequential, which bounds a deep backfill by round-trip latency rather than your rate ceiling. The loop and the message fields are on the messaging API page.
The feed with no second page
GET /notifications accepts type and limit and nothing else: no offset, no cursor, no before timestamp. hasMore comes back true on any busy account with no parameter that acts on it. The deepest read is the newest 100 events, and history from before your first call is unreachable.
So the poll interval is a correctness setting, not a cost one: anything landing beyond the newest 100 between two polls is gone for good. Size it against the account's peak event rate, not its average — at 20 events a minute, 100 events is five minutes, so a five-minute poll is already at the edge. The feed and its counter are on the notifications API page.
There is no total
No list response carries a total, a count or a page count. That rules out rendering "page 3 of 12", and sizing a job before running it. Instead:
- Report work done, not a percentage. "4,300 fans so far, page 43" is honest; a progress bar needs a denominator you do not have.
- Pre-size from the one count that exists. A list object carries
usersCount, making a list sweep the only walk you can budget exactly:ceil(usersCount / 100)further reads. - Size a date range with the counters route.
GET /accounts/{id}/scheduled/posts/countersreturns per-day counts for a window, so one credit tells you how to cut it. - Do not reach for
newCount./notifications/countis an unread level, not a collection size, and the creator opening the app changes it.
Date ranges instead of pages
GET /accounts/{id}/scheduled/posts requires both publishDate and publishDateEnd, takes limit, and has no offset. A busy month cannot be walked, so it has to be cut: query week by week, or day by day for a heavy account, one credit per shard. With no total and no offset, a page of exactly limit rows is the only truncation signal — halve the window and read again. Payloads and date formats are on the scheduling API page.
The read that pages itself
/vault/media takes no parameters because the server does the paging: it walks upstream in pages of 40 until hasMore is false, keeping only ready and viewable items. If an upstream page fails partway, the walk stops and returns what it had, with a 200 — a truncated read looks exactly like a complete one. Never treat a missing id as proof an asset is gone; reconcile additively instead. The item shape is on the vault media API page.
What a page costs
Every list read carries the default weight of one credit per page, whatever the page size, so the arithmetic is simple: always read at limit=100. A 10,000-row walk is 100 calls and 100 credits; at the default 20 it is 500 of each for identical data. On Free that is 40% of the month's 250 credits, and 100 requests against a 60-per-minute ceiling spans two windows. Window mechanics and pacing are on rate limits; per-plan budgets on API costs.
A resumable walk
A long sweep will be interrupted. Persist the offset in the same transaction as the rows it covers, and upsert by id — offset paging over a mutating list is not stable and will hand you the same row twice.
import httpx
BASE = "https://api.ofapis.com/api/public/v1"
LIMIT = 100
UPSERT = """insert into fan (id, username, name, seen_at)
values (%(id)s, %(username)s, %(name)s, now())
on conflict (id) do update
set username = excluded.username,
name = excluded.name,
seen_at = excluded.seen_at"""
MARK = """insert into paging_checkpoint (job, next_offset) values (%s, %s)
on conflict (job) do update set next_offset = excluded.next_offset"""
def sweep(conn, api: httpx.Client, account_id: int) -> int:
job = f"subscribers:{account_id}"
with conn.cursor() as cur:
cur.execute("select next_offset from paging_checkpoint where job = %s", (job,))
row = cur.fetchone()
offset, seen = (row[0] if row else 0), 0
while True:
r = api.get(f"/accounts/{account_id}/subscribers",
params={"type": "all", "limit": LIMIT, "offset": offset})
r.raise_for_status()
rows = r.json()["data"]["list"]
with conn.transaction(), conn.cursor() as cur: # rows + cursor, one commit
cur.executemany(UPSERT, rows)
cur.execute(MARK, (job, offset + LIMIT))
seen += len(rows)
if len(rows) < LIMIT: # short page: done
with conn.cursor() as cur:
cur.execute("delete from paging_checkpoint where job = %s", (job,))
return seen
offset += LIMIT
That is safe to kill at any point: rows and checkpoint commit together, so the stored offset never runs ahead of persisted data, and on conflict do update absorbs the duplicates a shifting list produces. Resuming hours later walks a list that has moved, so treat the checkpoint as crash recovery within a run, not a bookmark — a nightly sweep starts from zero and uses seen_at to find what disappeared.
FAQ
What is the right stop condition for a paged loop?
Stop when the page you received is shorter than the limit you asked for, or when it is empty. hasMore is a useful early exit but not a reliable sole condition, because it is forced to false when upstream returns a bare array. Page length works in both shapes.
Why is there no total or page count on list responses?
Because nothing upstream supplies one: the envelopes are {list, hasMore, nextOffset} or {list, hasMore, nextMessageId} with no size field. Plan for walks of unknown length, and pre-size only where a count exists, such as usersCount on a list object.
Do I need to send limit on every request?
Yes, in practice. The defaults are not uniform — 20 on most routes but 10 on the two list reads — so omitting it silently makes 2.5× the requests there, each one billed. Sending limit=100 explicitly is the cheapest walk available.
How do I read a month of scheduled posts if there is no offset?
Shard the date range instead of paging it. Both dates are required, so query a week at a time at limit=100; if a shard returns exactly 100 rows, halve the window and read again, because a full page is the only truncation signal there is.
Is it safe to page in parallel across offsets?
Only for a list that is not changing, and never for message history, which has no offset to fan out across. Several offsets fired at once against a live subscriber list produce both duplicates and gaps as rows shift between responses, for the same credits as a sequential walk. Page sequentially and upsert by id.