OnlyFans Transactions API
GET /transactions does not exist. Neither does /statistics, nor any payout or statement object. OnlyFans does not expose a ledger to third parties, so ofapis has nothing to proxy and does not pretend to.
The onlyfans transactions api is therefore a data modelling problem, not an integration problem. You poll a handful of reads, turn what they return into rows, and make the ingest idempotent so polling the same window twice does not invent revenue. The hard parts are the dedupe keys and the backfill ceiling, both decided by API shape rather than by your schema. Get them wrong and the ledger quietly drifts.
Model rows, not responses
An event-sourced table beats mirroring any response body: the sources disagree about structure and agree only about identity.
CREATE TABLE of_ledger (
source text NOT NULL, -- 'message' | 'notification' | 'send'
source_id text NOT NULL, -- message id, feed item id, or idempotency key
account_id bigint NOT NULL, -- ofapis linked account
fan_id bigint, -- equals chatId for DM-derived rows
kind text NOT NULL, -- 'tip' | 'priced_send' | 'feed_event'
gross_cents integer NOT NULL DEFAULT 0,
occurred_at timestamptz, -- createdAt from the payload, never now()
observed_at timestamptz NOT NULL DEFAULT now(),
payload jsonb NOT NULL,
PRIMARY KEY (source, source_id)
);
CREATE INDEX ON of_ledger (account_id, occurred_at);
Two things do the real work. (source, source_id) is the natural key, because an id is only unique within the surface that issued it. And occurred_at and observed_at stay separate: the first is what the payload says, the second is when your poller saw it. Collapse them and you cannot tell a late-arriving row from a backdated one — exactly the question you will have when a total shifts.
Dedupe keys that hold
Each source gives you exactly one stable identifier, and none of them are interchangeable:
- DM messages — the numeric
idon each message fromGET /chats/{chatId}/messages. Stable across re-reads and the strongest key available. Use it for tip rows (isTiptrue) and for anything priced. - Notification feed items — the
idon each item fromGET /notifications. The documented item shape is{id, type, text, createdAt}, so the id is all you get; the body carries no amount and no structured actor. - Your own sends — the
Idempotency-Keyyou minted for thePOST. Key the row on that, not on the returned message id, so the row exists even if the response is lost in flight.
Do not key on a timestamp: createdAt is an ISO-8601 string with an offset, two messages in the same second are common, and the clocks are not yours. Do not key on a content hash either — identical "Thanks!" tips of the same amount from the same fan happen, and are not duplicates.
Idempotent ingest
With the natural key in place, ingestion is one statement and re-polling becomes free of consequence:
INSERT INTO of_ledger
(source, source_id, account_id, fan_id, kind, gross_cents, occurred_at, payload)
VALUES
('message', $1, $2, $3, 'tip', $4, $5, $6)
ON CONFLICT (source, source_id) DO NOTHING;
DO NOTHING rather than DO UPDATE is deliberate: the sources are read-only, so a re-read producing different content under the same id is worth alerting on, not silently overwriting.
The write side has its own idempotency and it is worth matching to the ledger. Any POST accepts an Idempotency-Key header. A replay returns the original response with Idempotency-Replayed: true and is not charged twice; a genuinely concurrent duplicate gets 409 IDEMPOTENCY_CONFLICT. Keys live for 24 hours. So: generate the key, insert the ledger row as pending, send the request, and on a 409 retry after a moment rather than minting a fresh key — a new key is a new send and a second charge.
Backfill is bounded, asymmetrically
This is where ledger projects discover their constraint late.
The notification feed does not backfill at all. GET /notifications accepts only type and limit (clamped 1–100). No offset, no cursor, no before/after. The response contains hasMore, but nothing you can pass to act on it. The oldest feed event you will ever hold is whichever sat in the most recent 100 items at your first poll, so history before that is unrecoverable — a reason to start collecting before you need the data. GET /mentions is the exception among the activity reads, taking limit and a zero-based offset, but it returns release-form mentions — tagged collaborations — with no amounts, so it belongs in a collaborations table, not a money ledger.
Message history does backfill, conversation by conversation. GET /chats/{chatId}/messages takes limit (1–100) and olderThanMessageId, and the response exposes nextMessageId — the id of the oldest message in the page — to feed the next call. The whole walk is a dozen lines of curl and jq.
CHAT=98765; CURSOR=""
while :; do
Q="limit=100${CURSOR:+&olderThanMessageId=$CURSOR}"
R=$(curl -s "https://api.ofapis.com/api/public/v1/chats/$CHAT/messages?$Q" \
-H "Authorization: Bearer $OFAPIS_TOKEN")
echo "$R" | jq -c '.data.list[] | select(.isTip or .price > 0)'
[ "$(echo "$R" | jq -r '.data.hasMore')" = "true" ] || break
CURSOR=$(echo "$R" | jq -r '.data.nextMessageId')
done
Cost it before running it across a roster. One credit per 100 messages: a 2,000-message conversation is 20 credits, and 400 conversations averaging 300 messages is roughly 1,200 credits plus four /chats pages. That fits a Starter month once. It does not fit as a nightly job — the whole argument for an append-only store with a watermark instead of a recompute.
What the ledger is not
It is not accounting, and nothing in it reconciles to an OnlyFans payout. Three structural reasons, independent of how carefully you build it:
- Gross only. Every amount is a face value on a message. The platform cut is absent from the data entirely.
- No reversals. A refund or chargeback produces no counter-event you can observe, so an append-only ledger built this way only ever grows. Correct with an explicit compensating row you write yourself, never with an
UPDATEor aDELETE— and mark those rows so you can separate observed money from adjusted money later. - No purchase events. A priced send is recorded as intent.
isOpenedon a message is documented as whether it was opened or read, not paid, so nothing promotes that row to a settled one.
Build dashboards on it, attribute fan value with it, let it drive revenue analytics and an analytics dashboard, and sit it beside the growth tables you snapshot so money and audience share a time axis. Do not close a month on it. The same caveat framed around a daily figure is on the earnings page; the detection mechanics for tip rows specifically are on the tips page.
FAQ
Why key on (source, source_id) rather than one global id?
Because message ids, feed item ids and your own idempotency keys come from three different issuers with no shared namespace, and nothing guarantees they will not collide. Prefixing the issuer makes the primary key a genuine natural key and makes ON CONFLICT DO NOTHING safe for every source at once.
Will re-polling the same window double-count?
Not if the unique constraint is on the natural key and the insert uses ON CONFLICT DO NOTHING. Re-polling then costs credits and nothing else, which is the behaviour you want — it means a crashed collector can simply restart from its last watermark without a reconciliation pass.
How far back can I reconstruct?
Message history goes back as far as the conversation does, walked 100 at a time with olderThanMessageId. Feed events do not go back at all beyond the 100-item window visible at your first poll. In practice: backfill DMs once, then start the feed collector and accept that its history begins on day one.
Should webhooks write to the ledger directly?
No. message.received is the only event type, and its payload omits price and isTip, so it cannot populate a money row. Use it as a wake-up to fetch the real message and write from that; webhook setup covers the signature and the X-Ofapis-Delivery header you dedupe deliveries on.
Do failed polls cost credits?
It depends on the failure. Credits are metered before the handler runs, so 401, 402, 403 and 429 are refused before metering and cost nothing, while 424 and 5xx are debited then refunded. A 400 or 404 — a bad limit, a chat id that does not exist — is charged and not refunded, so validate cursors before you loop. See pricing and the API reference.