How to Send OnlyFans Messages With an API
A send is one POST. Getting that POST right the first time, and making it safe when the network is not, is the rest of this page. If you followed the subscribers guide you already have everything the route needs.
Step 1 — Know what a chatId is
There is no separate conversation identifier to look up. A chatId is the fan's numeric OnlyFans user id, the same id that came back on each subscriber record. If you have the fan, you have the thread — the API will open it on first send.
You can also enumerate existing threads, but read the payload carefully before you build on it:
curl "https://api.ofapis.com/api/public/v1/accounts/42/chats?limit=50&offset=0" \
-H "Authorization: Bearer $OFAPIS_TOKEN"
{
"data": {
"list": [
{
"withUser": { "id": 98765 },
"canSendMessage": true,
"canNotSendReason": null,
"unreadMessagesCount": 2,
"lastMessage": { "text": "Hey!", "createdAt": "2026-07-19T12:00:00+00:00" }
}
],
"hasMore": false,
"nextOffset": 0
}
}
withUser is an id-only reference. No username, no display name, no avatar — the upstream request skips user expansion to keep a 100-row page small. If you need a handle to render, resolve it with GET /users/{targetUserId} and cache it. Each page costs one credit; the chat API page goes deeper on the inbox.
The field worth branching on is canSendMessage. When it is false, canNotSendReason says why — subscription_expired, for instance. Checking it is free and saves a failed send.
Step 2 — Send the message, with a key from the start
curl -X POST https://api.ofapis.com/api/public/v1/accounts/42/chats/98765/messages \
-H "Authorization: Bearer $OFAPIS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: welcome-98765-2026-10-19" \
-d '{"text":"Thanks for subscribing — here is what to expect this week."}'
Only text is required. Add the Idempotency-Key on the very first attempt, not when a retry goes wrong — a key you did not send cannot save you afterwards. If the terminal is where you are testing, the curl walkthrough covers keeping the token out of ps output while you do.
Step 3 — Read the response and confirm it landed
{
"data": {
"id": 444555666,
"text": "Thanks for subscribing — here is what to expect this week.",
"createdAt": "2026-10-19T09:30:00+00:00",
"isFree": true,
"price": 0,
"mediaCount": 0,
"isOpened": false,
"isNew": true,
"isDone": true,
"fromUser": { "id": 12345678 },
"toUser": { "id": 98765 }
}
}
A 200 plus a numeric id is delivery. Store that id — it is also the cursor for reading the thread back later, and the only way to correlate your send with what the fan replies to.
| Field | Confirms |
|---|---|
id |
the message exists on OnlyFans |
price, isFree |
the send was priced the way you intended |
canPurchase |
a priced message is still buyable by this fan |
mediaCount, isMediaReady |
attachments resolved and finished processing |
isFromQueue |
this came out of a scheduled send, not your call |
isMediaReady is the one people miss. A send can succeed with media attached that is still transcoding; the fan sees a placeholder until it finishes.
Step 4 — Price it, or attach vault media
Pay-per-view is the same route with two more fields. price is in cents as an integer, and mediaFiles takes vault media ids:
{
"text": "New set, 9 minutes",
"price": 1499,
"mediaFiles": [777888999],
"previews": [777889000],
"lockedText": false
}
previews are the frames that stay visible before purchase; lockedText: true additionally hides the body text and only makes sense alongside a non-zero price. The economics of all this sit on the PPV API page.
The ids in mediaFiles must already exist in the vault — the route attaches, it does not upload. List what you have with GET /vault/media, which returns a bare array rather than a page, each item carrying id, type, isReady and canView; reusing stored vault ids across posts and DMs is how you avoid paying to upload the same file twice. Only attach ids where isReady is true. If the file is not there yet, upload it first via POST /accounts/{id}/media (50 MB cap; image/jpeg, image/png, image/webp, video/mp4 only), which is a 5-credit call — see media upload.
Step 5 — Understand the 2-credit weight
Credit weights are per route, not flat:
POST /chats/{chatId}/messages → 1 credit
POST /accounts/{id}/chats/{chatId}/messages → 2 credits
The account-scoped send is configured at 2; the unscoped one falls through to the default of 1. That is not a reason to use the unscoped route — it resolves to whichever account happens to be first and active, which is a correctness problem, not a saving. Budget 2 credits per scoped send and move on. The price the fan pays has no bearing on it: a free "hi" and a $50 unlock both cost the same 2. Against Starter's 30,000 monthly credits that is 15,000 sends; pricing has the other plans.
Step 6 — Make a timed-out send safe
This is the failure that actually hurts. Your request times out at the client; you have no idea whether the message was delivered. Without a key, retrying double-posts, because the endpoint does no deduplication of its own.
With a key, the recovery is mechanical — replay the exact same key, never a new one:
| What you get back | What happened | Do this |
|---|---|---|
200 + Idempotency-Replayed: true |
the original send already succeeded | treat as delivered, do not resend |
200 without that header |
this attempt delivered it | record the message id |
409 IDEMPOTENCY_CONFLICT |
the first attempt is still in flight | wait a moment, replay the same key |
429 RATE_LIMIT_EXCEEDED |
never reached the handler | sleep Retry-After, replay |
async function sendOnce(accountId, chatId, body, key) {
const res = await fetch(
`https://api.ofapis.com/api/public/v1/accounts/${accountId}/chats/${chatId}/messages`,
{ method: "POST",
headers: { Authorization: `Bearer ${process.env.OFAPIS_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key },
body: JSON.stringify(body) });
if (res.status === 409) return { retry: true, after: 2 }; // in flight
if (res.status === 429) return { retry: true, after: Number(res.headers.get("Retry-After") ?? 5) };
if (res.status === 424) throw new Error("re-link the account"); // refunded
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return { replayed: res.headers.get("Idempotency-Replayed") === "true",
id: (await res.json()).data.id };
}
Keys are scoped to your API token and live for 24 hours, so derive them from something stable and unique to the intent — welcome-{fanId}-{date} is a good shape for an automated welcome DM, while crypto.randomUUID() generated at retry time is useless. A replay is served before metering, so it is not billed twice.
One caveat worth knowing: a send that fails with 424 or a 5xx discards the stored key as well as refunding the credit, which is deliberate — it frees the key so you can retry the same send once the account is healthy again.
Step 7 — Don't loop for a broadcast
The same message to a thousand fans through this route is a thousand POSTs, two thousand credits, and a rate limit you hit in the first minute. Use the mailing flow: create, preview — which returns recipientCount and estimatedCost before you commit — then trigger. See the mass DM API. And once a single send is reliable, the next step in this series is putting sends on a clock rather than firing them by hand: scheduling posts and messages.
FAQ
Do I need to create a conversation before the first message?
No. The chatId is the fan's OnlyFans user id, so you can POST to a thread that has never been used. If the fan came from your subscriber sync, that id is already in your database.
Why does the chat list not include fan usernames?
Because withUser is an id-only reference by design — the upstream call skips user expansion so a large page stays small. Resolve the handful of ids you actually display through GET /users/{targetUserId} and cache the result rather than expecting names inline.
What exactly does price mean?
An integer number of cents charged to the fan to unlock the message. 1499 is $14.99 and 0 sends it free. It affects what the fan pays OnlyFans and nothing about your credit balance.
Is a failed send always free?
No, and assuming so is how budgets go wrong. A 401, 402, 403 or 429 is refused before metering and costs nothing, and a 424 or 5xx is debited then refunded — but a 400, 404, 413, 415 or 422 is charged and not refunded. API errors has the full matrix.
Can I schedule a message instead of sending now?
Yes, through a separate route — POST /accounts/{id}/scheduled/messages, which costs 3 credits. Messages that arrive from that queue show up in history with isFromQueue: true, which matters if an auto-responder reads the newest message; see the messaging reference.