The Only API docs

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 relabelledstatus 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:

ComputingTreatment
Period revenue (/earnings/summary)Exclude chargeback rows. A naive sum overstates by one chargeback; subtracting would double-count the reversal
Per-fan lifetime spendSubtract 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:

statusMeaningContribution
doneCleared; withdrawable+net
loadingWithin the payout pending window (7 days by default)+net
undoChargeback or refund−net
anything elseUnknown0 — 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 total

Ignore 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 only

Requesting 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-requests

Verifying 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".

On this page