Deep linking t.me/Bot?start=payload: ліміт, мапінг у БД та захист від підміни

Параметр 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‑команди у вебхуці

При отриманні оновлення через вебхук необхідно:

  1. Перевірити заголовок X-Telegram-Bot-Api-Secret-Token (якщо вебхук зареєстрований із secret_token).
  2. Витягти update_id та забезпечити ідемпотентність — не обробляти одне й те саме оновлення двічі.
  3. Якщо повідомлення містить команду /start з параметром, взяти payload, знайти відповідний хеш у таблиці deep_link_tokens, перевірити термін дії та прапорець used.
  4. При успішній перевірці позначити токен як використаний, отримати entity_id та надіслати персоналізоване повідомлення через Bot API, використовуючи cURL.
Приклад обробника на чистому PHP (може бути адаптований під Laravel-контролер):
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

  • Payload ніколи не зберігається відкрито в базі — зберігаємо лише його криптостійкий хеш (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.

Нові статті — у Telegram

Розбираємо, що автоматизувати в бізнесі та як це працює на практиці. Без спаму.