ofapisv1
info

Updated

OnlyFans Webhooks

OnlyFans webhooks on ofapis push exactly one event — message.received — to an HTTPS endpoint you register in the dashboard, signed with an HMAC you verify. This page is the specification: what gets sent, under which headers, how the signature is built, and exactly how long a broken endpoint keeps getting retried before it is switched off. If you want the twenty-minute build instead, follow the webhook setup walkthrough; if you want the argument for pushing rather than polling, read why webhook events beat a poll loop.

Endpoints are created and listed from the dashboard, under your account session. There is no /webhooks route on the public token API, so do not go looking for one in the API reference — the Bearer token meters outbound calls, and registration is a separate, session-authenticated surface.

Event catalogue

Event type Emitted when Payload root
message.received An inbound DM arrives on a linked account's realtime stream data.chatId, data.message

One event type exists. Not "one for now, with a catalogue coming" — one, and the enum in the backend has a single entry. Anything you read elsewhere promising tip, subscription or post events from this platform is describing software that has not been written. A new subscriber raises nothing, which is why welcome-message automation has to detect signups by diffing the subscriber list rather than waiting to be told.

That single row has a consequence for registration. An endpoint stored with an empty subscription list is treated as subscribed to everything, so it receives message.received without you naming it. Convenient today, but a second event type added later would begin arriving at that endpoint unannounced. Name the type explicitly if your handler would rather reject what it does not recognise.

Envelope

{
  "type": "message.received",
  "occurred_at": 1760000000,
  "data": {
    "chatId": 55123987,
    "message": {
      "id": 88213,
      "text": "hey are you online?",
      "fromUser": { "id": 55123987, "_view": "s" },
      "toUser": { "id": 4471002, "_view": "s" },
      "createdAt": "2026-07-21T09:20:00+00:00"
    }
  }
}
Field Type Meaning
type string The event type, mirrored in the X-Ofapis-Event header
occurred_at integer Unix seconds, stamped when ofapis observed the event — not when OnlyFans created the message
data.chatId integer Conversation identifier; equal to the fan's OnlyFans user id
data.message.id integer OnlyFans message id
data.message.text string or null Plain text body; null on a media-only message
data.message.fromUser object User reference, { "id", "_view" } — the sender
data.message.toUser object User reference, { "id", "_view" } — the addressee
data.message.createdAt string ISO-8601 with offset, as OnlyFans reported it

Two absences matter more than any field above. There is no event identifier anywhere in the body, so body content is not a dedupe key. And there is no account identifier, so an envelope cannot tell you which of your linked creators it belongs to. When one token drives several creators, branch on data.message.toUser.id; the multi-account fan-in pattern covers how to map that id back to your own account row.

Because chatId is the fan's own OnlyFans user id, it doubles as the key you pass to a single-fan lookup to turn a delivery into a named person with a username and an avatar.

Treat every field as nullable. The upstream model types id, text and createdAt as optional, and a handler that destructures them without a guard will throw on the first media-only DM it meets.

Headers on every delivery

Header Value
Content-Type application/json
X-Ofapis-Event The event type, e.g. message.received
X-Ofapis-Delivery UUID, stable across all retry attempts of one event
X-Ofapis-Signature t=<unix>,v1=<hex>
X-Request-Id The same UUID as X-Ofapis-Delivery, for log correlation

X-Ofapis-Delivery is the idempotency key. It is minted once when the delivery row is written and reused on every attempt, which is precisely the property a dedupe key needs and precisely the property the body lacks.

Signature construction

The signed string is the timestamp, a literal dot, then the raw body bytes:

signed_payload = "<t>" + "." + <raw request body>
v1             = lowercase_hex( HMAC-SHA256( signed_payload, secret ) )
header         = "t=" + <t> + ",v1=" + v1

The secret is used as raw UTF-8 bytes, not decoded from base64 first. Four properties follow from the implementation and are worth writing down:

