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.
Your funnel doesn't only end on OnlyFans — part of the traffic is routed straight into a Telegram sales bot, and that bot produces its own revenue. Telegram attribution answers the same question Attribution answers for OnlyFans, but for the bot: which of your domains sent each paying Telegram fan, and how much did they pay?
The catch: the bot is reached through a plain redirect, so most fans arrive without a tracking
payload. TEASE therefore resolves each fan with a layered model — an exact match where one exists,
a time-and-country match for the bulk, and an honest organic residual — and stamps every row
with a precision badge so a calculation is never passed off as a fact.
The mental model
Every Telegram fan resolves to exactly one origin kind:
kind | Precision | Meaning |
|---|---|---|
cid | exact | The fan started the bot through a tracked deep link that carried a click id, and that click resolves to one of your domains. Bulletproof — but rare, because the everyday redirect into the bot carries no payload. |
tg_match | approx | The nearest preceding Telegram-bound click from the same country within a 5-minute window. This tier carries the bulk of the attribution. |
organic | approx | Neither signal — the honest residual bucket. |
How the time-and-country match works
When a fan first writes to the bot, TEASE looks back 5 minutes from that first message at the
human clicks that headed toward Telegram — the direct Telegram geo-redirects and the landing
pages' Telegram buttons — from the same country. The nearest click preceding the first
message wins, and its domain becomes the fan's origin. No qualifying click in the window (or no
country on the fan) → the fan stays organic.
Why five minutes
Someone you just sent to Telegram writes immediately. Measured on live data (2130 fans with a country and a first message), the gap between the nearest same-country click and the first message falls out like this: under 10 s — 29, 10–60 s — 665, 1–5 min — 170, 5–30 min — 31, 30–60 min — 26, 1–6 h — 67, 6–24 h — 45. Past five minutes the count collapses to a handful and then rises again at 1–6 h and 6–24 h — that is no longer a behavioural tail but coincidence: the wider the window, the likelier someone in the same country clicked for unrelated reasons. The old 24-hour window credited those coincidences to a domain.
The width is tunable without a deploy — TG_MATCH_WINDOW_SEC (seconds) in the API environment.
Default 300; garbage and non-positive values fall back to it. Widening the window buys more
attribution at the cost of truthfulness.
The precision badge never lies
precision is exact only for a real cid first touch. A time-and-country match is a
calculation, so it is always badged approx — and a match that cannot name a domain degrades to
organic instead of inventing a source. Render the badge next to the money; never present an
approx row as a measured fact.
Two honest lenses
The report deliberately mixes two windows, mirroring how the bot itself reports:
- Revenue (money) — every sale inside the window, dated by the sale, attributed to the
paying fan's resolved origin. An old fan (first message long before the window) who pays today
is counted today. This drives
summary.payers,summary.revenue_net_cents, the leaderboard money,countries, and the trend's revenue/payers. - Acquisition (funnel) — the arrival cohort: fans whose first message falls inside the
window, walked through arrived → wrote 5+ messages → paid in-period. This drives
summary.fans,funnel, per-sourcefans/msg5plus/nonpayers, and the trend's arrivals.
Why fans and payers don't reconcile against each other
summary.fans counts new arrivals while summary.payers counts everyone who paid in the
window — old fans included. A source can therefore show real money with zero new arrivals
(its old fans keep paying), and funnel.payers (payers among the arrivals) is always ≤
summary.payers.
Read the report
GET /api/admin/tg-attribution returns the whole report in one owner-scoped read. All money
values are integer net cents; responses are briefly cached server-side.
curl "https://app.tease.link/api/admin/tg-attribution?period_from=1751328000&period_to=0" \
-H "Authorization: Bearer $TEASE_API_KEY"const res = await fetch(
'https://app.tease.link/api/admin/tg-attribution?period_from=1751328000&period_to=0',
{ headers: { Authorization: `Bearer ${process.env.TEASE_API_KEY}` } },
);
const report = await res.json();import os, requests
res = requests.get(
"https://app.tease.link/api/admin/tg-attribution",
headers={"Authorization": f"Bearer {os.environ['TEASE_API_KEY']}"},
params={"period_from": 1751328000, "period_to": 0},
)
report = res.json()Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
period_from | integer | no | Window start, unix seconds. 0 = from the beginning. |
period_to | integer | no | Window end, unix seconds. 0 = up to now. |
Response
{
"summary": { "fans": 214, "payers": 57, "revenue_net_cents": 331900, "msg5plus": 122 },
"funnel": { "arrived": 214, "msg5plus": 122, "payers": 31 },
"leaderboard": [
{
"domain_source": "yourbrand.link",
"source_label": "Main IG",
"source_icon": "instagram",
"kind": "tg_match",
"fans": 96,
"spenders": 24,
"msg5plus": 61,
"revenue_net_cents": 182400,
"pct": 55.0,
"countries": [
{ "country": "DE", "revenue_net_cents": 96100 },
{ "country": "AE", "revenue_net_cents": 51200 }
],
"users": [
{ "fan_id": "7093412885", "username": "fan_handle", "country": "DE",
"revenue_net_cents": 44900, "first_ts": 1752537600 }
],
"nonpayers": [
{ "fan_id": "7093412901", "username": "quiet_fan", "country": "DE",
"message_count": 18, "first_ts": 1752882000 }
]
}
],
"organic": {
"domain_source": "", "source_label": "Органика (TG)", "source_icon": "",
"kind": "organic", "fans": 84, "spenders": 21, "msg5plus": 40,
"revenue_net_cents": 101800, "pct": 30.7,
"countries": [], "users": [], "nonpayers": []
},
"countries": [
{ "country": "DE", "fans": 41, "spenders": 12,
"revenue_net_cents": 121300, "msg10plus": 9 }
],
"trend": [
{ "date": "2026-07-18", "arrivals": 24, "payers": 6, "revenue_net_cents": 21500 }
],
"trend_unit": "day"
}Top-level fields
| Field | Type | Description |
|---|---|---|
summary | object | The headline: fans (arrivals in-window), payers (distinct fans with positive net in-window, old fans included), revenue_net_cents (net of all in-window sales), msg5plus (arrivals who wrote more than 5 messages). |
funnel | object | The arrival cohort's funnel: arrived → msg5plus → payers (payers among the arrivals). |
leaderboard | array | One row per resolved source domain, sorted by revenue_net_cents descending. Only cid / tg_match fans with a domain make a row — everything else lands in organic. |
organic | object | The residual bucket, same row shape. Always present. |
countries | array | Per-country rollup across all sources — see below. |
trend | array | Time series of arrivals, payers, revenue_net_cents per bucket. |
trend_unit | string | hour for windows up to 48 hours, else day. Buckets are UTC and range-filled (empty buckets are zeros) on bounded windows, so charts aren't sparse. |
Source row fields
| Field | Type | Description |
|---|---|---|
domain_source | string | The origin domain. Empty on the organic row. |
source_label | string | Friendly source name — the same display name the revenue leaderboard uses. |
source_icon | string | Brand-icon key for the source, matching the revenue leaderboard; may be empty. |
kind | string | cid, tg_match, or organic — the row's precision class. |
fans | integer | New arrivals resolved to this source in-window. |
spenders | integer | Distinct fans of this source with positive net in-window (old fans included). |
msg5plus | integer | Arrivals of this source who wrote more than 5 messages. |
revenue_net_cents | integer | Net of this source's in-window sales. |
pct | float | Share of the window's total Telegram net, 0–100. |
countries | array | This source's money split by fan country: { country, revenue_net_cents }, sorted by money. |
users | array | The paying fans behind the row (top 50 by in-window net): fan_id, username, country, revenue_net_cents, first_ts. Feed fan_id to the fan card. |
nonpayers | array | Arrivals of this source who didn't pay in-period (top 60, most-engaged first): fan_id, username, country, message_count, first_ts. Your best creative/targeting research list. |
Country row fields
| Field | Type | Description |
|---|---|---|
country | string | ISO-3166 alpha-2 code (empty when unknown). |
fans | integer | Arrivals from this country in-window. |
spenders | integer | Distinct payers from this country in-window. |
revenue_net_cents | integer | Net revenue from this country in-window. |
msg10plus | integer | Arrivals from this country who wrote more than 10 messages. |
A payer is positive net
A fan counts as a payer only when their in-window net is above zero — a lone refund doesn't make a payer. Sales are windowed by their sale timestamp, in UTC.
One fan's card
GET /api/admin/tg-fan/{user_id} opens a single Telegram fan — identity, resolved origin with the
precision badge, lifetime totals, a per-day net series, the transaction list, and the purchased
content. Returns 404 when the fan is unknown (or not yours — the read is tenant-scoped).
curl "https://app.tease.link/api/admin/tg-fan/7093412885" \
-H "Authorization: Bearer $TEASE_API_KEY"{
"fan_id": "7093412885",
"username": "fan_handle",
"name": "Alex",
"country": "DE",
"origin": {
"kind": "tg_match",
"label": "TG-трафик yourbrand.link",
"source": "yourbrand.link",
"domain": "yourbrand.link",
"country": "DE",
"precision": "approx"
},
"total_net_cents": 44900,
"purchases": 6,
"message_count": 132,
"closeness_level": "warm",
"closeness_score": 62,
"first_ts": 1752537600,
"last_ts": 1753142400,
"last_purchase_ts": 1753056000,
"blocked": false,
"summary_text": "…",
"preferred_name": "Alex",
"conversion": 3,
"paywall_triggers": 2,
"active_hours": [20, 21, 22],
"by_day": [ { "day": "2026-07-18", "net_cents": 9900 } ],
"transactions": [
{ "ts": 1753056000, "net_cents": 9900, "gross_cents": 12900, "kind": "content" }
],
"purchased": [
{
"content_id": "c_412", "net_cents": 19800, "count": 2, "last_ts": 1753056000,
"sale_type": "content", "description": "…", "tags": ["…"],
"level": 2, "level_label": "…", "media_count": 4,
"media_types": { "video": 1, "photo": 3 }, "in_catalog": true
}
]
}Card fields
| Field | Type | Description |
|---|---|---|
fan_id / username / name / country | strings | Identity as known to the bot. |
origin | object | The resolved origin: kind, human label, source, domain, country, and the precision badge (exact / approx) — the contract above. |
total_net_cents / purchases | integers | Lifetime net and purchase count. |
message_count | integer | Lifetime messages from the fan. |
closeness_level / closeness_score | string / integer | The bot's relationship gauge for the fan. |
first_ts / last_ts / last_purchase_ts | integers | First message, last message, last content purchase (unix seconds). |
blocked | boolean | Whether the fan blocked the bot. |
summary_text / preferred_name / conversion / paywall_triggers / active_hours | mixed | Live enrichment from the bot's own fan profile (chat summary, the name the fan prefers, funnel step, paywall hits, active hours 0–23). Fails safe: when the bot API is unreachable these come back empty and the card still renders from local data. |
by_day | array | Per-day net series: { day, net_cents }. |
transactions | array | Newest-first: { ts, net_cents, gross_cents, kind }. |
purchased | array | Gallery of bought content (content purchases only — tips make no tile): totals per item plus catalog metadata (description, tags, level, level_label, media_count, media_types, in_catalog). |
In the panel
The same report renders as the dashboard's «Telegram» mode — the funnel, the arrivals-and-payments trend, the source leaderboard (every fan chip opens the fan card above), and the country map. Each source row and each fan card carries the precision badge, so «точно» vs «расчёт» is always visible next to the money.
FAQ
Next steps
- Attribution — the OnlyFans-side revenue leaderboard this report mirrors.
- Landing pages — the geo-segments and direct redirects that route fans toward Telegram.
- Smart Links — the trackable links behind every click this report matches against.