Earnings & transactions
Cross-account aggregation, the chargeback trap, and platform money scales.
Balances and earnings
# One account, live
curl "$BASE/api/crm/$CRM/accounts/$OFUID/balances" -H "X-API-Key: $KEY"
curl "$BASE/api/crm/$CRM/accounts/$OFUID/earnings" -H "X-API-Key: $KEY"
# Whole panel, aggregated server-side across every connected account
curl "$BASE/api/crm/$CRM/balances/summary" -H "X-API-Key: $KEY"
curl "$BASE/api/crm/$CRM/earnings/summary" -H "X-API-Key: $KEY"The two /summary routes are the ones to reach for when building a dashboard —
they aggregate in one request instead of N live calls, and they work across both
platforms. See
GET /balances/summary and
GET /earnings/summary
for the full field lists.
/balances/summary serves last-known samples rather than live values — a live
panel-wide payout total would cost one platform round trip per account. Label it
with the oldest_sample_at / newest_sample_at it returns, and treat
accounts_never_sampled as "not counted yet" rather than zero. Each call to
GET /accounts/{of_user_id}/balances refreshes that account's sample.
Transactions
# Cached — zero platform requests, trustworthy total
curl "$BASE/api/crm/$CRM/accounts/$OFUID/transactions/cached?limit=100&offset=0" \
-H "X-API-Key: $KEY"
# One fan's history
curl "$BASE/api/crm/$CRM/accounts/$OFUID/fans/$FANID/transactions/cached" \
-H "X-API-Key: $KEY"
# Live walk (cursor-paginated — carry the marker forward)
curl "$BASE/api/crm/$CRM/accounts/$OFUID/purchases?limit=100" -H "X-API-Key: $KEY"Keep the cache current with
POST /accounts/{of_user_id}/transactions/refresh (see
Async jobs), and use
POST /accounts/{of_user_id}/backfill for history predating the connection.
The chargeback trap
This is the single most important thing on this page. Do not compute earnings with a plain sum over transaction amounts.
A chargeback is the original row relabelled — status flips to undo, tx_type
becomes chargeback, and amount/net stay positive. There is no reversing entry.
That means the correct treatment differs by what you are computing:
| Computing | Treatment |
|---|---|
Period revenue (/earnings/summary) | Exclude chargeback rows. A naive sum overstates by one chargeback; subtracting would double-count the reversal |
| Per-fan lifetime spend | Subtract them — the platform's canonical lifetime total still contains the original sale, so the delta has to reverse it out |
For per-fan spend, sign each row by status:
status | Meaning | Contribution |
|---|---|---|
done | Cleared; withdrawable | +net |
loading | Within the payout pending window (7 days by default) | +net |
undo | Chargeback or refund | −net |
| anything else | Unknown | 0 — ignore it |
def signed_total(rows):
total = 0.0
for row in rows:
status = row.get("status")
# `net` is what the creator received (gross minus the platform fee).
# `amount` is what the fan paid. The canonical lifetime total is net, so
# summing `amount` here inflates the result by roughly 1.25x.
if status in ("done", "loading"):
total += row["net"]
elif status == "undo":
total -= row["net"]
# Unknown statuses contribute nothing. Deliberately not treated as
# positive — if the platform adds a status, you want a visible
# discrepancy rather than a silently wrong number.
return totalIgnore unknown statuses rather than assuming they are positive
Treating an unrecognised status as income hides the problem. Contributing zero makes a new status show up as a divergence you can notice and fix.
Deleted fans still count. The money was real.
Per-fan lifetime spend
`GET /fans` does not use this formula
GET /fans reports total_spend as MAX(platform lifetime total, sum of captured tip/purchase events) — a hybrid that favours whichever source is higher. No signed
delta, no chargeback correction. It also returns spend_known: when that is 0, the
spend is unknown, not zero. total appears only if you pass ?with_total=true.
For the chargeback-corrected per-fan figure, read mapped_spent from
GET /accounts/{of_user_id}/fans/{fan_id}/transactions/cached.
The canonical model below is how per-fan spend is defined:
current_spent(fan) = canonical(fan) + signed_delta(fan, since = last subscriber sync)- canonical is the platform's own lifetime total for that fan, as of the last subscriber-cache sync.
- signed_delta is the status-signed sum of that fan's transactions after that sync timestamp.
The cutoff is strictly greater-than, so a transaction landing in the same second
as the sync stays inside canonical and is not double-counted.
A fan with transactions but no subscriber row yet gets canonical = 0 and is
flagged source: "tx-only" in the result, so you can tell a genuinely small
spender from one we have not fully synced.
Money scale differs by platform
OnlyFans reports money as decimal dollars, and the CRM routes pass those through
untouched — /api2/v2/* and the CRM money routes agree, so mixing them is safe.
Fansly reports integer tenths of a cent. 497496 is $497.50. The CRM routes
divide by 1000 on read and multiply by 1000 on write.
Reach a raw Fansly integer by any other path and you are out by 1000x
Fansly amounts are not cents — they are tenths of a cent. Sending a raw dollar value
to a Fansly write produces a PPV priced 1000x too low. The /api2/v2/* passthrough
rejects Fansly accounts outright, so raw Fansly integers are not reachable there.
Why two caches
Subscriber rows carry the platform's lifetime total per fan but are only as fresh as the last sync. Transactions are fresh and attributable per event but only cover a recent window, not all history. Combining them gives both lifetime accuracy and up-to-the-minute freshness — which is exactly what the formula above does.
If a figure looks wrong, refresh both caches before investigating anything else:
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/subscribers/refresh" -H "X-API-Key: $KEY"
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/transactions/refresh" -H "X-API-Key: $KEY"Payouts
GET /accounts/{of_user_id}/payout-account # payout destination
GET /accounts/{of_user_id}/payout-requests # history
POST /accounts/{of_user_id}/payout-requests # request a payout — OnlyFans onlyRequesting a payout is OnlyFans-only (501 on Fansly) and sits on the sensitive
rate tier. It is behind allow_of_write_actions, like every other write performed as the
account. Body: {"withdrawal_amount": 20}. The route pre-flights the platform's own
payout eligibility, so a blocked account gets a 400 with a blockers[] array
rather than an opaque platform error.
Referrals are separate money
GET /earnings/summary sums the transaction cache, and referral payouts never land
there — so by_category.referrals is reported but is always 0. Add referral money
explicitly if you need a true total — see
CRM API → Referrals:
GET /accounts/{of_user_id}/referrals
GET /accounts/{of_user_id}/referrals/earnings
GET /accounts/{of_user_id}/referrals/payout-requestsVerifying a figure
GET /accounts/{of_user_id}/earnings/verify
cross-checks the computed total against the platform's own chart for the same
window, and returns both numbers plus the difference. That tells you whether a
discrepancy is in the cache or in your aggregation.
It makes real upstream calls, so it is per-account and on the 100/minute sensitive
tier — do not fan it out across a panel. If the platform is unreachable,
live_available is false and live_total is null: that is "could not check",
not "matches".