ofapisv1
solution

Updated

How to Build an OnlyFans Chatbot

This is the build, not the pitch. If you are the developer who has to ship a DM bot for a creator or a roster, this is the file layout you end up with, the tables you have to keep, the worker that turns a delivery into a reply, and the five things that break once it has been running for a fortnight. ofapis is transport only: both processes below are yours to host, and nothing here runs without them. The API surface — endpoints, signature format, per-turn cost — is on the chatbot API page; assume it here.

Project layout

A working bot is two processes sharing one database. Keeping them separate is the whole design: the web process must answer a delivery in milliseconds, and the worker is where slow things are allowed to happen.

bot/
  web.py          # webhook receiver: verify, dedupe, insert, 200
  worker.py       # claims rows, builds the prompt, sends the reply
  store.py        # SQL, nothing clever
  policy.py       # escalation + guard rails (no model calls)
  prompts/
    persona.md    # per-creator voice, loaded by account_id
  migrations/
    001_init.sql

policy.py holding no model calls matters more than it sounds. Every rule you can answer without inference — do not reply to yourself, do not reply twice within 20 seconds, hand off anything that mentions a refund — belongs there, where it is testable and free. A bot made of nothing but those rules is a rules-only auto reply bot, and shipping that first tells you which questions are actually worth paying inference for.

The state you must keep

A chatbot that keeps no state re-reads history on every turn and still gets the ordering wrong. Three tables are enough:

CREATE TABLE delivery (            -- dedupe for at-least-once webhooks
  delivery_id TEXT PRIMARY KEY,    -- X-Ofapis-Delivery
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE turn (                -- the work queue
  id           BIGSERIAL PRIMARY KEY,
  account_id   BIGINT NOT NULL,
  chat_id      BIGINT NOT NULL,
  inbound_id   BIGINT NOT NULL UNIQUE,   -- OnlyFans message id
  state        TEXT NOT NULL DEFAULT 'pending',  -- pending|sent|handoff|failed
  claimed_at   TIMESTAMPTZ
);

CREATE TABLE message (             -- local context cache, newest-first per chat
  account_id BIGINT NOT NULL,
  chat_id    BIGINT NOT NULL,
  message_id BIGINT NOT NULL,
  author     TEXT   NOT NULL,      -- 'fan' | 'creator'
  text       TEXT   NOT NULL,
  created_at TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (account_id, chat_id, message_id)
);

inbound_id UNIQUE is a second line of defence behind the delivery-id check: if the dedupe table is ever cleared, the queue still refuses to process the same fan message twice. message exists so the common turn makes no read call at all — you already saw the inbound text in the payload, and you wrote your own reply when you sent it.

The worker

import os, requests

BASE = "https://api.ofapis.com/api/public/v1"
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['OFAPIS_TOKEN']}"

def handle(turn):
    history = store.recent(turn.account_id, turn.chat_id, limit=15)
    if not history:                                   # cold thread: one read
        r = SESSION.get(f"{BASE}/accounts/{turn.account_id}"
                        f"/chats/{turn.chat_id}/messages", params={"limit": 15})
        r.raise_for_status()
        history = store.ingest(turn, r.json()["data"]["list"])

    verdict = policy.check(history)
    if verdict.handoff:
        return store.mark(turn, "handoff", reason=verdict.reason)

    text = model.reply(persona(turn.account_id), history)   # your LLM call
    resp = SESSION.post(
        f"{BASE}/accounts/{turn.account_id}/chats/{turn.chat_id}/messages",
        json={"text": text},
        headers={"Idempotency-Key": f"turn-{turn.inbound_id}"},
        timeout=20,
    )
    if resp.status_code == 429:
        return store.requeue(turn, after=int(resp.headers.get("Retry-After", 5)))
    if resp.status_code == 422:            # session dead; credit already spent, do not retry
        return store.halt_account(turn, resp.text)
    resp.raise_for_status()
    store.append_own(turn, text, resp.json()["data"]["id"])
    store.mark(turn, "sent")

Three things to copy. The Idempotency-Key is derived from the inbound message id, so a worker that dies after sending but before committing replays the original response on restart instead of sending a second line. The 429 branch requeues rather than raising — throughput is capped per token per minute at 60, 100 or 500 depending on plan, and on a busy roster you will touch that ceiling long before you touch anything else. And 422 halts the account instead of requeuing, because that credit is debited and not refunded: a retry loop on a dead session is a bill, not a recovery.

Handing the conversation to a model

The prompt is three blocks: the persona file for that account_id, the last fifteen turns in chronological order with author labels, and a short instruction block carrying what the model is not allowed to do — invent prices, promise content, claim to be human if asked directly. Keep the persona per creator rather than per bot, because one token can address a whole roster and the same prompt across five creators is how a roster starts sounding like one person.

