ofapisv1
solution

Updated

The OnlyFans CRM API

If you are building an OnlyFans CRM — in-house for an agency roster, or as a product for creators — this is the reference to read before you commit to a schema: a map from the concepts a CRM is made of (contact, segment, timeline, campaign) onto the HTTP surface that actually exists at https://api.ofapis.com/api/public/v1, with the places where no endpoint exists marked as such rather than glossed over.

The structural fact to absorb first: nothing in this API reports money per fan. There is no transactions resource, no earnings-per-subscriber field, no lifetime-value counter. A subscriber record carries five fields and none of them is a number you can sum, and the richer per-fan reads do not add one. Every revenue-shaped column in a working OnlyFans CRM is something that system accumulated from observed messages and its own send log. Plan around it; the build guide covers the storage side.

CRM concept to endpoint

CRM concept Endpoint What you get back Gap
Contact list GET /subscribers?type=active id, username, name, avatar, subscribedOn No join date, spend or renewal date
Lapsed contacts GET /subscribers?type=expired Same five fields No cancellation date or reason
Contact detail GET /users/{targetUserId} subscribedBy, subscribedByExpireDate, currentSubscribePrice, lastSeen, joinDate, canChat, canReceiveChatMessage One credit per fan, so not bulk-usable
Lookup by handle GET /users/by-username/{username} Same profile record —
Conversation index GET /chats chatId, user, unreadMessagesCount, lastMessage with createdAt, text, isTip, isFree, mediaCount Ordered by most recent message; no filter
Contact search GET /chats/users?query= Matching profiles Searches people you have chats with, not the full roster
Message timeline GET /chats/{chatId}/messages id, text, createdAt, fromUser, toUser, price, isTip, isOpened Cursor only — no date-range filter
Reply POST /accounts/{id}/chats/{chatId}/messages The created message Costs 2 credits
Activity feed GET /notifications Items forwarded verbatim from OnlyFans No offset; head of feed only
Unread counter GET /notifications/count Counter object —
Segments GET /accounts/{id}/lists id, name, usersCount, canDelete, canManageUsers Default limit is 10, not 20
Segment members GET /accounts/{id}/lists/{listId}/users Fan rows incl. subscribedBy Default limit 10
Tag a cohort POST /accounts/{id}/lists/{listId}/users with {"userIds":[…]} {"added":[…]} Removal is one fan per DELETE call
Campaign POST /accounts/{id}/mailings, then /preview, /trigger, /runs Run record with counts Account-scoped only; no top-level form
Scheduled DM POST /accounts/{id}/scheduled/messages Queue entry Costs 3 credits
Attachments POST /accounts/{id}/media, GET /vault/media Media ids for reuse Upload costs 5 credits
Inbound events Webhook message.received chatId plus {id, text, fromUser, toUser, createdAt} The only event type that exists
Creator identity GET /me id, username, name, email, subscribersCount, isAuth —

Every read also has an account-scoped twin under /accounts/{accountId}/…; the lists and mailings surfaces exist only in that form. The full machine-readable contract is the OpenAPI spec linked from the docs.

What a subscriber row actually contains

{"data":{"list":[{"id":98765,"username":"fan_handle","name":"Fan Name",
  "avatar":"https://cdn3.onlyfans.com/...","subscribedOn":true}],
  "hasMore":true,"nextOffset":20}}

The id here is the fan's OnlyFans user id, and it is also the chatId for that conversation — so you can go from a subscriber row straight to /chats/{chatId}/messages without a lookup. That identity is the single most useful join key in the whole surface; the subscribers API page covers the list itself.

Be careful with subscribedOn: it is a boolean, not a date. There is no subscription-start timestamp on a subscriber row, so "fans who joined this month" is not a query you can run — it is a diff between two snapshots you stored yourself. In OnlyFans's own vocabulary the two booleans also point in opposite directions: on a full profile record subscribedBy means this user subscribes to you, while subscribedOn means you subscribe to this user. Treat the type query parameter as the authority on subscription state and the booleans as decoration. The parameter accepts active, expired and all; anything else is silently coerced to active, with a 200 and no warning.

Pagination is not uniform

Surface Mechanism Default limit Clamp
/subscribers, /following, /chats, /chats/users, /mentions limit + offset, response carries hasMore and nextOffset 20 1–100
/chats/{chatId}/messages limit + olderThanMessageId cursor, response carries hasMore and nextMessageId 20 1–100
/accounts/{id}/lists, /accounts/{id}/lists/{listId}/users limit + offset 10 1–100
/notifications limit only 20 1–100

The two that catch people are the lists pair at 10 and the notifications feed with no offset at all. A client that never sends limit and assumes a default of 20 silently sees half of every segment.

Credit weight per call

