OnlyFans API in Node.js: a webhook receiver that answers back
Node earns its keep on the inbound half of this API. A creator's DMs arrive as message.received webhooks, and the service catching them has to verify a signature over raw bytes, ignore the duplicates it will certainly receive, answer fast, and only then make an outbound call of its own. Short synchronous ack, asynchronous work — that shape is what the examples below build, against https://api.ofapis.com/api/public/v1 with one Bearer token (start free, authentication).
What actually lands on the endpoint
message.received is the only event type that exists today, so there is no event router to write. Four headers come with every POST:
| Header | What it is |
|---|---|
X-Ofapis-Event |
message.received |
X-Ofapis-Delivery |
the delivery id — your dedupe key |
X-Ofapis-Signature |
t=<unix>,v1=<hex HMAC-SHA256> |
X-Request-Id |
echoes the delivery id, for correlating logs |
{
"type": "message.received",
"occurred_at": 1760000000,
"data": {
"chatId": 98765,
"message": {
"id": 111222333,
"text": "are you around tonight?",
"fromUser": { "id": 98765 },
"toUser": { "id": 12345678 },
"createdAt": "2025-06-01T10:05:00+00:00"
}
}
}
Read that payload before writing against it. occurred_at is unix seconds, while message.createdAt beside it is ISO-8601 — passing the first to new Date() lands you in 1970. fromUser and toUser are objects, not bare numbers. There is no event id and no account id anywhere in the body, which is why deduping happens on the X-Ofapis-Delivery header and why an endpoint serving several creators routes on data.message.toUser.id. Registration is covered in the webhook setup guide.
Verify before you parse
The signature covers the exact bytes that were sent, and re-serialising a parsed object will not reproduce them, so capture the raw body first:
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use("/hooks/ofapis", express.raw({ type: "application/json" }));
function verify(raw, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
String(header ?? "").split(",").map((p) => p.split("=")),
);
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${raw.toString("utf8")}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
// timingSafeEqual throws a RangeError on a length mismatch — compare lengths first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
The signed string is t and the body joined by a literal dot, hashed with your endpoint secret. The tolerance is yours to pick; five minutes is generous and still closes the replay window.
Dedupe, acknowledge, then work
The sender allows ten seconds for a response and treats any 2xx as delivered. A failure is retried at one minute, five minutes, thirty minutes, two hours and six hours — six attempts — and ten consecutive failures disable the endpoint automatically. So acknowledge first and do the interesting part afterwards:
const seen = new Map(); // deliveryId -> expiry; use Redis across instances
app.post("/hooks/ofapis", (req, res) => {
if (!verify(req.body, req.get("X-Ofapis-Signature"), process.env.OFAPIS_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const delivery = req.get("X-Ofapis-Delivery");
if (seen.has(delivery)) return res.sendStatus(200); // a retry of work already done
seen.set(delivery, Date.now() + 86_400_000);
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200);
queueMicrotask(() => reply(event, delivery).catch((e) => console.error(delivery, e)));
});
Acking first means a slow OnlyFans round trip never turns into a retry storm. The in-memory Map is the one piece to replace in production: two Node processes behind a load balancer do not share it, and the same delivery can reach either one.
Replying with undici and a hard deadline
Global fetch is undici underneath, but undici.request hands back headers as a plain object, which matters when you want x-ratelimit-remaining and x-request-id off every call:
import { request } from "undici";
const BASE = "https://api.ofapis.com/api/public/v1";
async function reply(event, delivery) {
if (event.type !== "message.received") return;
const { chatId, message } = event.data;
// chatId IS the fan's user id, so anything not sent by the fan is our own echo.
if (message.fromUser?.id !== chatId) return;
const { statusCode, headers, body } = await request(`${BASE}/chats/${chatId}/messages`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.OFAPIS_KEY}`,
"content-type": "application/json",
"idempotency-key": `reply:${delivery}`,
},
body: JSON.stringify({ text: draft(message.text), price: 0 }),
signal: AbortSignal.timeout(15_000),
});
const json = await body.json(); // always drain, even on an error
if (statusCode >= 400) {
throw Object.assign(new Error(json.error?.code ?? "UNKNOWN"), {
statusCode, requestId: headers["x-request-id"],
});
}
if (headers["idempotency-replayed"] === "true") console.log(delivery, "already answered");
return json.data;
}
AbortSignal.timeout is built in from Node 18 and rejects with a TimeoutError; nothing else is needed to bound the call. An aborted request tells you nothing about whether the message landed, which is the whole argument for idempotency-key. Keying it on the delivery id is exact: ofapis retries that same delivery id up to six times, so the second attempt replays the stored response with idempotency-replayed: true instead of DMing the fan twice, and two retries arriving at once produce one send and one 409 IDEMPOTENCY_CONFLICT. The record is held for 24 hours, comfortably longer than the retry schedule. What draft() should contain is the subject of the chatbot guide.
Note the body.json() on the error path too: undici keeps a connection checked out until its body is consumed, so skipping it on a 4xx leaks a socket per failure. body.dump() is the explicit version when you do not want the payload.
Reading history with an async generator
When the bot needs context it pages the conversation backwards. Message history is cursor-based, not offset-based — pass the id of the oldest message you hold:
async function* history(chatId, pageSize = 100) {
let olderThan;
for (;;) {
const qs = new URLSearchParams({ limit: String(pageSize) });
if (olderThan) qs.set("olderThanMessageId", String(olderThan));
const { body } = await request(`${BASE}/chats/${chatId}/messages?${qs}`, {
headers: { authorization: `Bearer ${process.env.OFAPIS_KEY}` },
signal: AbortSignal.timeout(15_000),
});
const { data } = await body.json();
if (!data.list.length) return;
yield* data.list;
if (!data.hasMore) return;
olderThan = data.list.at(-1).id;
}
}
for await (const m of history(98765)) { /* newest first */ }
Chat listings differ: GET /chats takes limit and offset, a chat has no id of its own, and withUser.id is both its key and the path segment you send to. limit is clamped to 1–100 on either endpoint. Every page costs one credit against a per-token ceiling of 60 requests a minute on Free, 100 on Starter and 500 on Pro, so a deep backfill needs a concurrency cap, not Promise.all over every chat (rate limits).
Errors the bot has to tell apart
Branch on error.code, not the number. RATE_LIMIT_EXCEEDED carries Retry-After in seconds and deserves a real sleep. INSUFFICIENT_CREDITS means stop, not slow down. OF_SESSION_EXPIRED means a person must re-link that creator in the dashboard, so raise an alert instead of burning retries. The rest are in the error reference; what a send itself rejects on is in the messaging endpoint.
FAQ
Why does my ofapis webhook signature never match in Express?
Almost always because express.json() ran first and the raw bytes are gone. The HMAC is computed over the exact body that was transmitted, and re-stringifying a parsed object changes key order, spacing or unicode escaping. Mount express.raw({ type: "application/json" }) on the webhook path only, verify against the Buffer, then JSON.parse it yourself.
Should I use global fetch or undici in a Node service?
Global fetch is undici with a WHATWG wrapper, so performance is the same and either is fine. undici.request suits this job because it returns headers as a plain object and makes draining the body explicit. Whichever you pick, bound every call with AbortSignal.timeout — neither has a default timeout.
Which field tells me which creator a webhook belongs to?
data.message.toUser.id, the OnlyFans user id of the recipient. The payload deliberately carries no ofapis account id, so an agency running several creators maps that id to its own account record on one shared endpoint. data.chatId is the fan on the other side, and it doubles as the path segment when you reply.
What happens if my endpoint is down for an hour?
Each delivery is retried at one minute, five minutes, thirty minutes, two hours and six hours, up to six attempts, then marked failed. Separately, ten consecutive failures disable the endpoint, so a long outage costs events even after the service recovers. A fast 200 with asynchronous processing is the cheapest insurance against both.
How do I stop a crashed handler from sending the same DM twice?
Put Idempotency-Key: reply:<X-Ofapis-Delivery> on the send. Because retries reuse the same delivery id, the second attempt matches a stored record and replays the original response with Idempotency-Replayed: true instead of sending again, and it is not charged a second time. Two attempts racing in parallel resolve as one send and one 409 IDEMPOTENCY_CONFLICT.