OnlyFans API Best Practices
Everything below is about the unhappy path, because that is the part of an integration that costs money while you sleep: what a failure costs, what is safe to retry, what an idempotency key actually promises, and how to keep a month's credits from evaporating in a loop.
Failures are not free, and which ones are depends on where they happen
Credits are debited before the handler runs. That single implementation detail splits every error into two classes.
Requests are evaluated in a fixed order: idempotency, then the rate limiter, then subscription status, then the credit debit, then your actual call. Anything rejected earlier in that chain never reaches the meter. Anything that fails later has already been paid for, and is refunded only for the statuses that say the fault was not yours.
| Status | Code | Charged? | Retry? |
|---|---|---|---|
| 400 | BAD_REQUEST |
yes | no — fix the request |
| 401 | UNAUTHENTICATED |
no | no |
| 402 | INSUFFICIENT_CREDITS |
no | no — top up |
| 403 | NO_ACTIVE_SUBSCRIPTION |
no | no |
| 404 | ACCOUNT_NOT_FOUND |
yes | no |
| 409 | IDEMPOTENCY_CONFLICT |
no net charge | yes, after a short pause |
| 413 | MEDIA_FILE_TOO_LARGE |
yes | no |
| 415 | MEDIA_TYPE_NOT_SUPPORTED |
yes | no |
| 409 | ACCOUNT_NOT_ACTIVE |
debited, then refunded | not until the link is fixed |
| 422 | OF_AUTH_INVALID / ACCOUNT_NOT_LINKED |
yes | not until re-linked |
| 424 | OF_SESSION_EXPIRED |
debited, then refunded | not until re-linked |
| 429 | RATE_LIMIT_EXCEEDED |
no | yes, after Retry-After |
| 5xx | — | debited, then refunded | yes, with backoff |
400, 404, 413, 415 and 422 all take your credits and keep them, and the consequences are concrete. Validating a timestamp or a file size locally is a billing decision, not a style preference — a 60 MB upload rejected with 413 still cost 5 credits. A wrong accountId baked into a cron job produces 404s forever at 1 credit each, with no 429 and no alarm to notice it by. And because 400 is charged, a retry loop around a malformed body is the most expensive bug you can ship. Never write "failed calls aren't billed" into a runbook; the full list is in the error reference.
Retry only 429 and 5xx, and let the response tell you when
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset as a unix timestamp. A 429 adds Retry-After in seconds. Use them instead of a blind doubling — the server already knows when the window rolls over, and an exponential curve that overshoots just idles your worker.
import random, time, requests
RETRYABLE = {429, 500, 502, 503, 504}
def call(method, url, **kw):
for attempt in range(5):
r = requests.request(method, url, **kw)
if r.status_code not in RETRYABLE:
return r # includes every charged 4xx — do not loop
if r.status_code == 429:
wait = int(r.headers.get("Retry-After", 1))
else:
wait = min(2 ** attempt * 0.2, 10) + random.random() * 0.2
time.sleep(wait)
raise RuntimeError(f"gave up after 5 attempts, request_id={r.headers.get('X-Request-Id')}")
def should_slow_down(r, floor=5):
return int(r.headers.get("X-RateLimit-Remaining", floor + 1)) <= floor
The ceiling is per token per minute: 60 on Free, 100 on Starter, 500 on Pro — see rate limits and pricing. Because the bucket is keyed on the token rather than the account, a nightly enumeration job and a live webhook responder on separate tokens do not starve each other, which is usually a better fix than tuning retry curves. If you are writing this in something other than Python, the Go and Node.js quickstarts carry the equivalent client with typed errors and request deadlines already wired in.
What an idempotency key actually promises
Any POST accepts an Idempotency-Key header, scoped to your token, method and path. The guarantees are narrow and worth knowing exactly:
- A replay — same key, after the first request finished — returns the original status and the original response body, with
Idempotency-Replayed: true, and is not charged a second time. - A concurrent duplicate — same key, while the first is still in flight — gets
409 IDEMPOTENCY_CONFLICT. Pause briefly and retry; the first one is probably about to finish. - A request that ended in a refunded status (
424,5xx) does not leave a reusable record. Retrying with the same key runs the call again, which is the behaviour you want: you do not want a transient session failure pinned to that key forever.
Pick keys that are stable across process restarts and derived from the work, not the attempt: a calendar row id for a scheduled post, a campaign id for a mailing trigger, the X-Ofapis-Delivery value for a webhook-driven reply. A UUID generated at call time is not an idempotency key, it is a request id with extra steps.
Dedupe webhooks on the delivery header
Webhook delivery is at-least-once, with retries at 1m, 5m, 30m, 2h and 6h across six attempts. The message.received payload contains no event id — only type, occurred_at and data — so the only value you can safely key on is the X-Ofapis-Delivery header. Store it with a unique constraint and drop repeats. Verify the HMAC signature on raw bytes before any of that; the mechanics are worked through in building a bot and in webhook events explained.
Log X-Request-Id on every call
X-Request-Id is echoed back on responses and is the same identifier the gateway writes into its own request log and into the credit ledger entry for that call. Capturing it turns "a send failed last Tuesday and I think I was double-charged" into a one-line lookup. Log it on failures at minimum, together with the status and the route:
level=warn route=POST /accounts/42/chats/98765/messages status=422 request_id=9f1c... credits=2
For inbound webhooks the same header carries the delivery id, so a single value correlates your handler log with the delivery record.
Budget credits from the weight table, not from a call count
Weights are not flat. A call is 1 credit by default, but:
POST /accounts/{id}/media— 5POST /accounts/{id}/scheduled/postsand/scheduled/messages— 3POST /accounts/{id}/chats/{chatId}/messages— 2POST /accounts/{id}/mailings/{id}/trigger— 0 at the call, thenrecipientCount × perRecipientWeightdebited inside it
Then there is pagination, which is where most waste hides. List endpoints take limit (default 20, clamped 1–100) and offset (default 0); /lists and /lists/{listId}/users default to 10 instead; message history pages backwards with the olderThanMessageId cursor. Each page is a billed call, so enumerating 2,000 subscribers at the default costs 100 credits and at limit=100 costs 20 — the subscriber sync guide builds the resumable version of that walk. That is a five-fold difference from one query parameter, repeated on every sync. Cache anything slow-moving — profile data, list membership — rather than re-reading it per event, and prefer a webhook over a poll for anything event-shaped. Credit costs has the per-plan arithmetic.
Keep secrets and scopes tight
The ofapis_sk_... token and each webhook signing secret are credentials: environment variables or a secrets manager, never a browser bundle or a commit. Rotate on a schedule and immediately on suspicion — revoking in the dashboard is instant. Address work through the scoped /accounts/{accountId}/... routes rather than the unscoped legacy paths, which resolve to your first active account; on a multi-creator setup that is the gap between a bug and a message sent as the wrong creator, which is why agency setups scope every route. The authentication model covers how tokens and account links relate. That is the production checklist; if you are starting this sequence rather than finishing it, it begins at linking a creator account.
FAQ
Which failed calls still cost me credits?
400, 404, 413, 415 and 422 are debited and not refunded, because the credit is taken before the handler runs. 401, 402, 403 and 429 are refused before metering and cost nothing, and 424 and 5xx are debited then refunded. Treat local validation as a cost control, not a nicety.
How should I back off from a 429?
Read Retry-After from the response and sleep that long — the server knows when the window resets and the value is exact. Between calls, watch X-RateLimit-Remaining and shed optional work before you hit zero. The ceiling is per token per minute: 60 on Free, 100 on Starter, 500 on Pro.
If I replay a request with the same Idempotency-Key, what comes back?
The original status code and the original response body, plus an Idempotency-Replayed: true header, and no second charge. If the first request is still in flight you get 409 IDEMPOTENCY_CONFLICT instead — wait a moment and retry the same key.
Why is my credit usage higher than my call count?
Because weights vary. A media upload is 5 credits, a scheduled post or message is 3, an account-scoped DM send is 2 — walked through send by send here — and a mailing trigger bills one credit per recipient inside the call. Paginated reads also multiply: every page is a separate billed call.
What single value should I log for support?
X-Request-Id. It is echoed on every response and is the identifier the gateway records against both the request and the credit ledger entry, so quoting it resolves a billing or delivery question without reconstructing timestamps.