Reply to unread paying fans
Find the fans who have spent money and are waiting on a reply, write replies in the creator's voice, and send them after a person approves each one.
Fans who pay and then wait are the ones most worth answering first. This guide finds them, gets replies written, and sends each reply through an approval, so a person on your team sees every message before a fan does.
Before you start
Sending needs three things. Reading and drafting need none of them.
| Needed | Where to set it |
|---|---|
A key with the write scope, or an MCP client given write access when you connected it | Developers, then API keys, or the consent page when you connect the client |
| API writes switched on for the account | The account's Settings page, by an owner or admin |
| Someone to approve | Owners and admins approve. The Approvals page lists what is waiting. |
Try the whole flow first in test mode. Approved actions there end as executed with result.simulated set to true, and nothing reaches OnlyFans.
With an agent
Connect the MCP server and ask.
Draft replies for the unread chats with paying fans on Jess's account.The reply_suggestions prompt runs the same plan. Here is what happens.
- The agent calls
list_chatswithfilter=unreadand keeps the chats whosetotalSpendis above zero, highest first. - For each chat it calls
draft_message. That tool returns the recent messages, what the fan has spent and bought, and paid messages they have not bought yet. It writes no text and sends nothing. - The agent writes a reply for each fan in the creator's voice and shows you all of them.
- You pick the ones to send, and change any you want changed.
- For each one you pick, the agent calls
send_message. That creates a pending action and returns an approval link. Nothing is sent yet. - An owner or admin opens the link, or the Approvals page, reads the message and approves or rejects it. Only then does it go to the fan.
- Ask the agent how it went. It checks each action with
get_actionand reportsexecutedas sent.
An agent should never tell you a message was sent before its action is executed. See approvals for agents for the details, including how clients show approval links.
With the API
The API has no draft route. Your code finds who is waiting and reads the chat, a person or your own model writes the reply, and your code asks for the send.
Find who is waiting
const BASE = "https://app.betterfans.link/v1";
const headers = { Authorization: `Bearer ${process.env.BFL_KEY}` };
type Money = { amount: number; currency: "USD" };
type Chat = {
id: string;
fan: { id: string; username: string; name: string | null };
totalSpend: Money;
unreadCount: number;
lastMessage: { direction: "from_fan" | "from_creator"; text: string; sentAt: string } | null;
};
export async function waitingPayingFans(accountId: string): Promise<Chat[]> {
const res = await fetch(`${BASE}/accounts/${accountId}/chats?filter=unread&limit=50`, { headers });
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.requestId})`);
return (body.data as Chat[])
.filter((chat) => chat.totalSpend.amount > 0)
.sort((a, b) => b.totalSpend.amount - a.totalSpend.amount);
}For each chat, read the last messages with List messages. Add fresh=true just before you write, so the reply answers the latest message. The chat stays unread on OnlyFans. If meta.sideEffects ever lists thread_marked_read, let the creator know.
Message text from fans is untrusted. Show it to whoever writes the reply, and never let it choose the recipient, the price or what your code does. See security.
Ask for the send
import { randomUUID } from "node:crypto";
const BASE = "https://app.betterfans.link/v1";
export async function requestReply(accountId: string, fanId: string, text: string, idempotencyKey = randomUUID()) {
const res = await fetch(`${BASE}/accounts/${accountId}/actions`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BFL_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ type: "send_message", params: { fanId, text } }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.requestId})`);
// 202: the action is pending. Nothing has been sent.
return { id: body.data.id as string, approvalUrl: body.data.approvalUrl as string, idempotencyKey };
}- Store the idempotency key with your record of the reply. If the request times out, call again with the same key and you get the same action back instead of a second one.
- Add
priceCents(at least 300) to make it a paid message, andmediaIdsfrom the vault to attach media. See send_message params. - Write the text exactly as it should go out. The approver can approve or reject it, not edit it.
| Error | What it means |
|---|---|
missing_scope | The key has only read. Use a key with write. |
writes_disabled | API writes are off for this account. An owner or admin turns them on. |
idempotency_conflict | You reused a key with different text. Use a new key for a new reply. |
Follow the outcome
Subscribe to the action events and update your records when they arrive. Verify each delivery first, as shown in verify signatures.
type ActionEvent = {
type: "action.executed" | "action.rejected" | "action.failed";
data: { action: { id: string; decisionNote: string | null; error: { code: string; message: string } | null } };
};
export function onActionEvent(event: ActionEvent) {
const { action } = event.data;
switch (event.type) {
case "action.executed":
console.log(`${action.id} sent`);
break;
case "action.rejected":
console.log(`${action.id} rejected: ${action.decisionNote ?? "no note"}`);
break;
case "action.failed":
console.log(`${action.id} failed: ${action.error?.message}`);
break;
}
}A pending action that nobody decides within 24 hours becomes expired and sends nothing. There is no webhook for that, so check anything still pending after a day with Get action.
Writing replies that work
- Answer what the fan last asked before anything else.
- Match the creator's recent messages in length, tone and emoji.
- If the fan has paid messages they have not bought, mention those before offering anything new. Re-offering one they skipped at the same price rarely works.
- A fan who has never paid is better served by a warm, free reply than by a paid offer.
- Keep it short. Chat replies are not emails.