Messaging & mass DM
DMs, PPVs, mass sends with a dry-run preview, and the write-actions gate.
Read a conversation
# List conversations
curl "$BASE/api/crm/$CRM/accounts/$OFUID/chats" -H "X-API-Key: $KEY"
# Message history with one fan
curl "$BASE/api/crm/$CRM/accounts/$OFUID/chats/$FANID/messages" -H "X-API-Key: $KEY"Both of these paginate in surprising ways
/chats largely ignores limit and returns 10–14 conversations regardless, and
/messages uses an id= cursor where a short page does not mean the end. A
walker that gets this wrong loses most of a message history. Read
Pagination before writing one.
Send a DM or PPV
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/chats/$FANID/messages" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Hey! New drop is up 💕",
"price": 0,
"mediaFiles": []
}'price > 0makes the message a PPV.mediaFilestakes vault media IDs, locked behind the price.- Works for OnlyFans and Fansly.
Rate limited to 100 requests/minute.
Write actions are off by default
OnlyFans sends are gated per account by allow_of_write_actions. Without it you
get a 403 with code: "WRITES_DISABLED" (match on the code, not the message).
This applies to Fansly sends too:
curl -X PATCH "$BASE/api/crm/$CRM/accounts/$OFUID/polling" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"allow_of_write_actions": true}'Mass DM
OnlyFans only — Fansly returns 501 with
{"code": "PLATFORM_NOT_SUPPORTED"}.
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/messages/mass" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "New set just dropped 🔥",
"price": 12,
"mediaFiles": ["1234567"],
"previews": ["1234567"],
"audience": {
"min_spent": 20,
"since": "2026-01-01T00:00:00Z"
},
"dry_run": true
}'A message needs text, mediaFiles, or both — an empty send is rejected with
400 message requires text or mediaFiles.
dry_run defaults to true
This is the important detail. dry_run is true unless you explicitly set it to false. In dry-run mode the API resolves the audience, counts the recipients and
returns a sample — without sending anything.
{
"success": true,
"dry_run": true,
"recipients": 418,
"sent": 0,
"sample": [ { "fan_of_user_id": "987", "username": "somefan" } ],
"note": "Preview only — no messages were sent."
}recipients is the resolved count; sample is the first 10 only.
Always dry-run first
This is the "who will this actually reach?" preview, and it costs nothing. Check
the count and the sample, then re-send the identical body with
"dry_run": false.
A mass DM cannot be recalled once sent.
Audience filters
| Field | Effect |
|---|---|
type | all | active | expired — defaults to active |
fan_ids | Explicit id list. Overrides every other filter. Truncated at 5,000 |
min_spent | Only fans who have spent at least this much |
since / until | Inclusive bounds on the fan's subscribed_at |
`type` defaults to `active`, and tags are not an audience filter
Omitting type reaches only current subscribers, not everyone.
There is no tag field — the audience is resolved from the subscriber cache, which
carries no tags, so a tag key is silently ignored. To target a segment, resolve it
yourself with GET /fans, then pass the ids as audience.fan_ids.
PPV performance
curl "$BASE/api/crm/$CRM/accounts/$OFUID/ppv-stats" -H "X-API-Key: $KEY"Unlock rates and revenue per PPV message, so you can tell which sends actually earned.
Vault media
Media IDs for mediaFiles and previews come from the vault, which lives on the
passthrough surface — see the Content tag in the
OnlyFans passthrough reference. Upload and vault management are not
exposed on the CRM layer.
previews should be a subset of mediaFiles: those items stay unlocked as a teaser
while the rest sit behind the price.
Pacing
Sends are OnlyFans writes and hit the platform directly. See Rate limits.
A mass send is a serial loop inside your request
The real send is not dispatched to a background worker and is not paced. It
loops through recipients synchronously inside the HTTP request — up to 5,000 sends
on one connection. Keep audiences small enough that the request completes, and pace
by splitting the audience with since/until rather than relying on server-side
throttling.