Actions: send messages and other writes
Send OnlyFans messages and mass messages through the API, take messages back and change fan lists, then follow each write to its result.
Product page: OnlyFans messaging API
Every write through this API is an action. It waits for a person on your team to approve it in the dashboard, unless the key that created it has auto-approve on. MCP clients that sign in with OAuth always wait. See writes and approvals. Every route can also return the authentication errors, rate_limited and internal_error.
| Route | Method and path | Platforms |
|---|---|---|
| Create action | POST /v1/accounts/{accountId}/actions | OnlyFans and Fansly |
| Get action | GET /v1/actions/{actionId} | OnlyFans and Fansly |
| List actions | GET /v1/actions | OnlyFans and Fansly |
Create action
POST /v1/accounts/{accountId}/actions
Asks for a write on the account's platform. BetterFans Link stores it as a pending action, answers 202 with its approvalUrl, and runs it once a person approves it in the dashboard. When the key has auto-approve on, the action runs at once instead and the answer is 200 with the action executed or failed. Pending actions expire after 24 hours.
Needs the write scope and writes switched on for the account. Works for OnlyFans and Fansly accounts.
Send an Idempotency-Key header with a new unique value for each action you intend, and the same value when you retry. The same key with the same body returns the original action; the same key with a different body is idempotency_conflict.
An idempotency key is unique across your whole workspace, in both modes, and never expires. Reusing one with a different account, mode, type or params is idempotency_conflict, so use a new UUID for every new action.
Fansly accounts take send_message and unsend_message. A Fansly message goes to the chat the account already has with the fan, is free (leave out priceCents) and attaches only free vault media. If there is no chat yet, or a media item is not free, nothing is sent and the action ends failed. Any other type is not_supported_on_platform, and no action is created.
In test mode the action is approved the same way but never reaches the platform: once it runs, result is {"simulated": true}.
Path parameters
| Name | Description |
|---|---|
accountId | The account's id, which is the creator's user id on its platform. Get it from GET /v1/accounts. |
Request body
Send JSON with the action type and its params.
| Field | Type | Description |
|---|---|---|
type | enum | What to do. One of send_message, send_mass_message, unsend_message, add_fan_to_list or remove_fan_from_list. Fansly accounts take send_message or unsend_message only. |
params | object | The parameters for that type: send_message, send_mass_message, unsend_message, add_fan_to_list and remove_fan_from_list. |
Returns
202 with an Action object in data.
Example request
curl -X POST "https://betterfans.link/v1/accounts/412345678/actions" \
-H "Authorization: Bearer $BFL_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"type": "send_message",
"params": {
"fanId": "38291045",
"text": "Here is the custom clip you asked about",
"priceCents": 1500,
"mediaIds": [
"3399120045"
]
}
}'Example response
{
"data": {
"id": "act_8KpQ2wLz5XnR7cVb3MhT9dYs",
"type": "send_message",
"status": "pending",
"mode": "live",
"accountId": "412345678",
"platform": "onlyfans",
"summary": "Send a $15 message to @mike_travels",
"params": {
"fanId": "38291045",
"text": "Here is the custom clip you asked about",
"priceCents": 1500,
"mediaIds": [
"3399120045"
]
},
"estimatedRecipients": 1,
"price": {
"amount": 1500,
"currency": "USD"
},
"approvalUrl": "https://betterfans.link/approve/act_8KpQ2wLz5XnR7cVb3MhT9dYs",
"requestedBy": {
"kind": "api_key",
"label": "Reply assistant",
"client": null,
"tool": null
},
"decidedBy": null,
"decisionNote": null,
"createdAt": "2026-09-28T14:02:10Z",
"expiresAt": "2026-09-29T14:02:10Z",
"decidedAt": null,
"autoApproved": false,
"executedAt": null,
"result": null,
"error": null
},
"meta": {
"source": "synced",
"asOf": null,
"requestId": "req_7Hq2LmX9pRt4VbN8cKe3WzYa",
"platform": "onlyfans"
}
}Errors
| Code | Status | When |
|---|---|---|
account_not_found | 404 | The account is not linked to your workspace, your key's account list leaves it out, or, with a test key, it is not a sandbox creator. |
invalid_parameter | 400 | The body does not match the action type's parameters, or a Fansly message has a priceCents above 0. error.param names the field. |
not_supported_on_platform | 400 | The action type does not run on the account's platform. error.param is type. |
idempotency_key_required | 400 | The Idempotency-Key header is missing. |
idempotency_conflict | 409 | The key was used before with a different body. |
missing_scope | 403 | The key does not have the write scope. |
writes_disabled | 403 | Writes are switched off for this account. |
Send an OnlyFans message through the API
To message one fan, call Create action with type set to send_message. The message goes out when the action runs: once a person approves it, or at once when the key has auto-approve on.
send_message
Sends a message to one fan. Add mediaIds to attach vault media. On OnlyFans, add priceCents to make it a paid message. On Fansly the message goes to the chat the account already has with the fan, is always free and attaches only free vault media. Works for OnlyFans and Fansly accounts.
| Field | Type | Description |
|---|---|---|
fanId | string | The fan to message. |
text | string | Message text. 1 to 5000 characters. |
priceCents | integer | Optional. Set to make it a paid message (PPV). OnlyFans only, minimum 300. 0 to 2000000. |
mediaIds | array of strings | Optional. Vault media ids from list_vault. Up to 50 items. |
A request body:
{
"type": "send_message",
"params": {
"fanId": "38291045",
"text": "Here is the custom clip you asked about",
"priceCents": 1500,
"mediaIds": [
"3399120045"
]
}
}Send an OnlyFans mass message
To send one message to fan lists or a set of fans, call Create action with type set to send_mass_message. The message goes out when the action runs: once a person approves it, or at once when the key has auto-approve on.
send_mass_message
Sends one message to fan lists or a set of fans. The action shows an estimate of how many fans it will reach. Works for OnlyFans accounts only. Other accounts get not_supported_on_platform, and no action is created.
| Field | Type | Description |
|---|---|---|
text | string | Message text. 1 to 5000 characters. |
priceCents | integer | Optional. Set to make it a paid message (PPV). 0 to 2000000. |
mediaIds | array of strings | Optional. Vault media ids. Up to 50 items. |
audience | object | Who gets it. Pick at least one list or fan. |
audience.listIds | array of strings | Optional. Fan lists to send to. |
audience.excludeListIds | array of strings | Optional. Fan lists to leave out. |
audience.fanIds | array of strings | Optional. Fans to send to. Up to 1000 items. |
A request body:
{
"type": "send_mass_message",
"params": {
"text": "Weekend special: the full beach video is in your inbox",
"priceCents": 1200,
"mediaIds": [
"3399120045"
],
"audience": {
"listIds": [
"994512"
],
"excludeListIds": [
"994530"
]
}
}
}Other action types
Three more types take back a message and change fan lists. You create them with Create action like the two above.
unsend_message
Takes back a message. On OnlyFans, pass a massMessageId to take back every copy of a mass message. Works for OnlyFans and Fansly accounts.
| Field | Type | Description |
|---|---|---|
messageId | string | For an OnlyFans mass message, pass its massMessageId to unsend every copy. |
fanId | string | Optional. The fan whose chat holds the message. |
add_fan_to_list
Adds a fan to one of the fan lists. Works for OnlyFans accounts only. Other accounts get not_supported_on_platform, and no action is created.
| Field | Type | Description |
|---|---|---|
fanId | string | The fan. |
listId | string | The fan list. |
remove_fan_from_list
Removes a fan from one of the fan lists. Works for OnlyFans accounts only. Other accounts get not_supported_on_platform, and no action is created.
| Field | Type | Description |
|---|---|---|
fanId | string | The fan. |
listId | string | The fan list. |
Get action
GET /v1/actions/{actionId}
One action with its status, who decided and the result. Poll it after creating an action, or listen for the action.executed, action.rejected and action.failed webhook events.
Needs the read scope.
Path parameters
| Name | Description |
|---|---|
actionId | The act_ id from POST /v1/accounts/{accountId}/actions. |
Returns
200 with an Action object in data.
Example request
curl "https://betterfans.link/v1/actions/act_8KpQ2wLz5XnR7cVb3MhT9dYs" \
-H "Authorization: Bearer $BFL_KEY"Example response
{
"data": {
"id": "act_8KpQ2wLz5XnR7cVb3MhT9dYs",
"type": "send_message",
"status": "executed",
"mode": "live",
"accountId": "412345678",
"platform": "onlyfans",
"summary": "Send a $15 message to @mike_travels",
"params": {
"fanId": "38291045",
"text": "Here is the custom clip you asked about",
"priceCents": 1500,
"mediaIds": [
"3399120045"
]
},
"estimatedRecipients": 1,
"price": {
"amount": 1500,
"currency": "USD"
},
"approvalUrl": "https://betterfans.link/approve/act_8KpQ2wLz5XnR7cVb3MhT9dYs",
"requestedBy": {
"kind": "api_key",
"label": "Reply assistant",
"client": null,
"tool": null
},
"decidedBy": {
"name": "Ana Ortiz",
"email": "ana@example.com"
},
"decisionNote": null,
"createdAt": "2026-09-28T14:02:10Z",
"expiresAt": "2026-09-29T14:02:10Z",
"decidedAt": "2026-09-28T14:05:31Z",
"autoApproved": false,
"executedAt": "2026-09-28T14:05:33Z",
"result": {
"messageId": "5820194410",
"fanId": "38291045"
},
"error": null
},
"meta": {
"source": "synced",
"asOf": null,
"requestId": "req_7Hq2LmX9pRt4VbN8cKe3WzYa"
}
}Errors
| Code | Status | When |
|---|---|---|
action_not_found | 404 | No action with this id in your workspace. |
List actions
GET /v1/actions
Actions in your workspace, filtered by status or account. status=pending lists what is waiting for a person.
Needs the read scope.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
status | enum | all | Only actions in this state. One of all, pending, approved, rejected, expired, executing, executed or failed. |
accountId | string | None | Only this account's actions. |
cursor | string | None | nextCursor from the previous page. Leave out for the first page. |
limit | integer | 25 | Page size, 1 to 100. |
Returns
200 with a list of Action objects in data. See pagination.
Example request
curl "https://betterfans.link/v1/actions?status=pending" \
-H "Authorization: Bearer $BFL_KEY"Example response
{
"data": [
{
"id": "act_8KpQ2wLz5XnR7cVb3MhT9dYs",
"type": "send_message",
"status": "pending",
"mode": "live",
"accountId": "412345678",
"platform": "onlyfans",
"summary": "Send a $15 message to @mike_travels",
"params": {
"fanId": "38291045",
"text": "Here is the custom clip you asked about",
"priceCents": 1500,
"mediaIds": [
"3399120045"
]
},
"estimatedRecipients": 1,
"price": {
"amount": 1500,
"currency": "USD"
},
"approvalUrl": "https://betterfans.link/approve/act_8KpQ2wLz5XnR7cVb3MhT9dYs",
"requestedBy": {
"kind": "api_key",
"label": "Reply assistant",
"client": null,
"tool": null
},
"decidedBy": null,
"decisionNote": null,
"createdAt": "2026-09-28T14:02:10Z",
"expiresAt": "2026-09-29T14:02:10Z",
"decidedAt": null,
"autoApproved": false,
"executedAt": null,
"result": null,
"error": null
}
],
"hasMore": false,
"nextCursor": null,
"meta": {
"source": "synced",
"asOf": null,
"requestId": "req_7Hq2LmX9pRt4VbN8cKe3WzYa"
}
}Errors
| Code | Status | When |
|---|---|---|
invalid_parameter | 400 | A query parameter has a value this route does not accept, or a required one is missing. error.param names it. |
invalid_cursor | 400 | cursor is not a nextCursor this list returned. |
The Action object
A write and where it is on its way to the account's platform.
| Field | Type | Description |
|---|---|---|
id | string | Action id, starting with act_. |
type | enum | What the action does. One of send_message, send_mass_message, unsend_message, add_fan_to_list or remove_fan_from_list. |
status | enum | Where the action is. See action statuses. One of pending, approved, rejected, expired, executing, executed or failed. |
mode | enum | test actions never reach the platform. One of live or test. |
accountId | string | The account the action is for. |
platform | enum | The platform the account lives on: onlyfans or fansly. |
summary | string | One plain sentence, for example: Send a $12 message to @jess. |
params | object | The params you sent, after validation. A JSON object. |
estimatedRecipients | integer or null | How many fans it will reach. For a mass message this is an estimate. |
price | Money or null | The price of a paid message. |
approvalUrl | string | Dashboard page where a person approves or rejects the action. Share it with whoever decides. |
requestedBy | object | Who asked for the action. |
requestedBy.kind | enum | The kind of credential that asked. One of api_key, oauth or user. |
requestedBy.label | string | Key name, OAuth client name or person. |
requestedBy.client | string or null | The MCP client, when the request came through MCP. |
requestedBy.tool | string or null | The MCP tool that asked, for example send_message. |
decidedBy | object or null | The person who approved or rejected it. |
decidedBy.name | string | Name. |
decidedBy.email | string | Email. |
decisionNote | string or null | The note the person left with their decision. |
createdAt | timestamp | When it was created. |
expiresAt | timestamp | When a pending action expires, 24 hours after it was created. |
decidedAt | timestamp or null | When it was approved or rejected. |
autoApproved | boolean | True when the key that asked auto-approves its writes, so it ran without a person deciding. |
executedAt | timestamp or null | When it ran on the platform. |
result | object or null | What the write returned, set once it ran. See results. A JSON object. |
error | object or null | Why it failed. |
error.code | string | Error code. |
error.message | string | What went wrong. |
Action statuses
| Status | Label | Meaning |
|---|---|---|
pending | Waiting for approval | Waiting for a person to approve or reject it. It expires after 24 hours. |
approved | Approved | A person approved it and it is about to run. |
rejected | Rejected | A person rejected it. Nothing was sent. |
expired | Expired | Nobody decided within 24 hours. Nothing was sent. |
executing | Running | Running on the account's platform. |
executed | Done | Done. result holds what the write returned. |
failed | Failed | It ran and did not succeed. error says why. |
Results
When an action ran, result holds what the write returned:
| Type | result fields |
|---|---|
send_message | messageId, the id of the sent message, and fanId. messageId can be null when the platform did not return one. |
send_mass_message | massMessageId, the mass message id. It matches massMessageId on each copy. It can be null when OnlyFans did not return one. |
unsend_message | With fanId: messageId, fanId and unsent, which is true. Without fanId: massMessageId and unsent. |
add_fan_to_list | fanId, listId and inList, which is true. |
remove_fan_from_list | fanId, listId and inList, which is false. |
In test mode nothing reaches OnlyFans or Fansly, and result is only {"simulated": true}.