TEASEDocs
Guides

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.

BucketMeaning
AttributedThe fan resolved to a real source (a domain/link you own). Their money appears in a sources row.
OrganicThe fan paid, but carried no source signal — direct OnlyFans traffic, reposts, word of mouth.
PendingThe 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

FieldTypeRequiredDescription
rangestringnoTime window for the selected totals: 24h, 7d, 30d, 90d. Missing or unknown values use 7d.
lifetimebooleannoWhen 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_tsintegernoOptional explicit unix-second bounds. They override range only when both are provided; a lone bound is ignored.
domainstringnoFilters 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.
sourcestringnoRestricts 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.
scopestringnoAggregation 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.
idstringnoPage, creator, or tenant identifier selected by scope. Ignored when scope is omitted.
tzstringnoIANA 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

FieldTypeDescription
sourcesarrayOne row per resolved source, sorted by revenue_net_cents descending. Row shape below.
organicobjectThe organic bucket, same row shape. Always present, even when empty.
pendingobjectThe pending/undetermined bucket, same row shape. Always present and never merged into organic.
total_net_centsintegerAccount net for the window. Equals sources + organic + pending.
total_gross_centsintegerAccount gross (before fees/VAT) for the window.
by_kindarrayGlobal money-by-type split. Σ net_cents equals total_net_cents.
coverageobjectSub-census of fans by bucket — see Coverage.
trial_subsintegerAccount-wide distinct free-trial fans in the window.
trial_to_paidintegerOf those trial fans, how many later paid (net over 0).
reconciliationobjectThe balance check — see Reconciliation.
sincestringThe "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:

FieldTypeDescription
domain_sourcestringThe source key. Sentinels __organic__ and __pending__ mark the non-attributed rows.
source_labelstringFriendly persona label for the source (e.g. Main IG). Sentinels carry no label.
channelstringChannel tag for the source (e.g. instagram, tiktok); empty for sentinels.
subsintegerDistinct fans attributed to this source in the window.
spendersintegerOf those, how many paid (net over 0).
revenue_net_centsintegerNet revenue for this source.
revenue_gross_centsintegerGross revenue for this source.
pctfloatShare of total_net_cents (0..1; 0 when the total is 0).
new_fansintegerLifecycle split: all row fans not classified as returning; new_fans + returning_fans = subs. This is not the windowed funnel.new stage.
returning_fansintegerFans with a transaction before the fixed account tracking anchor, plus windowed live re-subscribes whose current action is return.
trial_subsintegerDistinct free-trial fans in this source.
trial_to_paidintegerOf those, how many converted to paid.
funnelobjectRequired seven-counter Full Attribution funnel for this row; see below.
by_kindarrayPer-source money-by-type. Σ net_cents equals the row's revenue_net_cents.
countriesarrayPer-country drill for this source — see below.
linksarrayPer-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 spenderstrial_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.

FieldTypeDescription
newintegerFirst entries in the window with no transaction earlier than one hour before from_ts; the final hour is treated as boundary clock skew.
oldintegerLive re-subscribes in the window whose first entry is not in that window.
ftlintegerEntries classified as free-trial-link (trial).
paidintegerEntries classified as paid-link (paid).
paid_afterintegerEntries 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_paidintegerTrial entries included in paid_after, with the same one-hour clock-skew tolerance; always <= ftl.
enteredintegerAll 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:

kindCovers
subscriptionNew paid subscriptions.
rebillRecurring subscription renewals.
ppvPay-per-view: paid posts, paid messages, locked content.
tipsTips.
otherAnything 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 }
FieldTypeDescription
countrystringISO-3166 alpha-2 code.
subsintegerDistinct fans from this country in this source.
spendersintegerOf those, how many paid.
revenue_net_centsintegerNet revenue from this country/source.
revenue_gross_centsintegerGross revenue from this country/source.

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_since anchor, not the selected money window. returning_fans includes fans with an earlier transaction and windowed live re-subscribes whose current of_fans action is return; the rest are new_fans. The aggregate row is the authoritative split: legacy per-fan drill flags only reflect the transaction-history branch.
  • Trial → paidtrial_subs is the count of free-trial fans in the bucket; trial_to_paid is 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.

FieldDescription
attributed_subsFans resolved to a real source row.
organic_subsFans in the organic bucket.
pending_subsFans in the pending bucket.
trial_unknown_subsLive 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
}
FieldTypeDescription
arppu_centsinteger | nullARPPU — average revenue per paying user (net ÷ distinct paying fans). null when there are no payers.
apcfloat | nullAPC — average payments per paying customer (2 decimals).
apv_centsinteger | nullAPV — average payment value (net ÷ payments).
arpnu_centsinteger | nullARPNU — average revenue per new user, over the cohort's lifetime money.
paying_fansintegerDistinct fans who paid in the window.
paymentsintegerNumber of paying transactions in the window.
new_fansintegerFans whose first-ever subscribe falls in the window (free trials included).
new_paying_fansintegerOf the cohort, how many have paid (any time).
new_to_paying_pctfloat | nullNew→paying conversion %, withheld (null) on cohorts too small to be statistically meaningful.
cohort_reliablebooleanfalse when the cohort is too small to publish a ratio.
cohort_revenue_centsintegerLifetime net of the cohort fans (matures over weeks).
maturingbooleantrue 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 exact range or explicit from_ts/to_ts; the account tracking anchor does not floor money. The since field is a legacy anchor label, not the effective lower bound.
  • Lifetime (lifetime=true) — the «за всё время (LTV)» view. Without a complete custom from_ts/to_ts pair, totals and grouped aggregates use every allowed revenue transaction all-time and ignore range; since reads "lifetime". Day-based trend axes are intentionally limited to the latest 730 days and coarsened for long spans. A complete custom pair overrides lifetime.

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
}
FieldTypeDescription
balancedbooleantrue when sources + organic + pending exactly equals total_net_cents.
sources_net_centsintegerSum of all attributed source rows.
organic_net_centsintegerThe organic bucket net.
pending_net_centsintegerThe pending bucket net.
total_net_centsintegerAccount 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

On this page