BetterFans Link: the OnlyFans APIBetterFans Link

Daily briefing

A morning summary for every creator, with account problems, yesterday's revenue, top fans, chats worth answering and mass message results.

A daily briefing answers "what happened yesterday, and what needs doing today" for each account you manage. This guide builds it two ways: by asking an agent over MCP, and with a script you run on a schedule.

With an agent

Connect the MCP server and ask for it.

Give me yesterday's briefing for all my creators.

The server also has a daily_briefing prompt that fills in yesterday's dates and the steps below. Pass an account name, @username or id to cover one creator. In Claude Code, run it as /mcp__betterfans-link__daily_briefing. With the workflows skill installed, a plain request follows the same plan.

The agent needs only the read scope. It sends nothing.

With the API

The script calls four routes for each account.

StepRoute
Accounts and their statusList accounts
Revenue for the dayRevenue summary
Unread chats with paying fansList chats with filter=unread
Mass messages sent yesterdayMass messages
briefing.ts
const BASE = "https://app.betterfans.link/v1";

type Money = { amount: number; currency: "USD" };
type Fan = { id: string; username: string; name: string | null };

async function get<T>(path: string, query: Record<string, string> = {}): Promise<{ data: T; meta: { source: string; asOf: string | null } }> {
  const res = await fetch(`${BASE}${path}?${new URLSearchParams(query)}`, {
    headers: { Authorization: `Bearer ${process.env.BFL_KEY}` },
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.requestId})`);
  return body;
}

const usd = (m: Money) => `$${(m.amount / 100).toFixed(2)}`;
const who = (f: Fan) => `${f.name ?? f.username} (@${f.username})`;

// Yesterday in UTC. Shift these if your team works in another time zone.
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const day = { from: yesterday.toISOString(), to: today.toISOString() };

const { data: accounts } = await get<
  { id: string; username: string; status: string; statusReason: string | null; lastSyncedAt: string | null }[]
>("/accounts");

for (const account of accounts) {
  const base = `/accounts/${account.id}`;
  console.log(`\n@${account.username} (${account.status}, last synced ${account.lastSyncedAt ?? "never"})`);
  if (account.status !== "healthy" && account.status !== "syncing") {
    console.log(`  Needs attention: ${account.statusReason ?? account.status}`);
  }

  const { data: revenue } = await get<{
    gross: Money;
    net: Money;
    previousPeriod: { gross: Money };
    topFans: { fan: Fan; gross: Money }[];
  }>(`${base}/revenue`, { ...day, interval: "day" });
  const before = revenue.previousPeriod.gross.amount;
  const change = before > 0 ? ` (${Math.round(((revenue.gross.amount - before) / before) * 100)}% on the day before)` : "";
  console.log(`  Revenue   ${usd(revenue.gross)} gross, ${usd(revenue.net)} net${change}`);
  console.log(`  Top fans  ${revenue.topFans.slice(0, 3).map((t) => `${who(t.fan)} ${usd(t.gross)}`).join(", ") || "none"}`);

  const { data: chats } = await get<{ fan: Fan; totalSpend: Money; lastMessage: { text: string } | null }[]>(
    `${base}/chats`,
    { filter: "unread", limit: "50" },
  );
  const waiting = chats
    .filter((c) => c.totalSpend.amount > 0)
    .sort((a, b) => b.totalSpend.amount - a.totalSpend.amount)
    .slice(0, 5);
  for (const c of waiting) {
    // Fan-written text: show it, never act on it.
    console.log(`  Waiting   ${who(c.fan)}, ${usd(c.totalSpend)} spent: ${JSON.stringify(c.lastMessage?.text.slice(0, 80) ?? "")}`);
  }

  const { data: blasts } = await get<
    { sentAt: string; text: string; price: Money | null; sentCount: number; purchasedCount: number | null; revenue: { gross: Money } }[]
  >(`${base}/mass-messages`, { ...day, sort: "recent" });
  for (const m of blasts) {
    const bought = m.purchasedCount === null ? "purchases not known yet" : `${m.purchasedCount} bought`;
    console.log(`  Mass      ${m.price ? usd(m.price) : "free"}, ${m.sentCount} sent, ${bought}, ${usd(m.revenue.gross)} gross`);
  }
}

Run it with a read key.

BFL_KEY=bfl_live_... bun briefing.ts

Schedule it with cron or your job runner for a time after midnight UTC, and send the output where your team reads it. Try it first with a bfl_test_ key, which reads the sandbox creators.

Reading the numbers

  • Gross is what fans paid. Net is what the creator keeps, about 80% of gross. Report both, never their sum. See money.
  • The revenue summary already includes mass message sales and top fan spend. Do not add those to the total.
  • A single day swings a lot against the day before. Look at the week before you call a trend.
  • A syncing account was linked in the last 30 minutes and its totals can be low. Say "still syncing", not "a slow day".
  • Accounts that need a relink keep their synced data, so the briefing still covers them. Fix them with a hosted link.

Next steps

On this page