Telegram Deep Links: Using the start Parameter for Tracking
Practice4 minInnovialabs editorial team
In short
Add ?start=CODE to a bot link (or ?startapp=CODE for a Mini App), and the bot receives that code when the user starts it, so you can tell which ad, blogger or friend brought each user.
How the start parameter works
A deep link is an ordinary t.me link with one extra parameter. When a user opens https://t.me/your_bot?start=c_8f3k2 and taps Start, the bot receives the message /start c_8f3k2.
For Mini Apps the parameter is called startapp: https://t.me/your_bot/app?startapp=c_8f3k2 opens the Mini App and passes the value as start_param in its launch data. In this case the bot gets no message — the value lives only inside the Mini App.
One link per placement is enough to know where each user came from: an ad campaign, a blogger, a QR code on packaging or a friend's invitation.
Parameter rules
- Up to 64 characters.
- Allowed characters:
A-Z,a-z,0-9,_and-. - No spaces,
=,&or Cyrillic, so a raw UTM string will not fit.
| Approach | Example | When it fits |
|---|---|---|
| Short readable code | ig_spring | A handful of campaigns |
| Fields with separators | src-ig__cmp-spring | Several attributes, no database |
| Database ID | c_8f3k2 | Many campaigns, mapping may change |
| Base64url | encoded JSON | Arbitrary data that fits in 64 characters |
The most practical option is a database ID: store the campaign (source, placement, creative, date) in a table and put only its short ID into the link. Links stay short, do not reveal internal names, and you can fix the mapping without reissuing them.
Handling it in aiogram and grammY
In aiogram 3, register the deep-link handler before the plain /start handler:
from aiogram import Router
from aiogram.filters import CommandStart, CommandObject
from aiogram.types import Message
router = Router()
@router.message(CommandStart(deep_link=True))
async def start_with_code(message: Message, command: CommandObject):
await save_touch(message.from_user.id, command.args)
await message.answer("Welcome!")
@router.message(CommandStart())
async def start_plain(message: Message):
await message.answer("Welcome!")
In grammY the payload is available as ctx.match:
bot.command("start", async (ctx) => {
const code = ctx.match; // empty string if there is no parameter
if (code) await saveTouch(ctx.from!.id, code);
await ctx.reply("Welcome!");
});
In a Mini App, Telegram.WebApp.initDataUnsafe.start_param is fine for UI decisions, but for attribution trust only the value from initData validated on the server.
Attributing ad traffic
- Create a campaign record and generate a separate link for every placement.
- On
/start, save the first touch only if the user is new. Log every later touch in a separate table. - Join users with target events — registration, order, payment — and count conversions per campaign, not just starts.
- Mark users without a parameter as organic or unknown: Telegram does not pass a referrer, so someone who found the bot through search arrives with no code.
Decide upfront whether reports use first touch or last touch. Users can open different links at different times, and mixing the two models makes numbers inconsistent.
Referral programs with anti-fraud checks
A referral link looks like ?start=ref_x7k2p. Use a random code per user, not the raw Telegram ID: IDs are easy to enumerate and reveal account data.
Basic protection:
- New users only. If the invitee already existed in your database, no credit.
- No self-referral. Ignore codes that belong to the user themselves.
- Reward the target action, not
/start. Pressing Start costs nothing, so farms of fake accounts will flood a reward-per-start program. - Holding period before paying out bonuses, so refunds and fraud can surface.
- Unique constraint: one referral record per invitee, so retries and double taps do not create duplicates.
- Limits and alerts: cap rewards per inviter per day and flag sudden spikes or invitees who never do anything after starting.
Common mistakes
- Putting a full UTM string into the link: it breaks the character rules, and the parameter is lost.
- Overwriting the source on every
/start, so the original campaign disappears. - Trusting
start_paramfrom the client without server-side validation. - Forgetting the empty-parameter case and crashing the handler.