Параметр start в deep‑link t.me/Bot?start=payload позволяет передать бота произвольную строку при первом запуске диалога. Согласно документации Bot API, длина payload ограничена 64 байтами, а значение доступно только в объекте message с командой /start, когда пользователь ещё не имел чата с ботом. Если чат уже существует, бот получит обычное сообщение /start без параметра — это важное отличие от продолжения диалога.
Генерация безопасного payload
Для привязки payload к сущности (например, ID записи на услугу) не стоит сохранять сам ID открытым: его можно подменить. Лучше использовать криптостойкий одноразовый токен, а в базе хранить только его хеш. Пример генерации ссылки в 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}";
}
Токен длиной 11 символов хорошо укладывается в лимит 64 байта. Мы сохраняем только его SHA‑256 хеш, поэтому даже при утечке базы злоумышленник не сможет подобрать оригинальный токен без перебора.
Обработка start‑команды в вебхуке
При получении обновления через вебхук необходимо:
- Проверить заголовок
X-Telegram-Bot-Api-Secret-Token(если вебхук зарегистрирован сsecret_token). - Извлечь
update_idи обеспечить идемпотентность — не обрабатывать одно и то же обновление дважды. - Если сообщение содержит команду
/startс параметром, взять payload, найти соответствующий хеш в таблицеdeep_link_tokens, проверить срок жизни и флагused. - При успешной проверке пометить токен как использованный, получить
entity_idи отправить персонализированное сообщение через Bot API, используя 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);
Отличие от продолжения диалога и идемпотентность
Параметр start доступен только тогда, когда пользователь впервые открывает чат с ботом через deep‑link. Если пользователь уже имел диалог и отправляет /start (или бот получает команду из меню), объект message будет содержать только команду без параметра. Поэтому логика, привязанная к payload, должна выполняться только при первом запуске.
Для защиты от повторной обработки одного и того же обновления используем идемпотентность: сохраняем update_id в быстром хранилище (Redis, Memcached или отдельная таблица с TTL). При повторном поступлении того же обновления мы просто возвращаем 200 OK без действий.
Безопасность и защита от подмены payload
- Пayload никогда не хранится открыто в базе — сохраняем только его криптостойкий хеш (SHA‑256).
- Токен генерируется функцией
random_bytes, что делает его непредсказуемым. - Устанавливаем короткий TTL (например, 5 минут) и флаг
used, чтобы токен нельзя было использовать повторно после активации. - Проверяем длину payload — не более 64 байт, иначе отклоняем запрос.
- Если вебхук зарегистрирован с
secret_token, обязательно проверяем заголовокX-Telegram-Bot-Api-Secret-Token.
Таким образом, даже если злоумышленник перехватит или подменит параметр start, он не сможет подобрать валидный токен без доступа к секретному ключу, используемому при генерации хеша, и не сможет повторно использовать уже погашенный токен.
Помните: deep link — это лишь точка входа. Вся бизнес‑логика (валидация, запись в CRM, начисление бонусов) должна находиться в защищённом серверном обработчике, а не в клиентской части.
Для быстрого старта вы можете использовать готовый пакет BotCreator, который предоставляет шаблоны вебхуков и утилиты для работы с deep‑link в Laravel и чистом PHP.