What the model returns is a draft, not a send. Run it back through policy.py: length cap, banned-phrase list, and a check that any price it mentions exists in your own catalogue. Many teams ship this as draft-and-approve first — the worker writes the reply into the queue with state='handoff' and a human presses send — then flip to auto-send per creator once the approval rate is boringly high. The send path is identical either way, so that switch is one boolean, and the same messaging call backs both.

Tag as you go. When a thread reveals something durable — a spender, a fetish, a timezone — write the fan into a segment with POST /accounts/{id}/lists/{listId}/users and a body of {"userIds":[78910]}. Those are the same lists a later campaign targets, so the bot's reading quietly builds the audiences your broadcasts use. Nothing in the API records what a fan has spent, so the per-fan store you keep yourself is the only place a word like "spender" means anything at all.

Deployment

The web process needs a stable public HTTPS URL and almost no resources; the worker needs outbound network and a concurrency limit you set deliberately rather than inherit. Start with one worker per token. Registering the endpoint and storing the signing secret is covered in the webhook setup guide — the secret is shown once, so put it in your secret store at creation time, not later.

Give the worker a dead-letter path: a turn that fails three times goes to state='failed' with the response body attached. You will read that column more than any log.

What breaks in week two

The endpoint gets auto-disabled. Failed deliveries retry at 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then stop; after 10 consecutive failures the endpoint is disabled entirely. A deploy that 502s for twenty minutes is survivable, one that 502s overnight is not — alert on your own 2xx rate, not on missing messages.

The session expires. A creator changes their password or hits a re-auth prompt, and scoped calls for that account start failing in one of two shapes with two different bills. A session the gateway knows has expired returns 424 and the credit is refunded. A stored authorization that OnlyFans has stopped accepting — and an account that was never linked in the first place — returns 422, and that credit is spent. Every turn for the account fails until it is re-linked either way, so the first 422 is a page, not a retry: a worker that keeps retrying a dead session pays two credits for each attempt.

A duplicate deploy doubles replies. Two worker replicas with no row-level claim will both pick up the same pending turn. SELECT … FOR UPDATE SKIP LOCKED on the claim, plus the idempotency key, makes the race harmless.

Your error parser assumes JSON. Most error bodies are not. Only five failures carry the {"error":{"code","message"}} envelope, and all five are rejected by the gateway before your call runs — 401 UNAUTHENTICATED, 402 INSUFFICIENT_CREDITS, 403 NO_ACTIVE_SUBSCRIPTION, 429 RATE_LIMIT_EXCEEDED, 409 IDEMPOTENCY_CONFLICT. Anything raised past that point — 400, 404, 409 on a non-ACTIVE account, 422, 424, 500 — comes back as a bare text message with no code field at all. Branch on the HTTP status and store the body as text; resp.json()["error"]["code"] throws inside your own error handler and hides the real cause. The errors page lists which codes use which shape.

Credit burn is not where you expected. An account-scoped reply is two credits, not one, so a turn that also re-reads context is three — a cold-start week where every thread misses the cache costs half again what the steady state does. Failed turns count too: only 5xx, 424 and 409 are refunded. And a 5,000-fan mailing fired from the same token debits nothing on the trigger call, then one credit per recipient as it fans out. Check the monthly line on pricing against your actual turn count after a fortnight, not before.

FAQ

How do I stop two workers replying to the same message?

Claim the queue row with SELECT … FOR UPDATE SKIP LOCKED before doing any work, and send with an Idempotency-Key derived from the inbound message id. The claim prevents the duplicate turn; the key makes a mid-send crash replay the original response instead of sending twice.

Should the first version send automatically?

Usually not. Run it as draft-and-approve for a week: the worker writes the reply and a human presses send, which gives you a labelled set of accepted and rejected drafts to tune the persona against. Flipping to auto-send is one flag once the rejection rate stops teaching you anything.

Where should the persona live?

In a file per creator, loaded by account_id, not in the worker code. One token can drive a whole roster, and a shared prompt makes every creator sound the same — which fans notice faster than they notice a bot.

What should the bot refuse to handle?

Anything involving money it cannot verify, custom-content commitments, and any direct question about whether it is a person. Route those to state='handoff' and let a human answer; a wrong price quoted by a bot is a refund, and a refund is more expensive than a slow reply.

Can I build this without Python?

Yes — it is plain REST over HTTPS with one bearer token, so the only language-specific parts are the HMAC verification and your HTTP client. The Node.js webhook receiver and the Python sync job are the same two halves in other runtimes; build it in whatever you deploy comfortably, and test the loop against the playground before wiring a model in.

Related guides

All guides
Start free — 250 credits, no card
Generate a token and make your first call in minutes.
Get started