OnlyFans Multi Account Management
Running many creators from one codebase is a multi-tenancy problem wearing a creator-economy costume. You have one credential, one rate budget and one credit pool, and N tenants who must never see each other's data or starve each other of throughput. This page covers the four mechanics that decide whether that works: addressing, throughput, attribution, and lifecycle. The business those mechanics sit under — the roles, the chatter handoff, the per-creator margin question — is sketched as the five-layer agency stack.
accountId is the tenant key — and the legacy routes are a trap
Every resource exists in an account-scoped form:
GET /api/public/v1/accounts/{accountId}/chats
GET /api/public/v1/accounts/{accountId}/chats/{chatId}/messages
POST /api/public/v1/accounts/{accountId}/chats/{chatId}/messages
GET /api/public/v1/accounts/{accountId}/subscribers?type=active&limit=100
GET /api/public/v1/accounts/{accountId}/lists
GET /api/public/v1/accounts/{accountId}/notifications/count
POST /api/public/v1/accounts/{accountId}/scheduled/messages
The unscoped forms — /chats, /subscribers, /me — still work, and that is precisely the hazard. They are back-compatibility shims that resolve to the first active account on the token. On a single-creator key they behave as expected. The moment a second creator is linked, every unscoped call silently starts answering about whichever account sorts first, and the bug does not announce itself: you get a 200, a plausible inbox, and messages sent from the wrong creator.
Treat this as a lint rule. Ban the unscoped paths in your HTTP client, make accountId a required argument on every wrapper function, and let the type system refuse a call without a tenant. If you pass an id the token does not own, you get a 404 rather than someone else's data — the isolation holds server-side, but only the scoped form expresses intent. Every route has a scoped variant; the full list is in the API documentation.
class Creator:
def __init__(self, session, account_id):
self.s, self.a = session, account_id # no default, ever
def path(self, suffix):
return f"https://api.ofapis.com/api/public/v1/accounts/{self.a}{suffix}"
def chats(self, limit=100, offset=0):
r = self.s.get(self.path("/chats"), params={"limit": limit, "offset": offset})
r.raise_for_status()
return r.json()["data"] # {"list": [...], "hasMore": …, "nextOffset": …}
One token, one rate budget, N creators
The rate limit is counted per token per minute, not per account: 60 requests a minute on Free, 100 on Starter, 500 on Pro. A roster shares that ceiling, so the fifth creator's sync job competes with the first's. Worse, a naive fan-out — loop the roster, fire everything — spends the whole budget on whichever creator the loop reaches first.
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (a unix timestamp), and a 429 adds Retry-After in seconds. The window is a fixed sixty seconds that starts when the counter resets, not a smooth drip, so burning the remaining allowance in the first two seconds leaves you blocked for the rest of the minute. Read X-RateLimit-Reset rather than guessing.
The pattern that holds is a per-account share of a global budget:
import threading, time
class RosterBudget:
"""Splits one token's rpm ceiling into per-account shares."""
def __init__(self, rpm, account_ids):
self.share = max(1, rpm // max(1, len(account_ids)))
self.window, self.used = time.time(), {a: 0 for a in account_ids}
self.lock = threading.Lock()
def take(self, account_id):
with self.lock:
if time.time() - self.window >= 60:
self.window, self.used = time.time(), {a: 0 for a in self.used}
if self.used[account_id] >= self.share:
return False # this creator is done for the minute
self.used[account_id] += 1
return True
budget = RosterBudget(rpm=500, account_ids=[11, 22, 33, 44, 55]) # 100 calls/min each
A hundred calls a minute per creator sounds generous until a backfill starts. Give background jobs a lower share than interactive ones — a chatter waiting on an inbox refresh should never be queued behind the nightly reconciliation job. Full backoff guidance is on rate limits.
Credits are a shared pool — attribution is your job
Billing is per token, not per account, and the price is the call's own weight regardless of which creator it targeted: one credit for most routes, two for an account-scoped send, three for a scheduled create, five for a media upload. The gateway debits before dispatching and refunds automatically on 5xx, on 424 (the creator's OnlyFans session expired) and on 409; rejections at the gate — 401, 402, 403, 429 — never reach the meter. Everything else is charged and stays charged: a 400 from a malformed body, a 404 for an id the token does not own, a 422 for an account that is not linked or whose stored credentials no longer work. A roster multiplies the cost of your own bugs, not just your traffic. Branch on the HTTP status rather than on a code field — only those four gate rejections and the idempotency 409 IDEMPOTENCY_CONFLICT carry the {"error":{"code","message"}} envelope, everything raised deeper returns a bare text message — and keep API errors open as the taxonomy to classify your log against.
Nothing in that tells you which creator spent what. The API has no per-account usage breakdown, so if you want per-creator cost — and at renewal time you will — you have to log it yourself. Record account_id, endpoint, status and the X-Request-Id the API echoes back on every call. That log is also the only way to find the job that quietly tripled its interval, and it is what feeds a per-creator margin view in your CRM.
Mass DMs are the exception to per-call billing: the trigger call itself is weighted zero and the run then debits one credit per recipient, so a 3,000-fan mailing is one HTTP call and 3,000 credits. Five creators each sending one such blast is 15,000 credits — half a Starter month — so give campaigns their own per-creator budget line instead of letting them draw on the general pool. Retries are not free either: re-triggering a run that already completed recounts recipients, debits again and sends again, because the 409 guard only covers a run still in progress.
Webhook fan-in: one endpoint, many creators
Webhooks are registered in the dashboard against your account, not per creator, so a single endpoint receives events for the whole roster. The delivery carries X-Ofapis-Event, X-Ofapis-Delivery and X-Ofapis-Signature, and the body looks like this:
{
"type": "message.received",
"occurred_at": 1771200000,
"data": {
"chatId": 98765,
"message": { "id": 111222333, "text": "hey!",
"fromUser": { "id": 98765 }, "toUser": { "id": 12345678 },
"createdAt": "2026-03-04T19:00:00+00:00" }
}
}
Note what is not in it: the payload has no accountId field. Route on data.message.toUser.id — the recipient of an inbound DM is the creator — against a map of OnlyFans user id to your account_id, built once per creator at onboarding from GET /accounts/{id}/me. Do not infer the creator from anything else; a guess here cross-wires tenants.
Verify before you route. The signature is Stripe-style — t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> — computed over the raw body, so capture bytes before any JSON middleware touches them:
import crypto from "node:crypto";
export function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
return ok && Math.abs(Date.now() / 1000 - Number(parts.t)) < toleranceSec;
}
Delivery is at-least-once with retries at one minute, five minutes, thirty minutes, two hours and six hours, so deduplicate on X-Ofapis-Delivery before you act. Setup details are in webhook setup, and the whole receiver — raw-body capture, dedupe store, fast ack — is built out in the Node.js walkthrough.
Onboarding and offboarding a creator
Onboarding is five steps, and skipping the fourth is what causes mystery bugs a week later. Link the account and record its accountId; call GET /accounts/{id}/me and store the OnlyFans user id for webhook routing; seed your roster row with status and owner; add the account to the budget map so it gets a throughput share rather than inheriting someone else's; then backfill chats and subscribers once, at a deliberately low share, before enabling live jobs. Plan limits apply at link time — Free and Starter cover one account, Pro five — so a sixth link fails with a 403 and a plain-text limit message rather than silently queueing.
Offboarding is the step nobody writes. Stop the creator's jobs first, then cancel scheduled posts and messages you queued on their behalf, then unlink. If you unlink first, in-flight mailing runs pause instead of stopping and queued posts stay on the creator's calendar with nobody watching them. Finally, decide what you do with their message history in your store — you have a copy of a third party's conversations, and your contract should say how long you keep it.
What is shared and what is not
| Shared across the roster | Per creator |
|---|---|
| API token and credit pool | OnlyFans session, stored encrypted |
| Rate-limit budget (per token per minute) | Residential proxy, shared or dedicated by plan |
| Webhook endpoint and signing secret | accountId scope on every route |
| Your own panel and staff accounts | Lists, mailings, scheduled queue |
Proxy allocation is tiered rather than universal: Free draws on a shared pool, Starter gives a dedicated proxy per account, and Pro gives a dedicated proxy per creator. That distinction matters most when you move a roster onto a single plan — see agency software for the questions to ask before you do.
If you are still deciding, the cheapest test is one creator on Free: link an account, point a webhook at a tunnel and confirm that data.message.toUser.id resolves to the roster row you expect. Multi-tenant routing bugs are free to find at one creator and expensive to find at five.
FAQ
How do I address a specific creator on every call?
Use the account-scoped path, /accounts/{accountId}/…, on every request and never the unscoped form. The unscoped routes exist only for back-compatibility and resolve to the first active account on your token, which is a silent correctness bug the moment you link a second creator.
Does every creator get their own rate limit?
No. The rate limit is counted per token per minute — 60 on Free, 100 on Starter, 500 on Pro — and the whole roster draws on it. Split it yourself into per-account shares so one creator's backfill cannot starve another creator's inbox refresh.
How do I know which creator a webhook event belongs to?
The payload does not include an accountId, so map data.message.toUser.id — the creator who received the DM — to your internal account id, using the OnlyFans user id you captured from GET /accounts/{id}/me at onboarding. Verify the signature and deduplicate on X-Ofapis-Delivery before you route.
Can I see how many credits each creator used?
Not from the API — billing is per token and the credit pool is shared. Log accountId, endpoint, status and the echoed X-Request-Id on every call and aggregate it yourself; that log is the only source of per-creator cost, and it is what you need at contract renewal.
What happens when I try to link more creators than my plan allows?
The link is rejected — a 403 with a plain-text account-limit message — rather than partially succeeding. Free and Starter cover one linked account and Pro covers five. There is no self-serve tier above five: Enterprise is quote-based and not enabled by default, so a larger roster is arranged with us rather than bought from the pricing page. Check the ceiling on pricing before onboarding anyway, because the credit allowance and the account ceiling are separate limits and most rosters hit credits first.