A receiver that does all four correctly — raw bytes, constant-time compare, timestamp tolerance, acknowledge before you work — is written out line by line in the Node.js webhook walkthrough. If you would rather not run a service at all, a Pipedream workflow can own the HTTPS endpoint and do the verification step for you.

Endpoint constraints

Rule Value
Scheme https only; anything else is rejected at registration
Resolved address Public only — loopback, link-local, RFC1918 and CGNAT ranges refused
Re-validation The URL is resolved again before every attempt, closing the DNS-rebinding window
Secret 32 random bytes, base64url without padding (43 characters)
Secret visibility Returned once at creation; never present in any later read
Account scope Optional — an endpoint may be bound to one linked account, or left open to all of them

A URL that passes registration and later resolves inside a private range fails the re-check at delivery time, and that failure is counted as an ordinary failed attempt rather than a special case. It climbs the same ladder and contributes to the same disable counter.

Retry ladder and auto-disable

Any 2xx is success. Everything else — a 4xx, a 5xx, a connection error, or a handler that has not responded within the ten-second read timeout — is a failed attempt.

Attempt Fires at Elapsed since the event
1 immediately 0
2 +1 minute 1m
3 +5 minutes 6m
4 +30 minutes 36m
5 +2 hours 2h 36m
6 +6 hours 8h 36m

After the sixth attempt fails the delivery is terminal. Add a few seconds of slack to each row: the dispatcher sweeps due rows every five seconds rather than firing on an exact timer.

The disable counter moves at the level of whole deliveries, not attempts. A delivery that exhausts all six attempts increments the endpoint's consecutive-failure count by one; any delivered event resets it to zero. Ten consecutive exhausted deliveries disable the endpoint, which then stops receiving events until you re-enable it. Because each exhausted delivery spans roughly eight and a half hours, a completely dead endpoint survives a long time before it is switched off — and a flapping endpoint that succeeds once in a while never reaches the threshold at all.

Delivery rows carry a status of PENDING, RETRYING, DELIVERED or FAILED, plus the attempt count, the next scheduled attempt and the last error string. Endpoints are ACTIVE or DISABLED. Reading the delivery log for an endpoint is the fastest way to tell a signature bug (every attempt rejected immediately) from a reachability problem (connection errors) — the same distinction the error code reference draws for outbound calls.

Semantics

Delivery is at-least-once. There is no ordering guarantee between two events, no exactly-once mode, and no replay endpoint for an event that has gone terminal. An event that fails all six attempts is gone; recover it by reading the conversation back through the chat API and reconciling against what you have already processed.

Inbound deliveries are free. Credits meter your outbound calls only, so a webhook that triggers an auto-reply through the messaging API costs the two credits of that account-scoped send and nothing for the notification. Deliveries don't consume slots in your per-minute request window either, which is the second half of why push beats polling.

FAQ

How many webhook event types does ofapis have?

Exactly one: message.received, fired when a fan's DM lands on a linked account. The backend enum has a single member, so there is no second event type to subscribe to today and no dated promise of one.

What exactly is signed in the X-Ofapis-Signature header?

The HMAC-SHA256 of the timestamp, a dot, and the raw body bytes, keyed by your endpoint secret as UTF-8. The result is lowercase hex in the v1 field, with the same timestamp repeated in the t field so you can recompute it. The timestamp is taken fresh at each attempt, so retries of one event carry different signatures.

Can I deduplicate on something in the payload?

No. The envelope carries no event id, and occurred_at is only second-granular. Use the X-Ofapis-Delivery header, which holds one UUID per event and repeats it unchanged across all six attempts; data.message.id is a reasonable secondary key but is not what the retry machinery keys on.

When does ofapis stop sending to my endpoint?

A single event stops after six failed attempts spread over about eight and a half hours. The endpoint itself is disabled after ten consecutive events have each exhausted that ladder, and the counter resets to zero the moment any delivery succeeds.

Does a webhook payload say which creator it is for?

Not directly — there is no account id in the body. Read data.message.toUser.id, which is the OnlyFans user id of the account the DM was addressed to, and map it to your own records.

Related: webhook setup walkthrough

Related guides

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