OnlyFans Webhook Setup
You have a URL and twenty minutes. By the end of this page a real DM will have travelled from a fan's phone into your own queue. Everything here is procedure; the field-by-field schema, the signature algebra and the retry timings live on the webhooks reference, and the case for doing any of this is made in why events beat polling.
Before you start, link an OnlyFans account and confirm it is active — account authentication covers that, and an unlinked account produces no events at all, which is an unpleasant thing to debug as if it were a webhook problem.
1. Get a public HTTPS URL (3 minutes)
Pick the host first, because registration will reject most of what developers reach for by habit.
http://is refused outright.- A hostname that resolves to
127.0.0.1, a10.x,172.16–31.x,192.168.x,169.254.xor CGNAT address is refused. That rules outlocalhost, a LAN IP, and any vanity hostname you have pointed at your own machine. - A tunnel such as ngrok or Cloudflare Tunnel works, because the hostname resolves to the provider's public address and your laptop sits behind it.
- Self-signed TLS will not do. The sender validates against the platform trust store and does not pin, so any publicly trusted certificate is fine and a private CA is not.
Note the full path you intend to serve, not just the origin: https://hooks.example.com/ofapis is what you register.
2. Register and capture the secret (2 minutes)
Create the endpoint from the dashboard. Webhook endpoints are managed there rather than through the public Bearer API, so there is nothing to curl with your API token for this step.
Two choices to make. The event list can stay empty, which subscribes you to everything; naming message.received explicitly is the safer habit. The optional account binding scopes the endpoint to a single linked creator — leave it unset to receive from all of them.
Creation returns a 43-character base64url secret. Copy it into your secret manager before you close the tab. It appears exactly once and no later read will show it again. If you lose it, the only remedy is to delete the endpoint, register the same URL afresh, and redeploy with the new value. Set an environment variable now:
export OFAPIS_WEBHOOK_SECRET='paste-the-43-char-value-here'
3. Stand up the receiver (6 minutes)
Three rules, in priority order: read the body as bytes, answer before you work, and never let a handler block. An Express receiver that does all three:
const express = require("express");
const crypto = require("crypto");
const app = express();
const SECRET = process.env.OFAPIS_WEBHOOK_SECRET;
// express.raw, not express.json — the parser would hand you an object and
// the bytes the signature covers would be gone.
app.post("/ofapis", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Ofapis-Signature") || "";
const fields = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const mine = crypto
.createHmac("sha256", SECRET)
.update(`${fields.t}.`)
.update(req.body)
.digest("hex");
const a = Buffer.from(mine, "hex");
const b = Buffer.from(fields.v1 || "", "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
if (Math.abs(Date.now() / 1000 - Number(fields.t)) > 300) {
return res.sendStatus(401);
}
res.sendStatus(204); // answer first
queue.push({ // then hand off
key: req.get("X-Ofapis-Delivery"),
body: JSON.parse(req.body.toString("utf8")),
});
});
app.listen(8080);
timingSafeEqual throws on a length mismatch, which is why the lengths are compared first. Compare the decoded buffers rather than the hex strings so a malformed header cannot crash the route. A fuller version of this receiver — same verification, plus persistent dedupe and a typed queue hand-off — is built up step by step in the Node.js quickstart.
Deploy it. Any 2xx counts as an acknowledgement. The sender sets a ten-second ceiling on each of connect, write and read, so from the moment your route has the request it has ten seconds to produce a status line or the attempt is written off as failed.
4. Fire a test delivery and read it (3 minutes)
Trigger the test from the endpoint's page in the dashboard. The test body is deliberately thin, and it is not a sample of a real event:
{
"type": "message.received",
"test": true,
"data": {
"chatId": 0,
"message": { "id": 0, "text": "Test webhook delivery from ofapis" }
}
}
Three things to notice. It carries a test flag that real events never have. It has no occurred_at. And its message object omits fromUser, toUser and createdAt entirely. A handler that assumes those keys exist will crash on the very first thing you send it, so either branch on test early and drop the payload, or make your parsing tolerant of missing fields.
What the test does prove is the whole transport path: DNS, TLS, routing, your signature code and your response time. If the delivery log says delivered, those are all working.
5. Take the first real event (4 minutes)
Send a DM to the linked creator from a second account. Within seconds a delivery should land carrying data.chatId and the message. That chatId is the fan's own OnlyFans user id, so one fan lookup turns it into a username you can greet by name. Reply from your worker, never from the request handler:
async function handle({ key, body }) {
if (body.test || !(await claim(key))) return; // claim() is your dedupe write
const { chatId, message } = body.data;
if (message.toUser?.id !== MY_CREATOR_ID) return;
// ACCOUNT_ID is the ofapis account id from your dashboard, not an OnlyFans user id.
const url = `https://api.ofapis.com/api/public/v1/accounts/${ACCOUNT_ID}/chats/${chatId}/messages`;
await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OFAPIS_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key, // the delivery UUID, reused
},
body: JSON.stringify({ text: "thanks for the message, one sec" }),
});
}
Passing the delivery UUID as the Idempotency-Key is the cheapest safety net you will ever install: if your dedupe write loses a race, the replay returns the original response with Idempotency-Replayed: true and you are not charged twice. The account-scoped send costs two credits; the inbound notification costs nothing. The send can still fail, and which statuses are refunded and which stay charged is worth knowing before you wrap it in a retry — that is the error code reference.
More patterns for the reply side are in the messaging API guide, the full auto-responder shape in chatbot API, and the end-to-end version in how to automate OnlyFans DMs. Not every delivery has to answer in the creator's voice either: forwarding it into a Discord channel puts a human chatter on the conversation instead.
6. Checklist before production
| Confirm | How |
|---|---|
| Raw bytes reach the verifier | Change one character of the secret; every delivery must start failing |
| Dedupe actually persists | Replay a captured request twice; the second must do nothing |
| Handler returns inside 10s | Measure p99 of the route, not the mean |
| Worker failure is visible | Kill the queue consumer; acknowledgements continue, so only your own alerting will notice |
| Clock is disciplined | Run NTP; drift past the tolerance you chose rejects valid deliveries |
| Unknown types are survivable | Send a body with an unrecognised type; the route should still answer 2xx |
| Secret is rotatable | Write down the delete-and-recreate runbook before you need it |
| Endpoint is still enabled | Check its status after any incident; repeated total failure switches it off |
The last row is the one teams forget. A fully exhausted event takes hours to give up, and ten of those in a row disable the endpoint — silently, from your side. Put the endpoint status on the same dashboard as your rate limit headroom and you will never be surprised by it.
FAQ
Can I point an ofapis webhook at localhost?
No. Registration resolves the hostname and refuses loopback, private and CGNAT addresses, and the same check runs again before each delivery. Use a tunnel like ngrok or Cloudflare Tunnel, which gives you a public hostname in front of your local port.
I closed the tab and lost the signing secret. What now?
It cannot be recovered — the value is returned only in the creation response and never in a later read. Delete the endpoint, create a replacement, and redeploy with the new secret. Until you do, every delivery to the old endpoint will fail verification on your side.
How long does my handler have before the attempt is marked failed?
Ten seconds of read timeout, with separate ten-second ceilings on connect and write. Acknowledge with any 2xx as soon as the signature checks out and move the real work to a queue; a handler that calls the API before responding will blow that budget under load.
Do I have to list the events I want?
No, and leaving the list empty subscribes the endpoint to everything. Naming message.received is still the better default, because it means a future event type cannot start arriving at a handler that was never written to expect it.
Is a test delivery the same shape as a real one?
No. The test body adds a test: true flag, omits occurred_at, and its message contains only id and text. Write your parser to tolerate that before you fire one, or read the real envelope and branch on the flag.