Call Credits
Any GET, and POSTs not listed below 1
POST /accounts/{id}/chats/{chatId}/messages 2
POST /accounts/{id}/scheduled/posts and /scheduled/messages 3
POST /accounts/{id}/media 5
POST /accounts/{id}/mailings/{id}/trigger 0 at the call; the run debits recipientCount × 1

Weights are bound to the account-scoped write paths, so the unscoped twin POST /chats/{chatId}/messages is metered at the default 1 credit while /accounts/{id}/chats/{chatId}/messages costs 2. Call the preview step before trigger and you get recipientCount and estimatedCost back for one credit, which is the cheapest confirmation dialog you will ever build. Note that recipientCount comes back as -1 when the mailing targets all subscribers rather than a list — so always point a campaign at a fan segment if you want a cost figure before you commit. Plan allowances are on pricing.

Which failures cost you

Credits are debited before the handler runs, so "did it fail" and "was it billed" are different questions. The refund rule is narrow: 5xx, 424 and 409 only.

Status Billed Response body
401, 402, 403, 429 No — refused by the gateway before metering JSON envelope with code: UNAUTHENTICATED, INSUFFICIENT_CREDITS, NO_ACTIVE_SUBSCRIPTION, RATE_LIMIT_EXCEEDED
409 on a duplicate Idempotency-Key Debited, then refunded JSON envelope with code: IDEMPOTENCY_CONFLICT
424 expired OnlyFans session, 409 inactive account, 5xx Debited, then refunded Bare text message, no envelope
400, 404, 413, 415, 422 Yes, and not refunded Bare text message, no envelope

Read the third and fourth rows together, because they are where money leaks. A stored OnlyFans session that expired gives 424 and the credit comes back. An account that was never linked, or credentials OnlyFans rejects as invalid, give 422 — charged, and not refunded; so does a rejected webhook URL. A retry loop on a malformed request bills every attempt.

So branch on error.code only for the five gateway cases in the first two rows. Everything else is a plain-text message with no JSON wrapper and no code field, so switch on the HTTP status instead and keep your handler ready for a body that is not JSON. Every response carries X-RateLimit-Limit, -Remaining and -Reset, a 429 adds Retry-After, and X-Request-Id is echoed back for correlation — log all four. Full list on API errors.

The four gaps, named

No money per fan. Covered above. Your only observable signals are the isTip flag on messages and whatever appears in the notifications feed.

No churn event. Nothing fires when a subscription lapses. Membership of type=expired is as close as it gets, and you only notice a transition by comparing two reads. Fan retention works through the consequences.

No notification history. Without an offset, the notifications feed is a live tap. Miss a window and those events are unrecoverable.

One webhook event type. message.received is it. Subscriptions, tips and purchases have no push path, so they arrive only via polling. The payload has no event id and no account id, so dedupe on the X-Ofapis-Delivery header and route multi-creator traffic on data.message.toUser.id.

All four are properties of the surface as it stands, not switches waiting to be flipped, so the real design question is what you persist from day one. The CRM build guide has the schema and the sync loop; Free's 250 credits walk a 25,000-fan roster once at limit=100, which is enough to see the shape of your own data before you pay for anything.

FAQ

Is there a single endpoint that returns a fan with their spend attached?

No. The richest per-fan read is GET /users/{targetUserId}, which returns profile and subscription-state fields such as subscribedByExpireDate, currentSubscribePrice and lastSeen — but no purchase history and no totals. Spend has to be inferred from tip-flagged messages you observed and PPV sends you logged.

How do I get a fan's conversation without searching for the chat first?

Use the subscriber's id directly as the chatId. They are the same number, so GET /chats/{chatId}/messages works straight off a subscriber row and you skip an entire lookup call per fan.

Why does my segment listing stop at ten fans?

Because /accounts/{id}/lists and /accounts/{id}/lists/{listId}/users default to limit=10, unlike every other paginated surface, which defaults to 20. Pass limit explicitly — up to 100 — and keep following nextOffset until hasMore is false.

Can one token serve several creators?

Yes, on a plan with more than one account slot. Address each creator through /accounts/{accountId}/… and keep accountId on every stored row. Rate limits are per token, not per account, so a roster shares one 100- or 500-request-per-minute budget — what that ceiling means for chatter shifts and staffing is the subject of running a roster as an agency.

What is the smallest set of endpoints a working CRM needs?

Four: /subscribers for the roster, /chats for recency, /accounts/{id}/lists for grouping and /accounts/{id}/mailings for outreach. Everything else — profiles, message history, media, scheduling — is enrichment you add once the loop runs. The mass DM API covers the outreach half in detail.

Related guides

All guides
Start free — 250 credits, no card
Generate a token and make your first call in minutes.
Get started