Actions
Ask for writes and follow them through approval.
Every write is an action. A person approves it in the dashboard before anything reaches OnlyFans. See writes and approvals. Every route can also return the authentication errors, rate_limited and internal_error.
| Route | Method and path |
|---|---|
| Create action | POST /v1/accounts/{accountId}/actions |
| Get action | GET /v1/actions/{actionId} |
| List actions | GET /v1/actions |
Create action
POST /v1/accounts/{accountId}/actions
Asks for a write on OnlyFans. BetterFans Link stores it as a pending action and returns its approvalUrl. Nothing reaches OnlyFans until a person approves it in the dashboard. Pending actions expire after 24 hours.
Needs the write scope and writes switched on for the account.
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.
In test mode the action is approved the same way but never reaches OnlyFans: once it runs, result is {"simulated": true}.
Path parameters
| Name | Description |
|---|---|
accountId | The account's id, which is the creator's OnlyFans user id. 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. |
params | object | The parameters for that type, below. |
send_message
Sends a message to one fan. Add priceCents to make it a paid message and mediaIds to attach vault media.
| 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). Minimum 300 on OnlyFans. 0 to 2000000. |
mediaIds | array of strings | Optional. Vault media ids. Up to 50 items. |
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.
| 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. |
unsend_message
Takes back a message. Pass a massMessageId to take back every copy of a mass message.
| Field | Type | Description |
|---|---|---|
messageId | string | For a 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.
| 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.
| Field | Type | Description |
|---|---|---|
fanId | string | The fan. |
listId | string | The fan list. |
Returns
202 with an Action object in data.
Example request
curl -X POST "https://app.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",
"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://app.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,
"executedAt": null,
"result": null,
"error": null
},
"meta": {
"source": "synced",
"asOf": null,
"requestId": "req_7Hq2LmX9pRt4VbN8cKe3WzYa"
}
}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. error.param names the field. |
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. |
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://app.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",
"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://app.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",
"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://app.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",
"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://app.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,
"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 OnlyFans.
| 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 OnlyFans. One of live or test. |
accountId | string | The account the action is for. |
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. |
executedAt | timestamp or null | When it ran on OnlyFans. |
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 OnlyFans. |
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 OnlyFans 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, and result is only {"simulated": true}.