ofapisv1
capability

Updated

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:

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:

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.

Get a free token · Notifications API

Related guides

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