OnlyFans Mass DM API
Two objects carry a mass DM from draft to delivery. A mailing is a stored scenario — text, vault media, a target segment, an optional price. A run is one execution of it: a row with counters that moves through a five-state machine while a background worker walks the recipient list one fan at a time. Everything here lives under https://api.ofapis.com/api/public/v1/accounts/{accountId}/mailings, on the same Bearer token as the rest of the API. For the budget side rather than the wire format, see the mass message tool page; for wiring a blast into a recurring job, automating DM sends covers the scheduling half.
The endpoints
GET /accounts/{accountId}/mailings
POST /accounts/{accountId}/mailings
GET /accounts/{accountId}/mailings/{mailingId}
DELETE /accounts/{accountId}/mailings/{mailingId}
POST /accounts/{accountId}/mailings/{mailingId}/preview
POST /accounts/{accountId}/mailings/{mailingId}/trigger
GET /accounts/{accountId}/mailings/{mailingId}/runs
GET /accounts/{accountId}/mailings/{mailingId}/runs/{runId}
accountId is the numeric ofapis account. mailingId and runId are UUIDs. listId, confusingly, is a string, because it comes from OnlyFans.
Creating a mailing
| Field | Type | Default | Notes |
|---|---|---|---|
name |
string | — | Required. Label only; never sent to a fan. |
text |
string | "" |
The literal body. There is no templating and no merge field, so every recipient gets the identical string. |
mediaIds |
number[] | [] |
Vault media ids, uploaded beforehand through the media upload API. |
listId |
string / null | null |
An OnlyFans list id. Null means "everyone". |
priceCents |
integer | 0 |
Integer cents. 0 sends a free message; a non-zero value makes the blast a priced unlock. |
lockedText |
boolean | false |
Hides the text behind the paywall. Only meaningful with a price. |
The response echoes all six fields plus id, createdAt and updatedAt. Two things the shape does not give you: there is no update verb, so fixing a typo means creating a second mailing, and DELETE removes the scenario without cancelling a run already in flight.
Preview, and what the number means
Preview pages through the target list fifty fans at a time, counts them, and returns the arithmetic:
{
"data": {
"mailingId": "0f21a6c4-55de-4c0b-9f3e-2a7d8e5b1c90",
"listId": "118",
"recipientCount": 742,
"perRecipientWeight": 1,
"estimatedCost": 742
}
}
It costs one credit whatever the list size. With listId null it does not enumerate at all: recipientCount comes back as -1 and estimatedCost as 0 — sentinels, not a free send. The budget gate simply does not exist for an untargeted mailing.
It is also worth knowing what "everyone" resolves to: with no listId, the trigger builds the audience by paging your conversation list and taking the other party in each thread. That is every fan you have ever exchanged a DM with, not the currently active subscribers you probably had in mind. Point the mailing at a list built through the lists API instead.
What the trigger does, in order
- Loads the mailing —
404if the id is unknown under this account. - Refuses if a run for this mailing is already
PENDINGorIN_PROGRESS—409. - Resolves the creator's OnlyFans session —
424if it has expired. - Enumerates recipients: list pages of fifty, or chat counterparties.
- Rejects an empty audience before touching the balance.
- Debits
recipientCount × perRecipientWeightatomically —402if the balance cannot cover the whole blast. All-or-nothing; no partial send is attempted. - Writes the run row as
PENDINGand one row per recipient. - Returns
{"data":{"runId":"…","recipientCount":742,"creditsDebited":742}}.
Step 7 is the one people get wrong. The audience is snapshotted into durable recipient rows at trigger time: fans added to the list a minute later will not receive the message, and fans removed from it still will. The trigger's own gateway weight is zero, so a blast costs exactly the per-recipient debit in step 6.
Step 2 is the other one. It looks for an active run only. Once a run reaches COMPLETED or FAILED the mailing is a clean slate again, and the next trigger re-enumerates the audience, debits the full cost a second time and delivers the same text to the same fans. An Idempotency-Key covers a worker retrying the identical request — the replay returns Idempotency-Replayed: true and is not billed again — but nothing stops a deliberate second trigger an hour later. Read GET /mailings/{mailingId}/runs before you fire.
Run states
| Status | Meaning | What moves it |
|---|---|---|
PENDING |
Row and recipients written, nothing sent | The worker claims it within seconds |
IN_PROGRESS |
The worker is walking the recipient list | Completion, or a dead session |
PAUSED |
Session expired mid-run, or the worker was lost | Resumes at the first unsent recipient once the account is re-linked |
COMPLETED |
Every recipient marked sent or skipped | Terminal — the refund is issued here |
FAILED |
The scenario could not be loaded at dispatch time | Terminal |
recipientCount is fixed at trigger; successCount and skippedCount increment per message and sum to it once the run is COMPLETED. FAILED almost always means the mailing was deleted between trigger and dispatch.
Debit now, refund at the end
Credits leave your balance at trigger. Any recipient the worker cannot deliver to — blocked, deleted, DMs closed — is marked skipped, and on COMPLETED the skipped total times the per-recipient weight is granted back as a single adjustment. So creditsDebited is a ceiling and real spend is creditsDebited − skippedCount. A run stuck in PAUSED holds the full debit: the refund is tied to completion, not to progress.
Wall-clock time
The worker sleeps 500 ms between messages, which fixes throughput at roughly 120 recipients a minute no matter what plan you are on.
| Recipients | Dispatch window |
|---|---|
| 400 | about 3 minutes |
| 1,500 | about 13 minutes |
| 6,000 | about 50 minutes |
| 20,000 | about 2 hours 47 minutes |
A worker instance takes one run at a time and finishes it before claiming another, so a second mailing triggered during the first queues rather than overlapping. Poll accordingly: every 30 seconds is plenty, and each poll is a billed call against your per-minute rate limit.
A client that gates on cost
import time, requests
BASE = "https://api.ofapis.com/api/public/v1"
H = {"Authorization": "Bearer ofapis_sk_..."}
def blast(account_id, mailing_id, budget):
root = f"{BASE}/accounts/{account_id}/mailings/{mailing_id}"
est = requests.post(f"{root}/preview", headers=H).json()["data"]
if est["recipientCount"] < 0:
raise RuntimeError("untargeted mailing: preview cannot cost it")
if est["estimatedCost"] > budget:
raise RuntimeError(f"{est['estimatedCost']} credits over budget {budget}")
t = requests.post(f"{root}/trigger", headers=H)
if t.status_code == 409:
raise RuntimeError("a run for this mailing is already in flight")
t.raise_for_status()
run_id = t.json()["data"]["runId"]
while True:
run = requests.get(f"{root}/runs/{run_id}", headers=H).json()["data"]
if run["status"] in ("COMPLETED", "FAILED"):
return run
if run["status"] == "PAUSED":
raise RuntimeError("re-link the account; the run resumes on its own")
time.sleep(30)
Errors specific to this flow
| Status | Raised when | Retry |
|---|---|---|
409 |
A run for this mailing is PENDING or IN_PROGRESS |
Only after it ends, and only if you mean to send again |
402 |
The balance cannot cover the whole audience | After topping up — nothing was debited |
424 |
The creator's OnlyFans session is expired | After re-linking the account |
404 |
Mailing or run id not found under this account | No |
4xx |
The audience resolved to zero fans | No — fix the list |
That last row is deliberately vague on the number: the OpenAPI description for the trigger labels the empty-audience case 422, while the handler in the current build answers 400. Treat anything other than 409 or 424 as "do not retry this trigger". Envelope shapes for the gateway-level failures are on the errors page.
Failures after the trigger returns never surface as a status code at all. A session that dies at recipient 300 of 742 moves the run to PAUSED and flags the account for re-linking; dispatch resumes at 301 once that is done. That is what polling exists for. One-to-one replies belong on the messaging API instead; plan allowances are on pricing.
FAQ
What does a mailing object actually store?
Six writable fields: a name, the message text, vault media ids, the target listId, a price in cents and a lockedText flag — the last two are what turn a broadcast into the recurring paid drop pattern. It stores no schedule and no history — history lives on the run records, and fetching the mailing returns its ten most recent runs alongside it.
Does the recipient list update between trigger and delivery?
No. The trigger enumerates the audience and writes one durable row per recipient before it returns, so the set is frozen at that instant. A two-hour dispatch to twenty thousand fans is still sending to the list as it looked when you triggered it.
Which part of this flow is charged if it fails?
The trigger carries a gateway weight of zero, so a rejected trigger costs nothing and the per-recipient debit only happens once the audience is counted and accepted. Preview is the one call always charged its single credit, including when it returns -1.
Can two mailings for the same account dispatch at once?
The 409 guard is scoped to a single mailing, so two different mailings can each hold an active run. They will not send in parallel, though: a worker instance drives one run to completion before claiming the next, so the second blast starts after the first finishes.
Is there a way to edit a mailing after creating it?
No — the resource exposes create, read, delete, preview and trigger, with no update verb. Correcting the text or the price means creating a replacement mailing and deleting the old one, and deleting it does not stop a run that is already dispatching.