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:
tis re-read from the clock at each attempt, so a retry of the same event carries a differenttand a differentv1over an identical body. The signature is not a stable fingerprint of the event.- The body is signed byte-for-byte as sent. Re-serialising parsed JSON before verifying changes key order and whitespace and breaks the comparison.
- Rejecting a stale
tis a receiver-side choice. Nothing in the protocol expires a signature for you; five minutes is a reasonable tolerance given that the slowest scheduled retry is hours away and will be re-signed. - Comparison should be constant-time. A naive string equality leaks timing on the prefix.
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