OnlyFans PPV API
There is no PPV endpoint. Pay-per-view is a price attached to an ordinary message, and the interesting part is that the three routes which can carry that price do not carry the same things around it. Pick the wrong one and you lose preview thumbnails, or locked text, or spend forty times the credits on the same drop.
Where a price can go
| Aspect | Direct send | Scheduled message | Mailing |
|---|---|---|---|
| Route | POST /accounts/{id}/chats/{chatId}/messages |
POST /accounts/{id}/scheduled/messages |
POST /accounts/{id}/mailings |
| Price field | price (cents) |
price (cents) |
priceCents |
| Media field | mediaFiles |
mediaFiles |
mediaIds |
| Lock the text | lockedText |
not supported | lockedText |
| Preview thumbnails | previews |
not supported | not supported |
| Targeting | one chatId |
userIds or userLists |
listId, or all subscribers |
| Credit weight | 2 scoped, 1 unscoped | 3 | 0 at trigger, 1 per recipient |
Read the two "not supported" rows twice. The scheduled-message body accepts exactly text, mediaFiles, price, scheduledAt and one of userIds or userLists — no preview frame, no locked text. A mailing takes lockedText but still has nowhere to put preview ids.
So a drop that depends on a visible teaser has to go out as a direct send, one call per fan, at the moment you want it seen. If the teaser is not load-bearing, a mailing to the same cohort is dramatically cheaper. Preview versus cost is the real decision here, and no amount of scheduling works around it.
The price field
price is an integer count of cents. 2500 is $25.00. There is no decimal form and no currency field; passing 25 sends a twenty-five cent unlock, which is the most expensive typo in this API because it succeeds.
price: 0 is a free message, so lockedText: true with no price locks nothing — the two are only meaningful together. The price is what the fan pays OnlyFans and has no relationship to your credit balance: ofapis meters the send, not the sale.
curl -X POST \
https://api.ofapis.com/api/public/v1/accounts/42/chats/98765/messages \
-H "Authorization: Bearer ofapis_sk_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: drop-mar14-98765" \
-d '{"text":"Full clip, 11 min, no cuts","lockedText":true,"price":2500,
"mediaFiles":[810001,810002],"previews":[810003]}'
The Idempotency-Key matters more here than on a free message: a retry without one drops a second priced offer in the fan's inbox, while a replay with one returns the stored response, carries Idempotency-Replayed: true and is not charged again.
Vault media and previews
mediaFiles and previews both take vault media ids — not file uploads, and not crops generated for you. A preview is a separate asset you uploaded and chose to show unpaid, so a proper teaser is two uploads, not one.
Ids come from one of two places. POST /accounts/{accountId}/media is a multipart upload, 50 MB maximum, image/jpeg, image/png, image/webp or video/mp4 only, returning {mediaId, contentType, filename} — the heaviest call in the API at 5 credits, and its 413 MEDIA_FILE_TOO_LARGE and 415 MEDIA_TYPE_NOT_SUPPORTED rejections are both charged. Or GET /vault/media lists what is already in the vault as {list, hasMore} of {id, type, isReady, canView, duration, files}. That route takes no limit and no offset at all.
Gate on isReady before referencing an id. A video still transcoding will attach anyway, and the fan sees a broken unlock they have already paid for. The media upload API covers the upload path.
What a campaign actually costs
Arithmetic for one priced set to a 300-fan cohort:
- Direct sends with previews — 2 uploads (10) + 300 scoped sends (600) = 610 credits.
- Mailing, no previews — 2 uploads (10) + create (1) + preview (1) + trigger (0) + 300 recipients (300) = 312 credits.
- Scheduled to a list, no previews or locked text — 2 uploads (10) + 1 create (3) = 13 credits.
The scheduled route is an order of magnitude cheaper and gives up the most. Per-recipient mechanics are on the mass DM API page; queueing is on scheduling.
Finding out whether it was bought
This is where the honest answer matters more than the convenient one.
There is no purchase event. message.received is the only webhook type that exists, and it fires on inbound messages, not on unlocks. There is no purchasedBy, no buyer list, no revenue field on a message — and no lifetime total on the fan record either. What you can do is re-read the thread and inspect the priced message you sent:
curl -s "https://api.ofapis.com/api/public/v1/accounts/42/chats/98765/messages?limit=50" \
-H "Authorization: Bearer ofapis_sk_..." \
| jq '.data.list[] | select(.price > 0)
| {id, price, isFree, canPurchase, isOpened, createdAt}'
Four fields carry signal. price is what you charged. isFree says whether the message is viewable without paying. canPurchase says the fan can still buy it, so a priced message where it has gone false is no longer on offer. isOpened is documented as whether the message has been opened or read — a weaker claim than "paid for", and you should treat it as such.
Together they let you say a priced message was opened, not that money moved. For attribution with teeth you need your own send log joined against the notifications feed, whose items are forwarded verbatim from OnlyFans and cannot be paged backwards — poll often and treat what you catch as a floor. Ledger construction is on the transactions API page.
Measuring costs too: one credit per thread page re-read. Checking 300 threads the day after a drop is 300 credits on top of the 610 you spent sending it.
Pricing as a loop you can measure
Because the signal is weak per message, it only gets strong in aggregate. The workable design is a split test across two cohorts of comparable fans, not a per-fan optimiser.
- Split the target audience into two lists of similar size through the lists API.
- Send the same asset at two prices, one per list, in the same hour.
- One day later, read each thread once and count priced messages where
isOpenedis true. - Compare
price x opened / sentper arm — expected cents per send, not raw open rate.
A 20% open rate at $25 beats a 40% rate at $10 by 25%, and only the combined figure shows that. Hold the asset, the copy and the send hour constant across arms, run each at a few hundred fans or the result is noise, and re-run quarterly.
Then move the fans who opened into a separate list so the next drop excludes them — resending a priced offer to someone who already bought it is the fastest way to train a cohort to ignore you. Cohort mechanics are on the fan segments API page, and the schedule-and-exclude loop around them on PPV automation.
FAQ
Can I schedule a PPV with a preview thumbnail?
No. The scheduled-message body accepts only text, mediaFiles, price, scheduledAt and a target — no previews field, no lockedText field. A drop that needs a visible teaser has to be a direct send, or a mailing if you can live without the preview frame.
Is there a webhook that tells me a fan unlocked a message?
No. message.received is the only event type ofapis emits, and it covers inbound DMs. Unlock tracking is therefore a polling problem: re-read the thread, inspect the priced message, and accept that you are reading an opened flag rather than a payment record.
What exactly does isOpened prove?
That the message was opened or read — that is the field's defined meaning, and it is not a synonym for purchased. Use it as a directional metric across a cohort where the sample size absorbs the ambiguity, and never as the basis for a per-fan claim about who paid you.
Can I change the price of a message after sending it?
No. There is no update route for a sent message. A queued one can be removed with DELETE /accounts/{id}/scheduled/messages/{queueId} and recreated at a new price, but once a message has gone out its price is final.
Does a priced send cost more credits than a free one?
No — the weight follows the route, not the body. An account-scoped send is 2 credits whether the message is free or $50; the unscoped form falls through to the default of 1. Plan allowances are on pricing.