ofapisv1
solution

Updated

OnlyFans Revenue Analytics

The warning goes first, because every design decision below follows from it. No number you compute from this API reconciles to an OnlyFans payout. There is no earnings endpoint, no transactions endpoint and no statistics endpoint. The platform's financial figures live behind the creator's own dashboard and are not exposed over REST, so ofapis cannot return them and does not pretend to.

What it does expose is one numeric money field — price, in cents, on a DM message — plus a feed of activity events with no amounts attached. Everything called "revenue analytics" here is built from those two, and the gap between them and a bank statement is wide enough that it belongs in your UI copy, not just your docs. This page is the money-specific slice; the broader build is on the analytics dashboard page.

What "revenue" can mean here

Pick the definition your team can defend, then name the tile after it.

Definition Implementable How
PPV value sent Yes Sum price across priced outbound messages in a window
PPV value collected No No field records a purchase against a message
Tip volume Partially Inbound messages flagged isTip; tips raised outside a DM thread are not in chat history
Subscription income No No per-charge record exists anywhere in the API
Gross earnings No Requires the platform statement
Net payout No Requires fees, refunds and chargebacks, none of which are exposed
Top fans by spend Partially Rank by summed price of priced messages you stored, per chat_id; the subscriber record has no spend field, so nothing is rankable on day one

The honest headline for most teams is the first row with an explicit label — PPV value sent, last 30 days — beside the event counts from the notifications feed. It answers "is the chat team pricing and pushing harder this month" without claiming to answer "did we make more money". Signal-by-signal reference is on the earnings API and transactions API pages.

price is the ask, not the take

A message object from GET /accounts/{id}/chats/{chatId}/messages carries the money-adjacent fields in full:

{
  "id": 111222333,
  "text": "Hello!",
  "createdAt": "2025-06-01T10:05:00+00:00",
  "fromUser": { "id": 98765, "_view": "s" },
  "isFree": true,
  "price": 0
}

The model behind it also exposes lockedText, isTip, isOpened, isFromQueue, queueId, mediaCount and canPurchase with a canPurchaseReason. Read those carefully, because three of them look like money signals and are not:

There is no isPurchased, no amountReceived and no per-fan payment row — and the per-fan reads do not carry one either. That absence shapes the rest of the build, and it makes an automated PPV drop especially lossy: one scheduled message with price: 1500 may reach four hundred fans, and nothing in the payload splits that into who paid. See the PPV API and tips API pages.

The collection loop

Money lives inside conversations, so the loop is two levels. GET /accounts/{id}/chats is a limit/offset list ordered by most recent message; each entry carries withUser, lastMessage, unreadMessagesCount and a hasUnreadTips flag, and the chatId you need downstream is the fan's own OnlyFans user id. Message history under it is cursor-paged: results come newest-first and you walk backwards by passing the oldest id you saw as olderThanMessageId, which the response also hands you as nextMessageId.

The naive version is a full sweep, and the arithmetic kills it. A creator with 800 conversations costs 8 calls to list the chats at limit=100, plus at least one message page each — 808 credits per sweep, 24,240 a month run nightly. That is a fifth of Pro's 120,000 credits for one creator.

The fix is to exploit the ordering. Chats come back most-recent-first with a lastMessage summary, so stop paging the list the moment lastMessage.createdAt falls behind your watermark, and open only the conversations that moved:

def collect_priced_messages(account_id, since_iso):
    touched, offset = [], 0
    while True:
        page = get(f"/accounts/{account_id}/chats",
                   {"limit": 100, "offset": offset})["data"]
        for chat in page["list"]:
            last = (chat.get("lastMessage") or {}).get("createdAt")
            if last and last < since_iso:
                return touched            # list is ordered — everything after is older
            touched.append(chat["withUser"]["id"])   # chatId == the fan's user id
        if not page["hasMore"]:
            return touched
        offset = page["nextOffset"]

def chat_money_rows(account_id, chat_id, since_iso):
    rows, cursor = [], None
    while True:
        params = {"limit": 100}
        if cursor:
            params["olderThanMessageId"] = cursor
        page = get(f"/accounts/{account_id}/chats/{chat_id}/messages", params)["data"]
        for m in page["list"]:
            if m["createdAt"] < since_iso:
                return rows
            if m.get("price", 0) > 0 or m.get("isTip"):
                rows.append(m)
        if not page["hasMore"]:
            return rows
        cursor = page["nextMessageId"]

With sixty active conversations a day that is roughly 68 credits per run instead of 808 — about 2,000 a month, which leaves room for five creators and the rest of the product. Store raw message rows keyed on (account_id, message_id) so re-running a window is idempotent and a changed definition of "revenue" is a re-query, not a re-collection you cannot afford. That table and the outbound send log beside it are the same two the CRM build sets up, so it is worth doing once for both.

Rolling up a roster under one token

One Bearer token addresses every linked account through the /accounts/{accountId}/ prefix, so a roster roll-up is the same two functions in a loop with a different id. Pro allows five linked accounts; Starter and Free allow one. See pricing and multi-account management.

Keep account_id in the primary key of every stored row and the roll-up is a GROUP BY. The useful agency table is deliberately narrow — every column points back at a stored row:

Column Source
Priced messages sent Count of rows with price > 0
PPV value sent Sum of price, in cents
Median ask Median price across priced rows
Active conversations Distinct chat_id touched in the window
Tip messages Count of rows flagged isTip

Resist adding a "revenue" column that totals across creators. The inputs are asks, not receipts, and summing them across a roster produces a confident-looking figure that is wrong by an unknown multiple.

Spelling out the reconciliation gap

When someone asks why your figure and the payout statement disagree, these are the reasons, and all of them are structural rather than bugs to fix:

Practical rule: render the number with the window and the definition next to it (PPV value sent, 1–30 June), never the word "earnings", and never a comparison against a payout. A dashboard that is explicit about what it measures survives the first finance review; one that implies reconciliation does not.

FAQ

What should I call the number on a revenue tile?

Name it after the thing you actually computed — "PPV value sent" or "tip messages received" — with the window beside it. A tile labelled "earnings" or "revenue" is a claim about money that arrived, and nothing in this API supports that claim.

Can I tell whether a PPV message was bought?

No. The message object has price, isFree, lockedText and isOpened, but no purchase flag and no received-amount field, and isOpened only means the message was read. Purchase outcome is not exposed over the API in any form.

Where does subscription income show up?

Nowhere as a charge. The notifications feed can tell you that someone subscribed, and a user profile read carries that creator's current subscription pricing, but there is no record of an individual renewal being billed. Any subscription figure you build is list price multiplied by a headcount, which ignores promotions, bundles and trials entirely.

How much does one creator's collection run cost?

A watermarked run is roughly one credit per chat-list page plus one per conversation that saw activity — about 70 credits a day for a creator with sixty active threads. An unwatermarked full sweep of 800 conversations is 808 credits per run, which is why the ordering shortcut matters more than any other optimisation here.

Should webhooks keep the figure live?

They can keep the activity live, not the figure. message.received is the only event type that exists, it fires on inbound DMs, and its payload carries the message text and ids rather than an amount. Use it to mark a conversation dirty for the next collection pass; see the chat API for the message surface and get a token to start.

Related guides

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