OnlyFans Chatbot API
A chatbot needs three things from an API: a signal that a fan said something, enough history to answer sensibly, and a way to send the reply. Everything else — the model, the persona, the escalation rules — is yours. This page is the API surface that supports those three things, what each turn costs, and the two numbers (rate limit and credit weight) that decide how many conversations one bot can actually hold at once. For the code around it — the queue table, the worker, the dead-letter path — see building the bot end to end.
The surface a bot touches
A reactive bot uses four things. Nothing else on the API is required to run one.
| Purpose | Call | Cost |
|---|---|---|
| Wake up | message.received webhook delivery |
free |
| Read context | GET /accounts/{id}/chats/{chatId}/messages?limit=15 |
1 credit |
| Look up the fan | GET /accounts/{id}/users/{targetUserId} |
1 credit |
| Reply | POST /accounts/{id}/chats/{chatId}/messages |
2 credits |
The send is the only weighted call there: two credits account-scoped, one on the unscoped POST /chats/{chatId}/messages, which resolves the token's linked account itself and so is no use once a token drives more than one creator. Base URL is https://api.ofapis.com/api/public/v1, authenticated with a single Authorization: Bearer token. There is no request signing on the outbound side — only inbound webhook deliveries are signed. The account-scoped prefix means one token and one bot process can serve a whole roster: route on the account id, not on a separate credential per creator.
Verifying the delivery
Deliveries are signed Stripe-style. The X-Ofapis-Signature header is t=<unix seconds>,v1=<hex>, where the HMAC-SHA256 is computed over the string <t>.<raw body> — not over the body alone. Verifying the body alone rejects every genuine delivery, so this is the one piece of the integration worth copying exactly:
import hmac, hashlib, time
from flask import Flask, request, abort
app = Flask(__name__)
TOLERANCE = 300 # seconds; older deliveries are treated as replays
def verify(raw: bytes, header: str, secret: str) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, v1 = parts["t"], parts["v1"]
except (ValueError, KeyError):
return False
if abs(time.time() - int(ts)) > TOLERANCE:
return False
signed = f"{ts}.".encode() + raw # timestamp, dot, raw body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/hooks/ofapis")
def hook():
raw = request.get_data() # raw bytes, before JSON parsing
if not verify(raw, request.headers.get("X-Ofapis-Signature", ""), SECRET):
abort(401)
if not first_time(request.headers["X-Ofapis-Delivery"]):
return "", 200 # at-least-once: already handled
enqueue(request.get_json()) # model work happens off-thread
return "", 200
Three details that bite people. Verify the raw bytes — a framework that re-serialises the JSON changes whitespace and breaks the digest. Deduplicate on X-Ofapis-Delivery, because delivery is at-least-once and a retry reuses that id. And return 200 before you call a model: the delivery attempt has a 10-second read timeout, and a handler that blocks on generation will be retried while the first reply is still being written. The webhook setup guide covers registering the endpoint and rotating the secret.
What the payload already tells you
The message.received body carries the chat id and the message itself, so a fast bot can often answer without a context read at all:
{
"type": "message.received",
"occurred_at": 1753084800,
"data": {
"chatId": 98765,
"message": {
"id": 111222333,
"text": "hey are you online?",
"fromUser": { "id": 98765, "_view": "s" },
"toUser": { "id": 12345678, "_view": "s" },
"createdAt": "2025-06-01T10:05:00+00:00"
}
}
}
That is the entire body — no event id, and no ofapis account id either. chatId is the fan's OnlyFans user id, so on an inbound message it equals fromUser.id, while toUser.id is the creator: route a multi-account bot on toUser.id, and skip any delivery where fromUser.id is the creator, because skipping that check is how bots end up replying to themselves in a loop. There is also nothing in the body to deduplicate on, which is why the X-Ofapis-Delivery header is the only key for that. The user refs carry an id and an internal _view marker, no handle and no display name, so greeting a fan by name costs a GET /accounts/{id}/users/{targetUserId} lookup or your own cache. And message.received is the only event type there is, so a bot cannot react to reads, tips or new subscriptions — those need polling. The event reference spells out what is and is not delivered.
Context windows and the cursor
GET /chats/{chatId}/messages returns newest-first, limit defaults to 20 and is clamped to 1–100. Older pages come from the olderThanMessageId cursor: pass the id of the oldest message you already hold, not an offset. For a chat bot, one page is almost always enough: fifteen turns of DM-length text is a small prompt and costs one credit, while paging back a year of history costs a credit per page and buys context the model will mostly ignore.
A practical shape: cache the last 15 turns per chat in your own store, keyed by chatId, refreshed from the webhook payload as replies arrive. Then a turn is one send and zero reads, and the context fetch only happens when a thread has been cold long enough that your cache expired.
What a turn costs, and how fast it can run
A context read and a fan lookup are one credit each; an account-scoped send is two. Credits are debited before the call reaches OnlyFans, so which failures cost money is worth knowing exactly. Refunded automatically: 5xx, 424 (the OnlyFans session for that account has expired) and 409 (account not ACTIVE, or an idempotency conflict). Free, because the gateway rejects them before any debit: 401, 402, 403 and 429. Billed and not refunded: everything else — a 400 on a malformed body, a 404 on an unknown account, and the 422 you get when an account is not linked or its stored OnlyFans authorization no longer works. So retry on 5xx and 424; on a 4xx, fix the request instead, because a tight retry loop around a bad body spends credits at full price.
The harder ceiling is the rate limit, which is per token per minute: 60 on Free, 100 on Starter, 500 on Pro. Do the arithmetic before you promise instant replies across a roster. A naive turn of one context read plus one send is two calls, so Starter tops out at 50 replies a minute and Pro at 250 — across every creator on that token, shared. Cache the context and a turn is a single call, doubling both figures. Breach the ceiling and you get 429 RATE_LIMIT_EXCEEDED with a Retry-After in seconds; every response also carries X-RateLimit-Remaining and X-RateLimit-Reset, so a sender can pace itself instead of discovering the wall. The rate limit page has the backoff pattern.
Retrying a send without double-messaging
Model latency makes timeouts normal, and a timed-out POST is ambiguous: the message may have gone out. Any POST accepts an Idempotency-Key, scoped to your token and retained for 24 hours:
curl -X POST "https://api.ofapis.com/api/public/v1/accounts/42/chats/98765/messages" \
-H "Authorization: Bearer ofapis_sk_..." \
-H "Idempotency-Key: reply-111222333" \
-H "Content-Type: application/json" \
-d '{"text":"still here — what are you after?"}'
Key the value on something stable from the trigger, like the inbound message id, rather than a fresh UUID per attempt. A repeat with the same key replays the original response with Idempotency-Replayed: true and is not charged again; a second request sent while the first is still in flight gets 409 IDEMPOTENCY_CONFLICT, which means "wait and read the replay", not "send again". Error shapes for the rest are on the errors page.
Bringing your own model
ofapis is transport. The chat context goes to whatever you want to reason with — Claude, ChatGPT, a local model, or a plain rule table with no inference at all — and the reply comes back as a text field. Because the account runs through a managed session rather than a headless browser you drive yourself, your bot's latency budget is HTTP latency, not page-load latency; the no-scraping architecture explains what that session is doing. For broadcast rather than reactive messaging, mailings are a different mechanism entirely — automating DMs walks through that side.
FAQ
How much history should the bot read per turn?
Fifteen messages is usually the sweet spot: enough for the model to track what was promised and priced, cheap at one credit, and small enough that it does not dominate the prompt. Cache that window per chat and refresh it from webhook payloads so most turns need no read at all.
What happens if my handler is slow?
The delivery has a 10-second read timeout, and a timeout counts as a failed attempt that will be retried with backoff. Acknowledge with 200 immediately, do the model call in a background worker, and deduplicate on X-Ofapis-Delivery so a retry that arrives mid-generation does not produce a second reply.
How many fans can one bot answer per minute?
That depends on the plan's per-token rate limit — 60, 100 or 500 requests a minute — divided by the calls your turn makes. A read-plus-send turn on Starter means 50 replies a minute across all accounts on that token; drop the read by caching context and it becomes 100.
Can the bot react to tips or new subscribers?
Not through webhooks. message.received is the only event that exists, so tip and subscription reactions have to be polled from the notifications feed on a schedule you choose and pay a credit for each check.
Is a timed-out send safe to retry?
Yes, if you sent an Idempotency-Key. The replay returns the original response with Idempotency-Replayed: true and costs nothing extra, so the fan never sees the same line twice. Without the header, a blind retry is a genuine second send. Try the loop in the playground.