OnlyFans Chat API
Where the messaging API reads and sends inside one thread, the chat endpoints give you the conversation list — the backbone of an inbox, a support queue, or a triage view spanning a whole agency roster. A chat is the thread; a message lives inside it.
Endpoints
GET /api/public/v1/chats
GET /api/public/v1/chats/users?query=...
GET /api/public/v1/accounts/{accountId}/chats
GET /api/public/v1/accounts/{accountId}/chats/users?query=...
The unscoped form resolves to the first ACTIVE linked account. The /accounts/{accountId}/... form names a creator explicitly, which is what you want once more than one account is linked — see multi-account management.
Parameters
| Param | Where | Type | Default | Bounds | Notes |
|---|---|---|---|---|---|
accountId |
path | integer | — | — | ofapis account id, scoped routes only |
limit |
query | integer | 20 |
clamped to 1–100 | values outside the range are silently clamped, not rejected |
offset |
query | integer | 0 |
clamped to ≥ 0 | zero-based |
query |
query | string | — | required | /chats/users only; free-text name or handle |
/chats is ordered by most recent message. /chats/users is a search, not an enumeration: omit query and the request fails with a 400 before it reaches the gateway.
List conversations
curl "https://api.ofapis.com/api/public/v1/accounts/42/chats?limit=50&offset=0" \
-H "Authorization: Bearer ofapis_sk_..."
{
"data": {
"list": [
{
"withUser": { "id": 98765 },
"canSendMessage": true,
"canNotSendReason": null,
"unreadMessagesCount": 2,
"hasUnreadTips": false,
"isMutedNotifications": false,
"lastReadMessageId": "111222333",
"countPinnedMessages": 0,
"lastMessage": {
"id": 111222333,
"text": "Hey!",
"createdAt": "2025-06-01T10:00:00+00:00",
"isOpened": false,
"isFree": true,
"isTip": false,
"mediaCount": 0,
"fromUser": { "id": 98765 }
}
}
],
"hasMore": true,
"nextOffset": 20
}
}
Two things in that payload decide how you build the inbox.
There is no separate thread id. A chat item carries no chatId field. The identifier you pass to /chats/{chatId}/messages is withUser.id — the fan's OnlyFans user id. One fan, one thread.
The embedded user objects are id-only. Upstream is queried with skip_users=all, so withUser and lastMessage.fromUser contain the id and nothing else: no name, no username, no avatar. Hydrate them yourself with GET /users/{id} from the creator profile and user API, and cache the result — a display name changes far less often than an inbox refreshes.
Paging
Walk offset forward by your limit while hasMore is true. Treat nextOffset as a hint rather than a contract: OnlyFans does not always return one, and the field then falls back to 0. Your own counter is the reliable cursor.
// Node 20+, no dependencies
const base = "https://api.ofapis.com/api/public/v1";
const headers = { Authorization: `Bearer ${process.env.OFAPIS_TOKEN}` };
async function allChats(accountId, limit = 100) {
const out = [];
for (let offset = 0; ; offset += limit) {
const res = await fetch(
`${base}/accounts/${accountId}/chats?limit=${limit}&offset=${offset}`,
{ headers }
);
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? 5);
await new Promise((r) => setTimeout(r, wait * 1000));
offset -= limit;
continue;
}
const { data } = await res.json();
out.push(...data.list);
if (!data.hasMore || data.list.length === 0) return out;
}
}
// Merge a roster into one queue, unread first.
const roster = [42, 43, 44];
const inbox = (await Promise.all(roster.map(allChats)))
.flatMap((chats, i) => chats.map((c) => ({ accountId: roster[i], ...c })))
.sort((a, b) => b.unreadMessagesCount - a.unreadMessagesCount);
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds); a 429 adds Retry-After. The per-token ceiling is 60 requests per minute on Free, 100 on Starter and 500 on Pro — details on rate limits.
Cost
One credit per successful page, so a 4,000-chat roster at limit=100 is 40 credits per full sweep. Credits are debited before the call runs and refunded automatically on 5xx, on 424 and on 409. Requests the gateway rejects outright — 401, 402, 403, 429 — never reach the debit at all. Budget against pricing.
Errors
| Status | error.code |
Meaning |
|---|---|---|
| 401 | UNAUTHENTICATED |
Missing or invalid Bearer token |
| 402 | INSUFFICIENT_CREDITS |
Balance too low for this call's weight |
| 403 | NO_ACTIVE_SUBSCRIPTION |
Token valid, plan not active |
| 404 | ACCOUNT_NOT_FOUND |
accountId is not yours |
| 409 | ACCOUNT_NOT_ACTIVE |
Account linked but disconnected |
| 422 | OF_AUTH_INVALID |
Stored OnlyFans session expired — re-link |
| 422 | ACCOUNT_NOT_LINKED |
Unscoped route with no active account — charged |
| 429 | RATE_LIMIT_EXCEEDED |
See Retry-After |
Errors use {"error":{"code","message"}}. A malformed query param is the exception: Spring rejects it as a plain 400 string before the envelope is applied. Full list on API errors.
Don't poll the list
Re-listing every few seconds burns credits and rate limit for nothing. Subscribe to the message.received webhook — the only event type ofapis emits — and refresh the single affected thread instead. The payload carries chatId, which is already the key your inbox is indexed by. See webhook setup for the endpoint contract, and the Node.js receiver for a worked verify-dedupe-ack handler.
FAQ
What is the chatId I pass to the messaging endpoints?
It is the fan's numeric OnlyFans user id, returned as withUser.id on each chat list entry. The chat list does not expose a separate thread identifier, because a conversation is uniquely defined by the fan on the other side of it.
Why does the chat list not include the fan's name?
The upstream query uses skip_users=all, so embedded user references are reduced to an id. Resolve the ids you actually display through the per-fan lookup at GET /users/{targetUserId} and cache them; re-resolving every id on every inbox refresh wastes a credit per fan.
Can I enumerate every fan I have messaged?
Not with /chats/users — that route requires a query string and returns matches, so it is a people-picker rather than an export. Use /chats for the full conversation set, or the subscribers API if what you want is the audience rather than the threads.
How do I keep one queue across several creators?
Call the account-scoped route once per linked account and merge the results in your own code, tagging each row with its accountId. The API never merges rosters for you, because a chat belongs to exactly one creator's session — the same model the agency automation workflows build on.
Does an empty page still cost a credit?
Yes. Billing is per successful request, not per row returned, so a page past the end of the list costs the same as a full one. Stop on hasMore: false rather than on an empty list.