BetterFans Link: the OnlyFans APIBetterFans Link

Whales and churn

Rank fans by lifetime spend, see what each one buys, and flag the big spenders who are slipping away before they are gone.

A few fans bring in most of a creator's revenue. This guide finds them, shows what they buy, and flags the ones who have stopped buying or are about to leave, so your chatters know whom to talk to first.

With an agent

Connect the MCP server and ask.

Who are Jess's top spenders, and which of them have gone quiet?

The whale_report prompt runs the same plan with the dates filled in. Pass an account name, @username or id, or leave it out to cover every account. The agent reads only. If it suggests a message, it asks before it requests one, and a person approves the request.

With the API

StepRoute
Top fans by lifetime spendList fans with sort=spend&status=all
Spend by type, subscription and last purchaseGet fan
Purchases in the last 90 daysList transactions with fanId

status=all matters. The default lists only active subscribers, and the fans whose subscription ran out are exactly the ones you want to see.

whales.ts
const BASE = "https://app.betterfans.link/v1";
const ACCOUNT_ID = process.env.ACCOUNT_ID!;

type Money = { amount: number; currency: "USD" };
type Page<T> = { data: T; hasMore?: boolean; nextCursor?: string | null };

async function get<T>(path: string, query: Record<string, string> = {}): Promise<Page<T>> {
  const res = await fetch(`${BASE}/accounts/${ACCOUNT_ID}${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 = (cents: number) => `$${(cents / 100).toFixed(2)}`;
const DAY = 24 * 60 * 60 * 1000;
const since = new Date(Date.now() - 90 * DAY).toISOString();

const { data: fans } = await get<{ id: string; username: string; name: string | null }[]>("/fans", {
  sort: "spend",
  status: "all",
  limit: "10",
});

for (const summary of fans) {
  const { data: fan } = await get<{
    spend: { total: Money; subscriptions: Money; tips: Money; messages: Money; posts: Money; other: Money };
    subscription: { status: "active" | "expired" | "never"; renews: boolean | null };
    lastPurchaseAt: string | null;
  }>(`/fans/${summary.id}`);

  // Spend in the last 90 days, across every page.
  let recent = 0;
  let cursor: string | null | undefined;
  do {
    const page = await get<{ gross: Money }[]>("/transactions", {
      fanId: summary.id,
      from: since,
      limit: "100",
      ...(cursor ? { cursor } : {}),
    });
    recent += page.data.reduce((sum, t) => sum + t.gross.amount, 0);
    cursor = page.hasMore ? page.nextCursor : null;
  } while (cursor);

  const { total, ...byType } = fan.spend;
  const [mostly] = Object.entries(byType).sort((a, b) => b[1].amount - a[1].amount);
  const quietDays = fan.lastPurchaseAt ? Math.floor((Date.now() - Date.parse(fan.lastPurchaseAt)) / DAY) : null;

  const reasons: string[] = [];
  if (quietDays === null || quietDays > 14) reasons.push(quietDays === null ? "never bought" : `no purchase in ${quietDays} days`);
  if (fan.subscription.status === "expired") reasons.push("subscription expired");
  if (fan.subscription.renews === false) reasons.push("renewal turned off");

  console.log(
    `${summary.name ?? summary.username} (@${summary.username})`,
    `lifetime ${usd(total.amount)}, last 90 days ${usd(recent)}, mostly ${mostly?.[0]}`,
    reasons.length ? `AT RISK: ${reasons.join(", ")}` : "",
  );
}
BFL_KEY=bfl_live_... ACCOUNT_ID=123456789 bun whales.ts

That is 10 fan reads plus at least 10 transaction reads per account, well inside the rate limit. For more fans, keep a few requests in flight at a time rather than all at once.

What counts as at risk

The script flags a whale on any of the first three signals. The fourth is worth a look by hand. Tune the thresholds to how often the creator's fans usually buy.

SignalFieldWhy it matters
No purchase in 14 dayslastPurchaseAtBig spenders who stop buying rarely come back on their own.
Subscription expiredsubscription.statusThey can no longer see new posts.
Renewal turned offsubscription.renewsThey plan to leave when the period ends.
Last 90 days far below their lifetime paceTransactions with fanIdSpend is fading even if they still buy now and then.

Before anyone reaches out, read the end of the chat with List messages. An unanswered question or a skipped paid message usually explains the silence better than any number.

Reading the numbers

  • spend.total is lifetime gross. spend.net is the creator's share. Label which one you show.
  • Fan spend is part of the account's revenue. Never add the two.
  • A fan who never subscribed can still buy paid messages. subscription.status of never is not churn.
  • Presence is a guess. A fan missing from Online fans is not known to be offline.

Next steps

On this page