OnlyFans API Error Codes
OnlyFans API errors from ofapis are decided in a fixed order, and the HTTP status tells you almost everything: whether a retry can ever work, and whether the call just cost you a credit. This page is the full taxonomy — every status, what triggers it, which of the two error body shapes it arrives in, and what it costs. Not every failure is free — see what an error costs below.
Two error shapes, not one
Success is always {"data": ...}. Failure is not always {"error": ...}, and assuming it is will crash your client on the first validation error it meets.
Refusals decided in the gateway — 402, 403, 429, the idempotency 409, and the fail-closed 401 — carry a JSON envelope with a machine-readable code:
{ "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded. Retry after 12s." } }
Everything raised inside the handler — 400, 404, the 409 for an inactive account, 413, 415, 422, 424 and the vault's 502 — comes back as a bare string body with the right HTTP status and no envelope and no code:
HTTP/1.1 424 Failed Dependency
The OnlyFans session for account 42 is expired.
A 500 is a third shape again: {"error":{"code":"INTERNAL","requestId":"..."}}, with no message. And a 401 raised by the token filter is written by the servlet container, so it has whatever body that produces.
Branch on the HTTP status first and treat the body as a hint rather than a contract. The identifiers in the table below name the condition — they are what the reference calls each case, not a string you can count on parsing out of every response. One status can carry more than one condition: a 409 is either an idempotency conflict or an inactive account, the two want opposite reactions, and only the first arrives with a code to read.
Every response also echoes X-Request-Id. Send your own value in that request header and it comes back unchanged; omit it and the gateway generates a UUID. That id is written into the server-side log line for your call, so quoting it turns "a request failed yesterday" into a single log lookup.
Where each error is decided
The gateway evaluates a public API request in a fixed order, and the first check that fails wins. That order explains most surprising precedence:
- Token — a missing
Authorizationheader, a value that doesn't startofapis_sk_, or a revoked token stops here with401. Nothing downstream runs. See authentication. - Idempotency — on a
POSTcarryingIdempotency-Key, a completed record replays the stored response withIdempotency-Replayed: true, and an in-flight one returns409 IDEMPOTENCY_CONFLICT. - Rate limit — the per-token per-minute counter. Over the ceiling,
429withRetry-After. Because this runs before the billing checks, you can be rate-limited while your credit balance is zero. - Subscription — no subscription, or one that isn't
ACTIVE, returns403 NO_ACTIVE_SUBSCRIPTION. - Credits — the endpoint's weight is debited here. Too small a balance returns
402, and nothing is spent. - Handler — everything else. The request is already paid for by the time your payload is parsed, which is why some
4xxresponses are billable.
Every code
| Status | Condition | What triggers it | Retry? | Credits |
|---|---|---|---|---|
| 400 | BAD_REQUEST |
Blank list name, empty userIds, a scheduledAt in the past, an unparseable date, a missing required query param |
No — fix the payload | Charged |
| 401 | UNAUTHENTICATED |
Header missing, token not prefixed ofapis_sk_, token unknown or revoked |
No | Free |
| 402 | INSUFFICIENT_CREDITS |
Balance below the endpoint's weight | After a top-up | Free |
| 403 | NO_ACTIVE_SUBSCRIPTION |
No plan, or a subscription that is canceled or past due | After subscribing | Free |
| 404 | ACCOUNT_NOT_FOUND |
The accountId, mailing or run id isn't yours |
No | Charged |
| 409 | IDEMPOTENCY_CONFLICT |
The same Idempotency-Key is already in flight on this token |
Yes, after a pause | Free |
| 409 | ACCOUNT_NOT_ACTIVE |
The account is PENDING or DISCONNECTED |
After reconnecting | Refunded |
| 413 | MEDIA_FILE_TOO_LARGE |
Upload above 52,428,800 bytes (50 MB) | No — shrink the file | Charged |
| 415 | MEDIA_TYPE_NOT_SUPPORTED |
Anything but image/jpeg, image/png, image/webp, video/mp4 |
No | Charged |
| 422 | OF_AUTH_INVALID |
The stored OnlyFans session failed validation | After re-linking | Charged |
| 422 | ACCOUNT_NOT_LINKED |
No linked account behind this token | After linking | Charged |
| 424 | OF_SESSION_EXPIRED |
The account is in ERROR state, or OnlyFans rejected the stored session upstream |
After re-linking | Refunded |
| 429 | RATE_LIMIT_EXCEEDED |
Over the per-minute ceiling for this token | Yes, after Retry-After |
Free |
| 5xx | — | Unhandled server error (500), or a vault upload that failed upstream (502) |
Yes | Refunded |
What an error costs
Credits are debited before the request reaches the handler, and whether you get them back depends on how far it got.
Free. 401, 402, 403 and 429 are refused at the gate, so nothing is ever debited. 409, 424 and any 5xx are debited and then refunded automatically — which is the important one for retry logic, because an expired session or a transient upstream failure is exactly what you retry, and doing so costs nothing.
Charged. 400, 404, 413, 415 and 422 are not refunded. The request was authenticated, inside your rate limit and paid for; it ran, and the input was what was wrong. So validate payloads locally against the request schemas in the OpenAPI reference rather than discovering the shape by probing the API, and never retry one of these unchanged — it will fail identically and bill again.
What it costs is the endpoint's weight, not a flat credit. A 413 on media upload burns the full five credits that endpoint is weighted at, because the size check happens inside the handler, after metering. Weights per route are listed on the cost page.
The handler to write
Four buckets cover every row in the table: retry as-is, pause and retry the same key, re-link the account, or stop and fix the call.
import time, requests
RETRYABLE = {429, 500, 502, 503, 504}
def call(method, url, attempts=5, **kw):
for n in range(attempts):
r = requests.request(method, url, headers=H, **kw)
if r.ok:
return r.json()["data"]
# Only gateway refusals carry the JSON envelope; handler errors are
# a bare string, so never assume r.json() will parse.
try:
err = r.json()["error"]
except Exception:
err = {"code": str(r.status_code), "message": r.text[:200]}
if r.status_code in RETRYABLE:
time.sleep(float(r.headers.get("Retry-After", 2 ** n)))
continue
if r.status_code == 409 and err["code"] == "IDEMPOTENCY_CONFLICT":
time.sleep(1.0)
continue
if r.status_code == 424:
raise NeedsRelink(err["message"], r.headers["X-Request-Id"])
raise ApiError(r.status_code, err["code"], err["message"])
raise ApiError(r.status_code, "RETRIES_EXHAUSTED", url)
NeedsRelink is not a retry. A 424 means the creator's session is dead server-side and no backoff revives it — raise a "reconnect this account" task and stop scheduling work against that account until it clears. On a roster that means quarantining one creator while the others keep running, which is the pattern agency operations is built around. Anything reaching ApiError is a caller bug: log the code, the X-Request-Id and the body, then fix it rather than loop.
The same four buckets, written idiomatically for other stacks: typed API errors and context deadlines in the Go quickstart, a Guzzle stack that honours Retry-After in the PHP guide, and the undici call with a hard timeout in the Node walkthrough.
Retrying safely
Any POST accepts an Idempotency-Key header, and the record is scoped to (token, key) with a 24-hour lifetime. Reuse the same key for a retry and you get the original response back verbatim with Idempotency-Replayed: true, charged once. This matters most for mass DMs and message sends, where a timeout leaves you genuinely unsure whether the first attempt landed — and most of all for a greeting fired at a new subscriber, where the duplicate is the first thing that fan ever hears from the creator.
The gateway discards the in-progress record whenever it refunds or rejects — on 409, 424, 5xx, a rate-limit block or a credit failure — so a key that failed is reusable immediately rather than poisoned for 24 hours. A key that succeeded is not: reusing it replays instead of re-sending.
When you get a 5xx
A 500 body carries {"error":{"code":"INTERNAL","requestId":"..."}} and no detail, by design — stack traces and SQL fragments never cross the wire, so the requestId is the only handle you get. A 502 is narrower: the vault accepted the request but the upstream upload failed, and one that repeats on the same file usually means the file, not the service. Both refund the weight. Pacing belongs on the rate limits page; per-endpoint shapes are in the API reference.
FAQ
Do failed API calls cost credits?
Most of them don't. A 401, 402, 403 or 429 never reaches metering, and a 409, 424 or 5xx is refunded automatically — so the failures you actually retry are free. A 400, 404, 413, 415 or 422 is charged, because the request ran and the payload was the problem.
What does a 424 error mean?
The token is valid but the stored OnlyFans session is dead (OF_SESSION_EXPIRED). The credit is refunded, and the fix is re-linking the account rather than retrying. Until that happens, every call against the account returns the same 424 without touching OnlyFans.
One caveat worth knowing: the OpenAPI document still annotates ACCOUNT_NOT_LINKED as 424, while the exception handler that actually runs returns 422. Trust the status you observe over the one in the spec, and treat that case as charged rather than refunded.
Why did a 400 still charge me a credit?
Because metering happens before the handler parses your body. The gateway debits the endpoint's weight, then runs the request; a validation failure inside the handler means the work was attempted and the weight stays spent. Only 409, 424 and 5xx trigger an automatic refund.
Which errors are safe to retry automatically?
429 after waiting out Retry-After, any 5xx, and a 409 IDEMPOTENCY_CONFLICT after a short pause. Everything else either needs a human (424, 403, 402) or a code change (400, 404, 413, 415, 422). Retrying those unchanged reproduces the error and bills again.
What is X-Request-Id for?
It correlates one call with its server-side log line. Set it yourself on the request and it is echoed back untouched; leave it off and the gateway mints a UUID and returns that. A 500 puts the same value in the response body, which is the only detail you get on an internal error.