How to Build an OnlyFans Bot
A bot is four steps in a loop — receive, verify, decide, reply — plus the state that has to survive between turns and the deployment that has to stay up while nobody is watching. This builds all of it on ofapis, an independent API you point at your own connected account; the chatbot API reference is the route-by-route version of what follows. It is the reactive half of automation: one fan, one message, one decision. Broadcasts are a different machine, covered in automating DMs.
Receive
message.received is the only webhook event that exists. Register an HTTPS endpoint through the webhooks API and every inbound fan DM arrives as a POST shaped exactly like this:
{
"type": "message.received",
"occurred_at": 1760000000,
"data": {
"chatId": 98765,
"message": {
"id": 111222333,
"text": "hey, is the bundle still up?",
"fromUser": { "id": 98765, "username": "fan_jane" },
"toUser": { "id": 12345678, "username": "creator_handle" },
"createdAt": "2026-06-01T10:05:00+00:00"
}
}
}
Three facts about that body drive the rest of the design. There is no event id in it — the only unique identifier for a delivery is the X-Ofapis-Delivery header, a UUID. There is no account id in it either, so a bot serving several creators routes on data.message.toUser.id, which is the receiving creator. And data.chatId equals the fan's OnlyFans user id, which makes it a usable primary key for per-fan state without a second lookup.
Deliveries are queued and drained by a worker on a short cycle, so expect a few seconds of latency rather than instant arrival. Each attempt has a 10-second timeout, retries at 1m, 5m, 30m, 2h and 6h for 6 attempts total, and after 10 consecutive failures the endpoint is disabled outright. Your handler must therefore acknowledge fast and do the work afterwards — a bot that replies inline before returning 200 will time out behind a slow upstream and get its endpoint switched off overnight.
Verify
The signature is X-Ofapis-Signature: t=<unix>,v1=<hex>, where the hex is HMAC-SHA256 over the string "<t>.<raw body>" with your endpoint secret. The timestamp is inside the signed payload specifically so you can reject replays, so check it. Verify on raw bytes, before any JSON parse — a body re-serialised by your framework will not hash to the same digest.
Decide, with state
A bot that only sees the current message cannot do anything interesting. Two tables are enough:
CREATE TABLE delivery_seen (
delivery_id UUID PRIMARY KEY,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE fan_state (
chat_id BIGINT PRIMARY KEY, -- = the fan's OnlyFans user id
creator_id BIGINT NOT NULL, -- from message.toUser.id
stage TEXT NOT NULL DEFAULT 'new',
last_msg_id BIGINT,
last_reply_at TIMESTAMPTZ
);
delivery_seen is the dedupe table, keyed on the header because the body has nothing to key on. Insert first and let the unique constraint tell you whether you have already handled this one; the retry schedule above means a slow 200 can easily arrive twice. fan_state is what makes the bot a conversation rather than a reflex — stage is yours to define, and last_reply_at is what stops two messages arriving thirty seconds apart from producing two near-identical replies.
On a chat_id you have never seen, you can backfill context with one read of GET /chats/{chatId}/messages, which returns newest-first with limit defaulting to 20 and clamped to 100, and pages backwards with the olderThanMessageId cursor. That is 1 credit for a cold start and worth it once per fan; doing it on every turn is not.
Reply
Sending is POST /accounts/{id}/chats/{chatId}/messages with a body of text, and optionally lockedText, mediaFiles, price in cents and previews for locked media. The account-scoped route costs 2 credits; the legacy unscoped /chats/{chatId}/messages costs 1 but resolves to your first active account, which is fine for a single creator and wrong for a roster. Pay the 2. The messaging API covers PPV and attachments in full, and sending messages walks a single send end to end.
The whole thing
import express from "express";
import crypto from "crypto";
const app = express();
const SECRET = process.env.OFAPIS_WEBHOOK_SECRET;
const TOKEN = process.env.OFAPIS_TOKEN;
const ACCOUNT = process.env.OFAPIS_ACCOUNT_ID;
const TOLERANCE = 300; // seconds
function verify(raw, header) {
const parts = Object.fromEntries((header || "").split(",").map((p) => p.split("=")));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > TOLERANCE) return false;
const expected = crypto.createHmac("sha256", SECRET)
.update(`${parts.t}.${raw.toString("utf8")}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/hooks/ofapis", express.raw({ type: "*/*" }), async (req, res) => {
if (!verify(req.body, req.headers["x-ofapis-signature"])) return res.sendStatus(401);
res.sendStatus(200); // ack inside the 10s window
const deliveryId = req.headers["x-ofapis-delivery"];
if (!(await db.firstSeen(deliveryId))) return; // INSERT … ON CONFLICT DO NOTHING
const { data } = JSON.parse(req.body.toString("utf8"));
const { chatId, message } = data;
if (message.fromUser.id === message.toUser.id) return; // never answer your own echo
const state = await db.loadFan(chatId, message.toUser.id);
const reply = decide(state, message.text); // your logic
if (!reply) return;
const r = await fetch(
`https://api.ofapis.com/api/public/v1/accounts/${ACCOUNT}/chats/${chatId}/messages`,
{ method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": deliveryId }, // same delivery → same send
body: JSON.stringify({ text: reply, price: 0 }) },
);
if (r.status === 424) return alertHuman("OnlyFans session expired — re-link the account");
if (r.ok) await db.saveFan(chatId, { last_msg_id: message.id, last_reply_at: new Date() });
else console.error("send failed", r.status, r.headers.get("x-request-id"));
});
app.listen(3000);
The Idempotency-Key set to the delivery id is the belt to the dedupe table's braces: if the same delivery is retried and your delivery_seen insert races, the replayed POST comes back with the original body and Idempotency-Replayed: true instead of sending a second DM, and is not charged twice. Logging x-request-id on failure gives you the one value that ties your log line to the gateway's. A fuller Node receiver adds the request timeouts and queue hand-off this sketch skips.
Deploy
The endpoint must be reachable over public HTTPS, and the URL is re-validated at delivery time, not only at registration — a host that resolves to a private or internal address is blocked and the attempt counts as a failure against the auto-disable counter. A tunnel pointed at your laptop is fine for a demo and not a deployment.
Beyond that: run the handler and the worker as one process only if you are comfortable with the ack path sharing a thread pool with outbound sends; otherwise push the parsed event onto a queue in the handler and reply from a consumer. Keep OFAPIS_WEBHOOK_SECRET and the ofapis_sk_... token in the environment, never in the bundle. And put a floor on reply frequency per chat_id — the per-token ceiling is 60 requests a minute on Free, 100 on Starter and 500 on Pro, and a feedback loop with a chatty fan will find it. The chatbot build goes further on the decision layer, and the API reference has the full send schema. Before you leave this running unattended, the next step in this series is what at-least-once delivery obliges you to build.
FAQ
What do I dedupe on if the payload has no event id?
The X-Ofapis-Delivery header, which is a UUID unique to each delivery attempt chain. Store it with a unique constraint and ignore repeats. The body genuinely contains no id field, so the header is the only safe key — and X-Request-Id on the same delivery echoes that same value.
How do I stop the bot replying to its own messages?
Compare data.message.fromUser.id with data.message.toUser.id and drop anything where the sender is the creator. Keeping last_reply_at in per-fan state is the second guard: it lets you ignore a burst of inbound messages that would otherwise each earn their own reply.
How long do I have to return a 200?
Ten seconds per attempt. Miss it and the delivery is retried at 1m, 5m, 30m, 2h and 6h — six attempts in total — and ten consecutive failures disable the endpoint. Acknowledge first, then do the work.
Can one bot serve several creators?
Yes. Route on data.message.toUser.id, which identifies the receiving creator, and send through the account-scoped /accounts/{id}/chats/{chatId}/messages route for that creator. Pro allows five linked accounts; the unscoped send route always resolves to the first active one, so avoid it in a multi-creator bot. Operating a creator roster is largely this plus per-creator configuration.
What should the bot do on a 424?
Stop and page a human. A 424 means the stored OnlyFans session is expired or invalid, the credits for that call are refunded, and every subsequent send fails identically until the account is re-linked. Retrying on a timer just produces a quiet outage with a full log.