The start parameter in the deep link t.me/Bot?start=payload allows passing an arbitrary string to the bot when the dialog is first launched. According to the Bot API documentation, the payload length is limited to 64 bytes, and the value is only available in the message object with the /start command when the user has not yet had a chat with the bot. If the chat already exists, the bot will receive a regular /start message without the parameter — this is an important difference from continuing a dialog.
Generating a secure payload
To bind a payload to an entity (for example, a service booking ID), you shouldn't store the ID itself in the open: it can be tampered with. It is better to use a cryptographically secure one-time token, and store only its hash in the database. Example of link generation in Laravel:
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
function makeDeepLink(int $entityId, int $ttlSeconds = 300): string
{
$botUsername = getenv('TELEGRAM_BOT_USERNAME');
$raw = Str::random(7); // 7 байт → base64url ≈ 11 символов
$token = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
$hash = hash('sha256', $token);
$expires = now()->addSeconds($ttlSeconds);
DB::table('deep_link_tokens')->insert([
'token_hash' => $hash,
'entity_id' => $entityId,
'expires_at' => $expires,
'used' => false,
]);
$payload = $token; // уже безопасен для URL
return "https://t.me/{$botUsername}?start={$payload}";
}
An 11-character token fits well within the 64-byte limit. We store only its SHA-256 hash, so even in the event of a database leak, an attacker won't be able to guess the original token without brute-forcing.
Handling the start command in a webhook
When receiving an update via a webhook, you must:
- Verify the
X-Telegram-Bot-Api-Secret-Tokenheader (if the webhook is registered with asecret_token). - Extract the
update_idand ensure idempotency — do not process the same update twice. - If the message contains the
/startcommand with a parameter, extract the payload, find the corresponding hash in thedeep_link_tokenstable, and check its lifetime and theusedflag. - Upon successful verification, mark the token as used, retrieve the
entity_id, and send a personalized message via the Bot API using cURL.
header('Content-Type: application/json');
$secretToken = getenv('TELEGRAM_WEBHOOK_SECRET');
if ($secretToken && $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] !== $secretToken) {
http_response_code(403);
exit;
}
$input = file_get_contents('php://input');
$update = json_decode($input, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
exit;
}
$updateId = $update['update_id'] ?? null;
if ($updateId === null) {
http_response_code(200);
exit;
}
// Идемпотентность: сохраняем обработанные update_id в Redis (пример)
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
if ($redis->get('tg_update:' . $updateId)) {
http_response_code(200);
exit; // уже обработано
}
$redis->setex('tg_update:' . $updateId, 300, '1');
$message = $update['message'] ?? null;
if (!$message || !isset($message['text'])) {
http_response_code(200);
exit;
}
$text = trim($message['text']);
if (str_starts_with($text, '/start')) {
$parts = explode(' ', $text, 2);
$payload = $parts[1] ?? '';
if ($payload === '') {
// обычный /start без параметра
http_response_code(200);
exit;
}
// проверяем длину payload (ограничение Bot API)
if (strlen($payload) > 64) {
http_response_code(200);
exit;
}
// ищем токен в БД
$pdo = new PDO('mysql:host=' . getenv('DB_HOST') . ';dbname=' . getenv('DB_NAME'),
getenv('DB_USER'), getenv('DB_PASS'));
$stmt = $pdo->prepare('SELECT id, entity_id, expires_at, used FROM deep_link_tokens WHERE token_hash = ?');
$hash = hash('sha256', $payload);
$stmt->execute([$hash]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
// токен не найден или уже использован
http_response_code(200);
exit;
}
if (new DateTime($row['expires_at']) < new DateTime()) {
// срок истёк
http_response_code(200);
exit;
}
if ((int)$row['used'] === 1) {
// уже использован
http_response_code(200);
exit;
}
// помечаем как использованный
$upd = $pdo->prepare('UPDATE deep_link_tokens SET used = 1 WHERE id = ?');
$upd->execute([$row['id']]);
$entityId = (int)$row['entity_id'];
$chatId = $message['chat']['id'];
// Формируем персонализированное сообщение
$reply = "Привет! Вы перешли по ссылке для сущности #{$entityId}.";
// Отправляем через Bot API cURL
$botToken = getenv('TELEGRAM_BOT_TOKEN');
$apiUrl = "https://api.telegram.org/bot{$botToken}/sendMessage";
$data = [
'chat_id' => $chatId,
'text' => $reply,
];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $apiUrl,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($data),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode !== 200) {
// логируем ошибку, но не прерываем вебхук
error_log("Telegram sendMessage failed: {$httpCode} {$response}");
} else {
$decoded = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || !$decoded['ok'] ?? false) {
error_log("Bad Telegram response: {$response}");
}
}
curl_close($ch);
}
http_response_code(200);
Difference from continuing a dialog and idempotency
The start parameter is only available when the user opens the chat with the bot via a deep link for the first time. If the user has already had a dialog and sends /start (or the bot receives the command from the menu), the message object will contain only the command without the parameter. Therefore, the logic tied to the payload should only be executed during the first launch.
To protect against processing the same update multiple times, we use idempotency: we store the update_id in a fast storage (Redis, Memcached, or a separate table with TTL). If the same update is received again, we simply return 200 OK without taking any action.
Security and protection against payload tampering
- The payload is never stored in plain text in the database — we store only its cryptographically secure hash (SHA-256).
- The token is generated by the
random_bytesfunction, making it unpredictable. - We set a short TTL (for example, 5 minutes) and a
usedflag so that the token cannot be reused after activation. - We check the payload length — no more than 64 bytes, otherwise we reject the request.
- If the webhook is registered with a
secret_token, we must verify theX-Telegram-Bot-Api-Secret-Tokenheader.
Thus, even if an attacker intercepts or tampers with the start parameter, they will not be able to guess a valid token without access to the secret key used to generate the hash, nor will they be able to reuse an already redeemed token.
Remember: a deep link is just an entry point. All business logic (validation, CRM entry, bonus accrual) must reside in a secure server-side handler, not on the client side.
For a quick start, you can use the ready-made BotCreator package, which provides webhook templates and utilities for working with deep links in Laravel and pure PHP.