Map t.me/Bot?start=payload in PHP: limits, payload → entity, and tamper checks

Map t.me/Bot?start=payload in PHP

Deep linking for Telegram bots uses one URL pattern:

https://t.me/<your_bot_username>?start=<payload>

When the user opens it and the bot is not yet running, Telegram opens a chat and shows Start. After the tap, your bot receives /start <payload> (one space, then the payload). You decide what that payload means.

This tutorial covers:

  • Practical limits on the ?start= payload
  • Deep linking (cold-start intent) vs continuing an in-chat dialog
  • A PHP mapping layer: create a DB entity, put a short id in the URL, resolve it on /start
  • Why the payload must not identify the user, and how to bind to message.from.id

It is not a full bot framework guide, and it does not cover Mini App initData or inline keyboard callbacks.

URL and payload limit

?start=<payload> is a query parameter. Telegram appends it to /start after one space. Constraints come from URL length and from how you treat the string as an identifier.

  1. URL length. Keep the full URL under a few hundred characters so email clients and previews do not break it.
  2. Safe ceiling. Treat the payload as an id with an explicit max size so nobody can stuff arbitrary text into your resolver.

A solid rule: keep the payload under 64 bytes, ASCII, URL-safe — the same budget as callback_data. bin2hex(random_bytes(7)) yields 14 hex characters and plenty of unique values.

<?php
$shortId = bin2hex(random_bytes(7)); // 14 chars, [0-9a-f]
echo 'https://t.me/YourBot?start=' . $shortId;

What deep linking is (and is not)

A start= payload is cold-start intent. The link may come from a browser, email, QR code, or NFC tag. The user might never have spoken to your bot. Telegram opens the chat and waits for Start. Only then do you get /start <payload>.

That differs from continuing a live dialog:

  • Inside the bot, confirm orders with InlineKeyboardMarkup and callback_query — both sides are already known.
  • If the user already started the bot, a start= link still works but forces a fresh cold start when dialog state already lives on your side.

Use start= when the link must work for someone arriving from outside Telegram. Use callback_data when both sides are already in the conversation.

Mapping layer: entity first, payload second

Do not use the payload as a primary key. Create the entity first (coupon, invite, magic link), give it an internal id, generate a short_id, store it, and put only short_id in the URL. On /start, look up the row and bind it to the Telegram user who opened the chat.

<?php
$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

Resolving /start in PHP

Telegram delivers /start <payload> as a normal message. Split on the first space, validate the payload, load the entity, and bind ownership.

<?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] ?? '';

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'];

$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->rowCount() === 0) {
$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 {
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 details worth keeping:

  • Validate with a strict regex before touching the database.
  • UPDATE ... WHERE owner_telegram_id IS NULL is the race guard so only one claim wins.

Why the payload must not identify the user

Encoding a user in the payload (for example ?start=u_42 or an email) is unsafe:

  1. Forgery. Anyone who guesses another user id can open the link from their own Telegram account. Your bot may credit user 42 while user 99 actually tapped Start. Fix: payload names an entity; bind ownership from message.from.id.
  2. Leakage. Plain identifiers travel through referers, email forwards, screenshots, and link previews. Opaque short ids leak less.

Telegram does not sign the URL. The only trustable identity on cold start is message.from.id on the resulting update.

Production notes

  • Idempotency. Deduplicate by update_id (DB or Redis SET NX) so webhook retries do not claim twice.
  • Secret token. Set secret_token in setWebhook and reject mismatched headers.
  • TTL. Expire pending coupons and reject stale rows in the resolver.
  • Rate limits. Throttle any public endpoint that mint start= URLs.

Create the entity, ship a short id in the URL, resolve /start against the DB, bind to message.from.id, and never let the payload name a user.

Shipping bots and Mini Apps with deep links like this? See botservice.biz.

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.