OnlyFans API Idempotency and Safe Retries
A retried GET costs a credit. A retried send is a second DM arriving on a real person's phone, and a retried blast is thousands of them plus the credits to match. That asymmetry is the whole reason to care about idempotency here, and it is why every POST in the ofapis public API accepts an Idempotency-Key header.
The check runs early — before the per-minute counter, before the subscription test, before the credit debit. A replay therefore costs nothing and does not consume rate-limit headroom either, which makes an aggressive retry policy cheap as long as the keys are right. Getting the keys right is the hard part, and one rule below will bite you if you skip it.
The key is scoped to your token, not to the endpoint
The uniqueness constraint is (tokenId, idempotencyKey). The HTTP method and the request path are both written into the record, and neither is compared on lookup. Reuse one key across two different endpoints and the second call returns the first endpoint's response.
POST /api/public/v1/chats/9183/messages
Idempotency-Key: fan-9183
→ 200 {"data":{"id":55120361,"text":"hey!"}}
POST /api/public/v1/accounts/7/mailings/1f0c.../trigger
Idempotency-Key: fan-9183
→ 200 {"data":{"id":55120361,"text":"hey!"}}
Idempotency-Replayed: true
The second call never reaches the mailing code. No run exists, no credits move, and the status is 200 with a plausible-looking data object, so nothing in a naive client complains. The only tell is the Idempotency-Replayed header. Namespace every key with the operation that owns it (send:, trigger:, upload:) and this failure mode disappears permanently.
Two smaller edges from the same code path. The header is read only on POST, so putting it on a DELETE does nothing at all. And the key column is 255 characters wide; a longer value fails the insert, which the gateway reads as a concurrent duplicate and answers 409 forever.
The three outcomes of a keyed POST
| Record state | Response | Charged | Counts against rpm |
|---|---|---|---|
| None — fresh insert | The real handler runs | Yes, at the endpoint's weight | Yes |
COMPLETED |
Stored status and body, plus Idempotency-Replayed: true |
No | No |
IN_PROGRESS |
409 IDEMPOTENCY_CONFLICT |
No — refused before metering | No |
A replay returns the stored status, not a 200. Any response the gateway does not refund is finalised into the record, and the charged failures are exactly the ones that do not refund. Send a malformed body with a key, get a 400, and that key replays the 400 for the next 24 hours — retrying a corrected payload under it returns the old failure, free but useless. Fix the payload and mint a new key.
One quirk to code around: a replay is written with Content-Type: application/json regardless of what the original was. Handler errors on this API are bare strings rather than envelopes, so a replayed 422 arrives labelled JSON with a plain-text body in it. The error reference has the full taxonomy of which statuses arrive in which shape.
Key lifecycle
| Outcome of the first attempt | Key afterwards |
|---|---|
2xx, 400, 404, 413, 415, 422 |
Locked to that response for 24 hours |
409, 424, any 5xx |
Discarded — reusable immediately |
429 rate-limit block |
Discarded |
402 insufficient credits, 403 no active subscription |
Discarded |
| An unhandled exception mid-request | Discarded, after the refund |
The pattern is simple: the gateway discards the in-flight record in the same breath as it refunds or refuses. A key that failed in a retryable way is never poisoned, so backoff-and-retry-with-the-same-key is always safe. A key that succeeded is a different matter — it is a permanent receipt for 24 hours.
That window is set at insert time and swept hourly, so twenty-four hours is a floor rather than a promise of prompt expiry.
Deriving a key that survives a restart
The single most common mistake is uuid4() at the call site. That is a unique request id, not an idempotency key. The whole point is that the retry presents the same key, and a retry after a process restart, a pod eviction or a queue redelivery runs fresh code that generates a fresh UUID. The key has to be a deterministic function of the business event.
greeting = f"welcome:{account_id}:{chat_id}" # one per fan, forever
drop = f"drop:{account_id}:{mailing_id}:{date.today()}" # tomorrow is a new event
reply = f"reply:{headers['X-Ofapis-Delivery']}" # the retry's stable handle
chatId is the fan's OnlyFans user id, so the first key is stable for the lifetime of that relationship and the greeting cannot fire twice however many times your welcome automation re-evaluates the fan list.
The third one deserves the explanation. Webhook delivery is at-least-once, and the event body carries no identifier of its own — no event id and no account id. The stable handle is the X-Ofapis-Delivery header, which keeps its value across all six retry attempts of the same event, and which the webhooks reference documents as the dedupe key. Using it as the idempotency key for your outbound reply means a redelivered event produces a replay instead of a second DM, even if your own dedupe table lost the race. The at-least-once model explains why that redelivery is inevitable rather than a bug.
What idempotency does not protect
The mass DM trigger. This is the expensive one, so read it twice.
POST /accounts/{id}/mailings/{mailingId}/trigger carries a gateway weight of zero; the real cost is recipientCount × perRecipientWeight debited inside the use case once the audience is enumerated. An Idempotency-Key does cover a worker retrying the identical HTTP request inside the window. It does not cover a second, deliberate trigger, because the guard against that is not an idempotency key at all — it is a run-state query, and it matches only runs in PENDING or IN_PROGRESS.
Everything else is a clean slate. A COMPLETED run, a FAILED run, and a run sitting in PAUSED waiting for the creator's session to be re-linked all look like "no active run" to that query. Trigger again and the audience is re-enumerated, the full cost is debited a second time, and every fan gets the message again. No 409, no warning. Before any unattended trigger, read GET /mailings/{mailingId}/runs and decide for yourself; the mass DM lifecycle covers the states in full.
Belt and braces for a scheduled drop: a date-scoped idempotency key stops the duplicate within 24 hours, and a runs check stops it after that.
Ordering, for anything event-driven
Claim the work before you cause the side effect, never after. An atomic insert against a unique index on the delivery id, then the send — because a crash between the two is survivable in that order and produces a duplicate DM in the other.
INSERT INTO handled (delivery_id) VALUES ($1) ON CONFLICT DO NOTHING;
-- 0 rows affected → already handled, return 200 and stop.
The claim and the idempotency key should be the same value. Then the two mechanisms cover each other: your table stops the second invocation from starting, and the API's key stops it from landing if it starts anyway.
A retry wrapper worth copying
const RETRY = new Set([429, 500, 502, 503, 504]);
export async function post(path: string, body: unknown, key: string) {
for (let n = 0; n < 5; n++) {
const r = await fetch(`https://api.ofapis.com/api/public/v1${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OFAPIS_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key, // identical on every attempt
},
body: JSON.stringify(body),
});
if (r.ok) {
if (r.headers.get("Idempotency-Replayed") === "true") {
console.info(`replayed ${key} — no send occurred`);
}
return (await r.json()).data;
}
// 409 here means our own earlier attempt is still in flight.
const wait = r.status === 409 ? 1
: Number(r.headers.get("Retry-After") ?? 2 ** n);
if (!RETRY.has(r.status) && r.status !== 409) {
throw new Error(`${r.status}: ${(await r.text()).slice(0, 200)}`);
}
await new Promise((res) => setTimeout(res, wait * 1000));
}
throw new Error(`exhausted retries for ${key}`);
}
Note the shape of the throw. A 400, 404, 413, 415 or 422 is debited and not refunded, so looping on one bills the endpoint's weight again for a byte-identical failure — five credits a time on media upload, which is the one that stings. Per-route weights are on the cost page. Those statuses need a code change, not a backoff, and the wrapper above is deliberately built so that adding one to RETRY requires a conscious decision.
FAQ
Does a replayed request cost credits?
No. The idempotency lookup happens before the credit debit, so a replay returns the stored response without metering anything. It is also evaluated before the per-minute rate limiter, which means a replay does not consume request budget either — a retry storm against completed keys is genuinely free rather than merely cheap.
Can I reuse an idempotency key after a failure?
It depends on which failure. The gateway discards the in-flight record whenever it refunds or refuses, so after a 409, a 424, any 5xx, a rate-limit block, a credit shortfall or a lapsed subscription the key is immediately reusable. After any response it keeps — including a charged 400 or 422 — the key is locked to that stored response for the rest of the window.
How long does an idempotency key last?
Twenty-four hours from the moment the request arrived, with an hourly background sweep clearing expired rows. Keys are per API token, so rotating a token gives you a clean namespace, and two tokens on the same account can use identical key strings without colliding.
Will an Idempotency-Key stop a duplicate mass DM?
Only for a retry of the same HTTP request within the window. A fresh trigger of the same mailing is blocked solely by a run-state check covering PENDING and IN_PROGRESS, so once a run reaches a terminal state — or merely pauses — the next trigger recounts, re-debits and re-sends to everyone. Guard it by listing the mailing's runs before you fire.
Should I just use a UUID as the key?
Not one generated at the moment of the call, because the retry that matters is the one made by a process that has restarted and will generate a different UUID. Derive the key from the event instead — the fan id for a greeting, the mailing plus the date for a daily drop, the delivery header for a webhook reply — and hash it into a v5 UUID afterwards if you want a fixed-width string.