Attribution
Read the Sources leaderboard — revenue by source, country, link, and fan, reconciled to your account net.
Attribution answers one question: where did each paying fan come from, and how much did they pay? TEASE takes every transaction on your OnlyFans account, resolves each paying fan to a single acquisition source, and rolls the money up into a leaderboard that always reconciles to your account net — no double counting, no money that quietly disappears.
The result is a revenue-by-source ranking you can drill from source → country → link → fan, split by money type (subscriptions, rebills, PPV, tips), tagged new-vs-returning, with trial→paid conversion and the fan-economics ratios (ARPPU, APC, APV, ARPNU) computed for you.
Attribution is read from /api/admin/stats
There is no standalone /attribution endpoint. The whole leaderboard, its drills, and the fan
economics are returned inside /api/admin/stats under the revenue object — revenue.sources
(the leaderboard), revenue.fans and revenue.fans_account (economics). The per-link
geo/source breakdown is a separate read,
/api/admin/domain_links/smartlinks/breakdown. All money values are integer cents.
The mental model
Every paying fan lands in exactly one of three buckets. The buckets are disjoint and their revenue sums to your account total.
| Bucket | Meaning |
|---|---|
| Attributed | The fan resolved to a real source (a domain/link you own). Their money appears in a sources row. |
| Organic | The fan paid, but carried no source signal — direct OnlyFans traffic, reposts, word of mouth. |
| Pending | The fan is known but not yet resolvable to a source (for example, a free-trial subscriber who hasn't produced a source signal). Pending is never silently folded into organic — it is its own row so you can see undetermined revenue honestly. |
Why the buckets always add up
Each sources row, plus the organic row, plus the pending row, sum to the account net for
the selected window. A reconciliation.balanced flag on the response confirms it. If you ever
see balanced: false, treat the numbers as provisional — it means a window of data was still
settling.
Enrich «organic» fans from their DMs
Many organic fans leave clues about where they came from — and much more — inside their DM
history. The «Фаны» tab (under Раскладка / Sources) reads each fan's conversation locally
and, with one BYO-key LLM pass per fan, extracts:
- a portrait — a 13-field sales dossier (who they are, what they buy, how to approach them);
- their country — inferred from what they tie to themselves (a place, a language, a timezone);
- their discovery source — when they reference it (a platform they mention browsing, or a content-format tell like "your reel" → Instagram).
Pick how many fans to process on the slider and press «Запустить разбор N фанов» — once.
The panel then drives the whole sweep for you: the engine slowly and safely pulls each fan's DM
history from OnlyFans (top-spenders first, one small batch per engine cycle), and we portrait each
fan as their conversation lands. You never re-click — cards fill in over time, and the progress line
shows queued / captured / portraited. «Остановить» halts further harvesting (batches already in
flight finish on their own). A confident DM source (≥ 0.6) replaces that fan's organic bucket in
the leaderboard; weaker signals still show on the fan card without touching the money split. Country
is the richest DM signal — an organic fan rarely states their source outright, so treat source-from-DM
as a bonus on top of the portrait + country.
The sweep is ban-safe by construction: the harvest is the engine's own passive, serial, human-paced capture (one thread at a time, self-halting on any throttle); the panel only queues the next batch — it never drives OnlyFans directly — and the portrait is one local LLM turn over already-captured text. Every read is per-creator scoped.
Read the leaderboard
The leaderboard is windowed by the range you pass and by the lifetime toggle (see
Windowing). Read it from /api/admin/stats and take
revenue.sources.
curl "https://app.tease.link/api/admin/stats?range=30d" \
-H "Authorization: Bearer $TEASE_API_KEY"const res = await fetch(
'https://app.tease.link/api/admin/stats?range=30d',
{ headers: { Authorization: `Bearer ${process.env.TEASE_API_KEY}` } },
);
const stats = await res.json();
const leaderboard = stats.revenue.sources;import os, requests
res = requests.get(
"https://app.tease.link/api/admin/stats",
headers={"Authorization": f"Bearer {os.environ['TEASE_API_KEY']}"},
params={"range": "30d"},
)
leaderboard = res.json()["revenue"]["sources"]Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
range | string | no | Time window for the selected totals: 24h, 7d, 30d, 90d. Missing or unknown values use 7d. |
lifetime | boolean | no | When true and no complete custom bounds are supplied, totals and grouped aggregates use all-time allowed data and ignore range. Day-based trend axes stay capped to the latest 730 days and are coarsened for long spans. A complete from_ts/to_ts pair takes precedence. Defaults to false. |
from_ts / to_ts | integer | no | Optional explicit unix-second bounds. They override range only when both are provided; a lone bound is ignored. |
domain | string | no | Filters the funnel blocks. Attribution is account-wide by design (it is a fan property), so the leaderboard, fan economics, and reconciliation are not narrowed by this filter. |
source | string | no | Restricts traffic and click-attributed aggregates to one exact traffic-source label. Like domain, it does not narrow the account-wide revenue.sources leaderboard or its funnel counters. |
scope | string | no | Aggregation level: page, creator, or tenant. Pair with id; omit both for the caller's current page. Resolved pages are always restricted to the caller's authorized data scope. |
id | string | no | Page, creator, or tenant identifier selected by scope. Ignored when scope is omitted. |
tz | string | no | IANA time-zone name for calendar-day buckets and axes, for example Europe/Berlin. Missing or invalid values use UTC. |
Compatibility update — 2026-08-11
The legacy revenue.ltv_* fields now use the same canonical seven-stage fan-source resolver as
revenue.sources. Fans resolved to a real source move to the attributed side; canonical
organic and pending both remain on the legacy ltv_organic side because that compatibility
split has no third bucket. In the measured account this reclassifies 285 fans / $44,609.89,
not 305 / $45,097.97: the earlier figure incorrectly included 20 canonical-pending fans. The
response shape, ltv_total, and reconciliation do not change. This is a
reconciliation-preserving classification correction, not a revenue restatement.
Response — revenue.sources
{
"sources": [
{
"domain_source": "ig-main",
"source_label": "Main IG",
"channel": "instagram",
"subs": 312,
"spenders": 188,
"revenue_net_cents": 421550,
"revenue_gross_cents": 502430,
"pct": 0.41,
"new_fans": 240,
"returning_fans": 72,
"trial_subs": 96,
"trial_to_paid": 51,
"funnel": { "new": 205, "old": 37, "ftl": 96, "paid": 112, "paid_after": 74, "ftl_paid": 38, "entered": 250 },
"by_kind": [
{ "kind": "subscription", "net_cents": 184200, "count": 240 },
{ "kind": "rebill", "net_cents": 96100, "count": 130 },
{ "kind": "ppv", "net_cents": 121050, "count": 410 },
{ "kind": "tips", "net_cents": 20200, "count": 88 }
],
"countries": [
{ "country": "US", "subs": 140, "spenders": 92, "revenue_net_cents": 210400, "revenue_gross_cents": 248900 },
{ "country": "GB", "subs": 51, "spenders": 33, "revenue_net_cents": 74100, "revenue_gross_cents": 88200 }
],
"links": [
{ "link_ref": "lnk_8fq2", "subs": 201, "spenders": 130, "revenue_net_cents": 290300, "revenue_gross_cents": 345100 }
]
}
],
"organic": {
"domain_source": "__organic__", "channel": "", "subs": 410, "spenders": 260,
"revenue_net_cents": 318900, "revenue_gross_cents": 379400, "pct": 0.31,
"new_fans": 410, "returning_fans": 0, "trial_subs": 0, "trial_to_paid": 0,
"funnel": { "new": 0, "old": 0, "ftl": 0, "paid": 0, "paid_after": 0, "ftl_paid": 0, "entered": 0 },
"by_kind": [], "countries": [], "links": []
},
"pending": {
"domain_source": "__pending__", "channel": "", "subs": 88, "spenders": 0,
"revenue_net_cents": 0, "revenue_gross_cents": 0, "pct": 0.0,
"new_fans": 88, "returning_fans": 0, "trial_subs": 88, "trial_to_paid": 0,
"funnel": { "new": 0, "old": 0, "ftl": 0, "paid": 0, "paid_after": 0, "ftl_paid": 0, "entered": 0 },
"by_kind": [], "countries": [], "links": []
},
"total_net_cents": 1025840,
"total_gross_cents": 1221230,
"by_kind": [
{ "kind": "subscription", "net_cents": 402100, "count": 590 },
{ "kind": "rebill", "net_cents": 221400, "count": 318 },
{ "kind": "ppv", "net_cents": 351900, "count": 1204 },
{ "kind": "tips", "net_cents": 50440, "count": 210 },
{ "kind": "other", "net_cents": 0, "count": 0 }
],
"coverage": { "attributed_subs": 740, "organic_subs": 410, "pending_subs": 88, "trial_unknown_subs": 88 },
"trial_subs": 184,
"trial_to_paid": 97,
"reconciliation": {
"balanced": true,
"sources_net_cents": 706940,
"organic_net_cents": 318900,
"pending_net_cents": 0,
"total_net_cents": 1025840
},
"since": "2026-06-01"
}Leaderboard fields
| Field | Type | Description |
|---|---|---|
sources | array | One row per resolved source, sorted by revenue_net_cents descending. Row shape below. |
organic | object | The organic bucket, same row shape. Always present, even when empty. |
pending | object | The pending/undetermined bucket, same row shape. Always present and never merged into organic. |
total_net_cents | integer | Account net for the window. Equals sources + organic + pending. |
total_gross_cents | integer | Account gross (before fees/VAT) for the window. |
by_kind | array | Global money-by-type split. Σ net_cents equals total_net_cents. |
coverage | object | Sub-census of fans by bucket — see Coverage. |
trial_subs | integer | Account-wide distinct free-trial fans in the window. |
trial_to_paid | integer | Of those trial fans, how many later paid (net over 0). |
reconciliation | object | The balance check — see Reconciliation. |
since | string | The "since" anchor date (YYYY-MM-DD), or "lifetime" when lifetime=true. |
Source row fields
Each entry in sources (and the organic / pending rows) has this shape:
| Field | Type | Description |
|---|---|---|
domain_source | string | The source key. Sentinels __organic__ and __pending__ mark the non-attributed rows. |
source_label | string | Friendly persona label for the source (e.g. Main IG). Sentinels carry no label. |
channel | string | Channel tag for the source (e.g. instagram, tiktok); empty for sentinels. |
subs | integer | Distinct fans attributed to this source in the window. |
spenders | integer | Of those, how many paid (net over 0). |
revenue_net_cents | integer | Net revenue for this source. |
revenue_gross_cents | integer | Gross revenue for this source. |
pct | float | Share of total_net_cents (0..1; 0 when the total is 0). |
new_fans | integer | Lifecycle split: all row fans not classified as returning; new_fans + returning_fans = subs. This is not the windowed funnel.new stage. |
returning_fans | integer | Fans with a transaction before the fixed account tracking anchor, plus windowed live re-subscribes whose current action is return. |
trial_subs | integer | Distinct free-trial fans in this source. |
trial_to_paid | integer | Of those, how many converted to paid. |
funnel | object | Required seven-counter Full Attribution funnel for this row; see below. |
by_kind | array | Per-source money-by-type. Σ net_cents equals the row's revenue_net_cents. |
countries | array | Per-country drill for this source — see below. |
links | array | Per-link drill for this source — see below. |
subs counts distinct fans; spenders counts the subset who actually paid. A free-trial-heavy
source can have many subs but few spenders — trial_to_paid is the number that matters for
judging trial quality.
Full Attribution funnel
Every real-source, organic, and pending row always carries the same required funnel
object. It counts entry-ledger events plus live re-subscribes for the requested window;
calculating it never changes the row's money, lifecycle fields, or reconciliation. For a preset,
or a custom upper bound equal or clamped to request time, this funnel has an open upper edge: its
calculation samples current time and accepts entry/return timestamps strictly before that sample
plus one hour. This is a vendor-clock-skew allowance, not an exact to_ts + 3600 boundary. A
historical custom upper bound below request time stays exact.
| Field | Type | Description |
|---|---|---|
new | integer | First entries in the window with no transaction earlier than one hour before from_ts; the final hour is treated as boundary clock skew. |
old | integer | Live re-subscribes in the window whose first entry is not in that window. |
ftl | integer | Entries classified as free-trial-link (trial). |
paid | integer | Entries classified as paid-link (paid). |
paid_after | integer | Entries or returns with positive net money no earlier than one hour before entry. For a paid, unknown-type, or return entry, one subscription transaction within ±1 hour is treated as the entry payment and excluded. |
ftl_paid | integer | Trial entries included in paid_after, with the same one-hour clock-skew tolerance; always <= ftl. |
entered | integer | All entries, including unknown entry type, plus old returns. |
new_fans and funnel.new answer different questions and can differ. new_fans is the
leaderboard's lifecycle complement of returning_fans across all subs assigned to the row.
funnel.new is window-scoped: it requires an entry inside this request's effective entry window
and no transaction earlier than its one-hour lower-bound tolerance. Lifecycle new_fans uses the fixed account tracking anchor
instead. Neither field is a subset of the other; when a window crosses that anchor, either count
can be larger.
Source-scoped team access
For a team member restricted to specific sources, every row still contains the full seven-key
funnel object, but all seven counters are 0. Display/persona folding can combine raw source
buckets, so zeroing the new people counters avoids exposing counts outside that member's grant.
Money by type
by_kind appears at two levels — globally (revenue.sources.by_kind) and per source
(row.by_kind) — and uses the same ordered keys every time:
kind | Covers |
|---|---|
subscription | New paid subscriptions. |
rebill | Recurring subscription renewals. |
ppv | Pay-per-view: paid posts, paid messages, locked content. |
tips | Tips. |
other | Anything not in the above. |
Each entry is { "kind", "net_cents", "count" }. Within any bucket, Σ net_cents reconciles to
that bucket's revenue_net_cents, so a "subscriptions vs PPV" chart never drifts from the
headline.
Per-country drill
Every source row carries a countries array. Each entry breaks that source's revenue down by the
fan's country:
{ "country": "US", "subs": 140, "spenders": 92, "revenue_net_cents": 210400, "revenue_gross_cents": 248900 }| Field | Type | Description |
|---|---|---|
country | string | ISO-3166 alpha-2 code. |
subs | integer | Distinct fans from this country in this source. |
spenders | integer | Of those, how many paid. |
revenue_net_cents | integer | Net revenue from this country/source. |
revenue_gross_cents | integer | Gross revenue from this country/source. |
Per-link drill
The links array on each source row attributes revenue down to the individual
smart link the fan arrived through:
{ "link_ref": "lnk_8fq2", "subs": 201, "spenders": 130, "revenue_net_cents": 290300, "revenue_gross_cents": 345100 }For a geo-by-link matrix across a domain's links, read the dedicated breakdown:
curl "https://app.tease.link/api/admin/domain_links/smartlinks/breakdown?domain=yourbrand.link" \
-H "Authorization: Bearer $TEASE_API_KEY"const res = await fetch(
'https://app.tease.link/api/admin/domain_links/smartlinks/breakdown?domain=yourbrand.link',
{ headers: { Authorization: `Bearer ${process.env.TEASE_API_KEY}` } },
);
const breakdown = await res.json();res = requests.get(
"https://app.tease.link/api/admin/domain_links/smartlinks/breakdown",
headers={"Authorization": f"Bearer {os.environ['TEASE_API_KEY']}"},
params={"domain": "yourbrand.link"},
)
breakdown = res.json()This returns each link with its per-country and per-source split, so you can compare which link on
a domain pulls which geographies. The money reconciles to the same per-link totals you see in the
leaderboard's links array.
Per-fan drill
Three reads take you from a source row down to the individual fans behind it.
Spenders for one source
List the paying fans attributed to a specific domain/source:
curl "https://app.tease.link/api/admin/sources/yourbrand.link/spenders" \
-H "Authorization: Bearer $TEASE_API_KEY"Returns { domain, spenders: [...], totals: { revenue_cents, spenders, conversions } }. Each
spender row carries fan_id, fan_username, total_net_cents, purchases, country,
is_returning, pre_anchor_cents, first_at, and last_at.
Account-wide spenders
The windowed payer list across all sources, newest-money first:
curl "https://app.tease.link/api/admin/spenders?range=30d" \
-H "Authorization: Bearer $TEASE_API_KEY"One fan's card
Open a single fan for their lifetime totals, per-day chart, and transaction list:
curl "https://app.tease.link/api/admin/fan/FAN_ID" \
-H "Authorization: Bearer $TEASE_API_KEY"The card returns identity, total_net_cents, a klass of ours or organic, an
is_returning flag, a by_day net series, and the transactions list (newest first).
New vs returning, and trial → paid
Two product questions are answered inline on every row, so you don't have to compute them.
- New vs returning — the money-history branch uses the fixed account
funnel_tracking_sinceanchor, not the selected money window.returning_fansincludes fans with an earlier transaction and windowed live re-subscribes whose currentof_fansaction isreturn; the rest arenew_fans. The aggregate row is the authoritative split: legacy per-fan drill flags only reflect the transaction-history branch. - Trial → paid —
trial_subsis the count of free-trial fans in the bucket;trial_to_paidis how many of them later produced paid revenue. Read together they are your trial-funnel conversion for that source.
Free trials count as fans, not as revenue
A $0 free-trial subscribe is a real fan (it counts in subs and trial_subs) but contributes
no money until the fan pays. That is why a source can show strong subs while its
revenue_net_cents lags — watch trial_to_paid to see whether the trials mature.
Coverage census
coverage is an honest sub-level census — how many fans landed in each bucket — so you can see
attribution completeness at a glance. It moves no money, so it never affects reconciliation.
| Field | Description |
|---|---|
attributed_subs | Fans resolved to a real source row. |
organic_subs | Fans in the organic bucket. |
pending_subs | Fans in the pending bucket. |
trial_unknown_subs | Live of_fans free-trial fans that reached the fallback with no source or stored pending mapping, counted within pending (they read as «undetermined», not «organic»). Later spend does not remove a fan from this counter; stored-pending and ledger-only trial entries may also be pending but are not included. |
Fan economics
The economics ratios are returned as outputs in two scopes alongside the leaderboard:
revenue.fans (attributed scope) and revenue.fans_account (whole-account scope). You read them;
TEASE computes them.
{
"scope": "account",
"arppu_cents": 2240,
"apc": 3.1,
"apv_cents": 720,
"arpnu_cents": 1180,
"paying_fans": 458,
"payments": 1420,
"new_fans": 240,
"new_paying_fans": 132,
"new_to_paying_pct": 55.0,
"cohort_reliable": true,
"cohort_revenue_cents": 283200,
"maturing": false
}| Field | Type | Description |
|---|---|---|
arppu_cents | integer | null | ARPPU — average revenue per paying user (net ÷ distinct paying fans). null when there are no payers. |
apc | float | null | APC — average payments per paying customer (2 decimals). |
apv_cents | integer | null | APV — average payment value (net ÷ payments). |
arpnu_cents | integer | null | ARPNU — average revenue per new user, over the cohort's lifetime money. |
paying_fans | integer | Distinct fans who paid in the window. |
payments | integer | Number of paying transactions in the window. |
new_fans | integer | Fans whose first-ever subscribe falls in the window (free trials included). |
new_paying_fans | integer | Of the cohort, how many have paid (any time). |
new_to_paying_pct | float | null | New→paying conversion %, withheld (null) on cohorts too small to be statistically meaningful. |
cohort_reliable | boolean | false when the cohort is too small to publish a ratio. |
cohort_revenue_cents | integer | Lifetime net of the cohort fans (matures over weeks). |
maturing | boolean | true when the window is recent enough that late rebills/PPV haven't fully landed yet — treat the cohort numbers as still settling. |
Small cohorts withhold the percentage
On a short window a cohort can be a handful of fans, and because the source data is payer-biased
the new→paying ratio can read a spurious 100%. When the cohort is below the reliability
threshold, TEASE returns the raw counts but sets new_to_paying_pct: null and
cohort_reliable: false so your UI can show "not enough data" instead of a misleading number.
Windowing: lifetime vs selected range
Attribution supports two windowing modes, chosen with the lifetime parameter:
- Selected range (default,
lifetime=false) — the «new traffic» view. Totals use the exactrangeor explicitfrom_ts/to_ts; the account tracking anchor does not floor money. Thesincefield is a legacy anchor label, not the effective lower bound. - Lifetime (
lifetime=true) — the «за всё время (LTV)» view. Without a complete customfrom_ts/to_tspair, totals and grouped aggregates use every allowed revenue transaction all-time and ignorerange;sincereads"lifetime". Day-based trend axes are intentionally limited to the latest 730 days and coarsened for long spans. A complete custom pair overrideslifetime.
Both modes preserve the reconciliation invariant — the buckets always add up to the total for whichever window you asked for.
Reconciliation
The reconciliation object is your integrity check on the leaderboard:
{
"balanced": true,
"sources_net_cents": 706940,
"organic_net_cents": 318900,
"pending_net_cents": 0,
"total_net_cents": 1025840
}| Field | Type | Description |
|---|---|---|
balanced | boolean | true when sources + organic + pending exactly equals total_net_cents. |
sources_net_cents | integer | Sum of all attributed source rows. |
organic_net_cents | integer | The organic bucket net. |
pending_net_cents | integer | The pending bucket net. |
total_net_cents | integer | Account net for the window. |
Trust the balanced flag
balanced: true is the contract that every cent in your account net is accounted for in exactly
one bucket. Build dashboards against it — if you ever surface a "balanced: false", flag the
window as still settling rather than publishing the split as final.
FAQ
Next steps
- Smart Links — create the trackable links these sources resolve to.
- Telegram attribution — the same question for the Telegram sales bot's revenue.
- Webhooks & CAPI — forward attributed conversions to Meta and TikTok.
- API Reference — the full
/api/admin/statsschema.
Scanning & data freshness
How TEASE reads your OnlyFans account — what it scans, how fresh the data is, how you trigger a scan on demand, and the ban-safe discipline that makes it all safe.
Telegram attribution
See where each paying Telegram fan came from — exact deep-link matches, country-and-time click matching, and an honest precision badge on every row.