Map t.me/Bot?start=payload in PHP: limits, payload → entity, and tamper checks
Deep linking in Telegram bots usually means one URL pattern:
https://t.me/<your_bot_username>?start=<payload>
When the user opens it on a device where the bot is not running, Telegram opens a chat with the bot and offers a *Start* button. After the user taps it, your bot receives a /start message whose text is literally /start <payload> (a single ASCII space, then the payload string). The whole point is that you, the developer, decide what payload means.
What this tutorial covers, concretely:
- The real limits on the payload string in a t.me/Bot?start= URL. - The difference between *deep linking* (carry an intent across cold start) and *continuing a dialog* (the user is already inside the bot and you move them to the next step). - A PHP mapping layer: a pre-created entity in the DB whose short id you put in the URL, then /start resolves the id back to the entity. - Why you must not trust the payload to identify the user, and how to bind the entity to the actual telegram_id that opened the chat.
What this tutorial is not: it is not a generic "build a Telegram bot" guide, and it does not cover Mini App initData validation (that has a different signing scheme) or inline keyboard callbacks (those have their own 64-byte callback_data budget and their own update type).
The URL and the payload limit
?start=<payload> is just a URL query parameter. Telegram only does one thing with it: it appends it to a /start command after a single space. So the constraints come from two places:
1. URL length. Telegram clients generally cope well with long URLs, but messengers and email clients that preview links do not. Keep the whole URL under a few hundred characters. 2. What you can safely read. The payload is delivered as the command arguments of a normal text message. The Bot API itself does not impose a hard byte limit on command arguments beyond the message size cap (4096 chars), but if you treat the payload as an identifier you should give it an explicit ceiling so that nobody can stuff arbitrary text into your resolver.
A safe rule: keep the payload under 64 bytes, ASCII, URL-safe. The same number you may have seen for callback_data is a good anchor. Use bin2hex(random_bytes(7)) for opaque ids — that gives you 14 hex characters, well under the ceiling, and ~268 million unique values before collision risk becomes interesting.
<?php
// Anywhere you need an id to put inside ?start=<payload>
$shortId = bin2hex(random_bytes(7)); // 14 chars, [0-9a-f]
echo 'https://t.me/YourBot?start=' . $shortId;
What deep linking is, and what it is not
A start= payload is cold-start intent. The link is opened from a browser, an email, a QR code, a sticker on a product, an NFC tag. The user might never have spoken to your bot before. Telegram resolves the link, opens (or focuses) the chat, and waits for the user to press *Start*. Only after that press does your bot get /start <payload>.
This is different from continuing a dialog that you already have going:
- If the user is inside your bot and you want them to confirm an order, you send an InlineKeyboardMarkup with callback_data and handle callback_query. That is not deep linking — both ends are already known to you. - If the user has already started the bot and you want them to *resume* a specific flow, a start= link still works, but it is wasteful: the dialog state is already on your side, and a start= link forces a fresh cold start.
So the rule is simple: use start= when the link must work for a user who has not yet talked to the bot, and who is arriving from outside Telegram. Use callback_data when both sides are already in the conversation.
The mapping layer: entity first, payload second
A naive design stores payload → entity and uses the payload as a primary key. Don't do that. Two reasons:
1. You need the entity row to exist before the user clicks the link, otherwise you cannot send the user anything meaningful after they tap *Start*. 2. You will want to rotate, expire, or invalidate individual links (one-time coupons, magic links with TTL). If the payload is the primary key, you cannot.
The pattern that works:
1. Create the entity row first (a draft order, a coupon, an invite, a magic-link token). Its primary key is an internal BIGINT or UUID. 2. Generate a short_id, store it on the row, put short_id in the URL. 3. On /start <short_id>, look the row up by short_id, bind it to the telegram_id of whoever just opened the chat, and continue.
<?php
// Pretend we are about to email a magic-link coupon to a customer.
$pdo = new PDO('mysql:host=127.0.0.1;dbname=shop;charset=utf8mb4', getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$shortId = bin2hex(random_bytes(7));
$stmt = $pdo->prepare(
'INSERT INTO coupons (short_id, kind, amount_cents, expires_at, status)
VALUES (:sid, :k, :a, :exp, :st)'
);
$stmt->execute([
':sid' => $shortId,
':k' => 'WELCOME10',
':a' => 1000,
':exp' => gmdate('Y-m-d H:i:s', time() + 7 * 86400),
':st' => 'pending',
]);
$url = 'https://t.me/YourShopBot?start=' . $shortId;
// email $url to the customer
The coupon row exists, has an expiry, has a status, and the URL carries only the *handle* the bot needs to find it.
Resolving /start in PHP
Telegram delivers /start <payload> as a normal message update. You read update.message.text, strip the leading /start and a single space, and you have the payload. The example below shows the full flow without the surrounding webhook plumbing.
<?php
declare(strict_types=1);
$pdo = new PDO('mysql:host=127.0.0.1;dbname=shop;charset=utf8mb4', getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$raw = file_get_contents('php://input');
$update = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
$msg = $update['message'] ?? null;
if (!$msg || empty($msg['text'])) {
http_response_code(200);
exit;
}
$text = $msg['text'];
$parts = explode(' ', $text, 2);
if ($parts[0] !== '/start') {
http_response_code(200);
exit;
}
$payload = $parts[1] ?? '';
// Hard ceiling. Anything longer is treated as garbage.
if ($payload === '' || strlen($payload) > 64 || !preg_match('/\A[A-Za-z0-9_\-]+\z/', $payload)) {
sendMessage($msg['chat']['id'], 'This link looks broken. Please request a new one.');
http_response_code(200);
exit;
}
$stmt = $pdo->prepare('SELECT id, kind, amount_cents, expires_at, status, owner_telegram_id
FROM coupons WHERE short_id = :sid LIMIT 1');
$stmt->execute([':sid' => $payload]);
$coupon = $stmt->fetch();
if (!$coupon) {
sendMessage($msg['chat']['id'], 'This coupon link is no longer valid.');
http_response_code(200);
exit;
}
if (strtotime($coupon['expires_at']) < time()) {
sendMessage($msg['chat']['id'], 'This coupon has expired.');
http_response_code(200);
exit;
}
$telegramId = (int)$msg['from']['id'];
// Bind the entity to the user who actually opened the chat.
// See the next section for why this matters.
$pdo->beginTransaction();
try {
if ($coupon['owner_telegram_id'] === null) {
$upd = $pdo->prepare('UPDATE coupons SET owner_telegram_id = :tid, status = :st
WHERE id = :id AND owner_telegram_id IS NULL');
$upd->execute([':tid' => $telegramId, ':st' => 'claimed', ':id' => $coupon['id']]);
if ($upd->getRowCount() === 0) {
// Race with someone who claimed it a millisecond earlier.
$pdo->rollBack();
sendMessage($msg['chat']['id'], 'This coupon was just claimed by someone else.');
http_response_code(200);
exit;
}
sendMessage($msg['chat']['id'], sprintf('Coupon claimed: %s, %d cents.',
$coupon['kind'], $coupon['amount_cents']));
} elseif ((int)$coupon['owner_telegram_id'] === $telegramId) {
sendMessage($msg['chat']['id'], 'You already claimed this coupon.');
} else {
// The link has already been claimed by a different Telegram user.
sendMessage($msg['chat']['id'], 'This coupon is not for your account.');
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
error_log('start payload resolve failed: ' . $e->getMessage());
http_response_code(500);
exit;
}
http_response_code(200);
function sendMessage(int $chatId, string $text): void
{
$token = getenv('BOT_TOKEN');
$url = "https://api.telegram.org/bot{$token}/sendMessage";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_POSTFIELDS => http_build_query([
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
]),
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($resp === false || $code !== 200) {
error_log("sendMessage failed http={$code} body={$resp}");
}
curl_close($ch);
}
Two practical details in that snippet:
- The payload is validated with a strict regex before touching the database. Anything that does not match [A-Za-z0-9_-]+ and is not between 1 and 64 chars is rejected with a generic message. That keeps your DB query cheap and your logs clean. - The UPDATE ... WHERE owner_telegram_id IS NULL is the concurrency guard. Two users tapping *Start* within the same connection race here, and only one of them flips the row from pending to claimed.
Why the payload must not identify the user
A very common bug is to encode the *user* in the payload — for instance ?start=u_<userId> or ?start=<email>. This is unsafe for two reasons:
1. Forgery. Anyone who can guess or learn another user's id can build a link of the form https://t.me/YourBot?start=u_42. When *their* Telegram account taps *Start*, your bot reads u_42 and may attribute a coupon, an invite, or a referral to user 42 — even though it was user 99 who actually opened the chat. The fix is what we did above: the payload names an entity, and the entity is bound to whoever actually shows up, recorded as telegram_id from message.from.id. 2. Leakage. If the payload ever carries a personal identifier in plain form, that identifier is now baked into referer logs, email forwarding, chat screenshots, and Telegram's own link-preview cache. The shorter and more opaque the payload, the less leaks when the URL travels.
There is also a subtler point. Telegram signs the *message* but not the *URL*. The Bot API never gives you a way to verify that the user who clicked the link is the same user who received it. The only thing you can trust is message.from.id on the resulting /start update. So design your schema so that message.from.id is the source of truth, and the payload is just a lookup key.
Production notes (optional but recommended)
These are layered on top of the example above and only matter once the bot is past prototype stage.
- Idempotency. Telegram will redeliver a webhook if your endpoint does not answer fast enough. Wrap the whole /start handler in a check against a small processed_updates table (or Redis SET NX with the update_id). If the same update_id shows up twice, do not run the resolver twice. - Secret token. Configure a secret_token in setWebhook. Telegram will then send it in the X-Telegram-Bot-Api-Secret-Token header, and you reject every request that does not match it. Without this header check, anyone who learns the webhook URL can forge updates. - TTL on the entity. A pending coupon or invite should expire. Either set expires_at and reject expired rows in the resolver (we did), or run a periodic cleanup job that deletes stale rows. - Rate limiting. Cold-start links are popular targets for spam. If you expose any endpoint that generates start= URLs (for example, a public "send to a friend" feature), throttle per IP and per recipient.
That is the whole loop: create the entity, generate a short id, ship the URL, resolve /start <id> against your DB, bind to message.from.id, and never let the payload name a user.
---
If you build Telegram bots and Mini Apps as part of your day job and want a studio that ships them end-to-end — backend, bot, and Mini App — BotCreator is the team behind botservice.biz/telegram-bot-api and related projects.