ofapisv1
guide

Updated

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:

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:

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.

Related guides

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