BetterFans Link: the OnlyFans APIBetterFans Link

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.

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

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

FieldTypeDescription
typeenumWhat to do. One of send_message, send_mass_message, unsend_message, add_fan_to_list or remove_fan_from_list.
paramsobjectThe 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.

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

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.

unsend_message

Takes back a message. Pass a massMessageId to take back every copy of a mass message.

FieldTypeDescription
messageIdstringFor a 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.

FieldTypeDescription
fanIdstringThe fan.
listIdstringThe fan list.

remove_fan_from_list

Removes a fan from one of the fan lists.

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

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. error.param names the field.
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.

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://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

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://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

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 OnlyFans.

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 OnlyFans. One of live or test.
accountIdstringThe account the action is for.
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.
executedAttimestamp or nullWhen it ran on OnlyFans.
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 OnlyFans.
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 OnlyFans 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, and result is only {"simulated": true}.

On this page