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
| Step | Route |
|---|---|
| Top fans by lifetime spend | List fans with sort=spend&status=all |
| Spend by type, subscription and last purchase | Get fan |
| Purchases in the last 90 days | List 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.
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.tsThat 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.
| Signal | Field | Why it matters |
|---|---|---|
| No purchase in 14 days | lastPurchaseAt | Big spenders who stop buying rarely come back on their own. |
| Subscription expired | subscription.status | They can no longer see new posts. |
| Renewal turned off | subscription.renews | They plan to leave when the period ends. |
| Last 90 days far below their lifetime pace | Transactions with fanId | Spend 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.totalis lifetime gross.spend.netis 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.statusofneveris not churn. - Presence is a guess. A fan missing from Online fans is not known to be offline.
Next steps
- Reach the fans at risk with a message a person approves. See reply to unread paying fans for the approval flow.
- Keep the list current with the
transaction.createdwebhook instead of re-reading it.
Daily briefing
A morning summary for every creator, with account problems, yesterday's revenue, top fans, chats worth answering and mass message results.
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.