BetterFans Link: the OnlyFans APIBetterFans Link

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.

RouteMethod and pathPlatforms
Create actionPOST /v1/accounts/{accountId}/actionsOnlyFans and Fansly
Get actionGET /v1/actions/{actionId}OnlyFans and Fansly
List actionsGET /v1/actionsOnlyFans 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

NameDescription
accountIdThe 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.

FieldTypeDescription
typeenumWhat 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.
paramsobjectThe 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

CodeStatusWhen
account_not_found404The 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_parameter400The 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_platform400The action type does not run on the account's platform. error.param is type.
idempotency_key_required400The Idempotency-Key header is missing.
idempotency_conflict409The key was used before with a different body.
missing_scope403The key does not have the write scope.
writes_disabled403Writes 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.

FieldTypeDescription
fanIdstringThe fan to message.
textstringMessage text. 1 to 5000 characters.
priceCentsintegerOptional. Set to make it a paid message (PPV). OnlyFans only, minimum 300. 0 to 2000000.
mediaIdsarray of stringsOptional. 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.

FieldTypeDescription
textstringMessage text. 1 to 5000 characters.
priceCentsintegerOptional. Set to make it a paid message (PPV). 0 to 2000000.
mediaIdsarray of stringsOptional. Vault media ids. Up to 50 items.
audienceobjectWho gets it. Pick at least one list or fan.
audience.listIdsarray of stringsOptional. Fan lists to send to.
audience.excludeListIdsarray of stringsOptional. Fan lists to leave out.
audience.fanIdsarray of stringsOptional. 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.

FieldTypeDescription
messageIdstringFor an OnlyFans mass message, pass its massMessageId to unsend every copy.
fanIdstringOptional. 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.

FieldTypeDescription
fanIdstringThe fan.
listIdstringThe 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.

FieldTypeDescription
fanIdstringThe fan.
listIdstringThe 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

NameDescription
actionIdThe 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

CodeStatusWhen
action_not_found404No 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

NameTypeDefaultDescription
statusenumallOnly actions in this state. One of all, pending, approved, rejected, expired, executing, executed or failed.
accountIdstringNoneOnly this account's actions.
cursorstringNonenextCursor from the previous page. Leave out for the first page.
limitinteger25Page 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

CodeStatusWhen
invalid_parameter400A query parameter has a value this route does not accept, or a required one is missing. error.param names it.
invalid_cursor400cursor is not a nextCursor this list returned.

The Action object

A write and where it is on its way to the account's platform.

FieldTypeDescription
idstringAction id, starting with act_.
typeenumWhat the action does. One of send_message, send_mass_message, unsend_message, add_fan_to_list or remove_fan_from_list.
statusenumWhere the action is. See action statuses. One of pending, approved, rejected, expired, executing, executed or failed.
modeenumtest actions never reach the platform. One of live or test.
accountIdstringThe account the action is for.
platformenumThe platform the account lives on: onlyfans or fansly.
summarystringOne plain sentence, for example: Send a $12 message to @jess.
paramsobjectThe params you sent, after validation. A JSON object.
estimatedRecipientsinteger or nullHow many fans it will reach. For a mass message this is an estimate.
priceMoney or nullThe price of a paid message.
approvalUrlstringDashboard page where a person approves or rejects the action. Share it with whoever decides.
requestedByobjectWho asked for the action.
requestedBy.kindenumThe kind of credential that asked. One of api_key, oauth or user.
requestedBy.labelstringKey name, OAuth client name or person.
requestedBy.clientstring or nullThe MCP client, when the request came through MCP.
requestedBy.toolstring or nullThe MCP tool that asked, for example send_message.
decidedByobject or nullThe person who approved or rejected it.
decidedBy.namestringName.
decidedBy.emailstringEmail.
decisionNotestring or nullThe note the person left with their decision.
createdAttimestampWhen it was created.
expiresAttimestampWhen a pending action expires, 24 hours after it was created.
decidedAttimestamp or nullWhen it was approved or rejected.
autoApprovedbooleanTrue when the key that asked auto-approves its writes, so it ran without a person deciding.
executedAttimestamp or nullWhen it ran on the platform.
resultobject or nullWhat the write returned, set once it ran. See results. A JSON object.
errorobject or nullWhy it failed.
error.codestringError code.
error.messagestringWhat went wrong.

Action statuses

StatusLabelMeaning
pendingWaiting for approvalWaiting for a person to approve or reject it. It expires after 24 hours.
approvedApprovedA person approved it and it is about to run.
rejectedRejectedA person rejected it. Nothing was sent.
expiredExpiredNobody decided within 24 hours. Nothing was sent.
executingRunningRunning on the account's platform.
executedDoneDone. result holds what the write returned.
failedFailedIt ran and did not succeed. error says why.

Results

When an action ran, result holds what the write returned:

Typeresult fields
send_messagemessageId, the id of the sent message, and fanId. messageId can be null when the platform did not return one.
send_mass_messagemassMessageId, the mass message id. It matches massMessageId on each copy. It can be null when OnlyFans did not return one.
unsend_messageWith fanId: messageId, fanId and unsent, which is true. Without fanId: massMessageId and unsent.
add_fan_to_listfanId, listId and inList, which is true.
remove_fan_from_listfanId, listId and inList, which is false.

In test mode nothing reaches OnlyFans or Fansly, and result is only {"simulated": true}.

On this page