Подключение Telegram Webhook на чистом PHP: secret_token, идемпотентность и быстрый ответ 200

Использование сторонних фреймворков для обработки входящих вебхуков от Telegram Bot API часто создает лишний overhead: загрузка ядра, инициализация роутинга и сервис-контейнеров занимают ценные миллисекунды. Если ваш бот высоконагружен или работает в рамках легкого микросервиса, скрипт на чистом PHP (Native PHP) обеспечит минимальное время отклика и максимальную отказоустойчивость.

В этой статье мы построим полноценный, изолированный скрипт приема Webhook на чистом PHP. Мы рассмотрим регистрацию URL через POST-запрос с cURL, валидацию заголовка безопасности secret_token, дедупликацию событий через update_id в MySQL и технику моментального возврата HTTP 200 OK до выполнения основной бизнес-логики.

Зачем обрабатывать Telegram Webhook на чистом PHP

Telegram требует, чтобы сервер бота отвечал статусом HTTP 200 OK на каждый входящий запрос. Если ваш сервер не успевает ответить за 5–10 секунд или возвращает код 5xx/4xx, Telegram считает доставку неудачной и начинает повторять отправку того же самого события (retry policy) с нарастающим интервалом.

Преимущества реализации вебхука на чистом PHP:

  • Минимальный latency: отсутствие накладных расходов на загрузку тяжёлых фреймворков (Laravel, Symfony).
  • Полная прозрачность: вы точно знаете, как обрабатываются заголовки, тело запроса и ошибки БД.
  • Легкость масштабирования: такой скрипт легко запустить в изолированном PHP-FPM пуле или Docker-контейнере.

Шаг 1. Регистрация Webhook через cURL и метод setWebhook

Для настройки вебхука необходимо вызвать метод setWebhook Telegram Bot API. Мы рекомендуем использовать метод POST с телом в формате JSON, передавая параметры безопасности и ограничения по типам обновлений.

Ниже представлен скрипт установки вебхука, использующий cURL, проверку HTTP-кодов, ошибки json_last_error() и переменные окружения.

<?php

declare(strict_types=1);

$botToken = getenv('BOT_TOKEN');
$secretToken = getenv('WEBHOOK_SECRET_TOKEN');
$webhookUrl = 'https://your-domain.com/telegram/webhook.php';

if (!$botToken || !$secretToken) {
throw new RuntimeException('Не заданы переменные окружения BOT_TOKEN или WEBHOOK_SECRET_TOKEN');
}

$apiUrl = 'https://api.telegram.org/bot' . $botToken . '/setWebhook';

$payload = [
'url' => $webhookUrl,
'secret_token' => $secretToken,
'allowed_updates' => ['message', 'callback_query', 'my_chat_member'],
'drop_pending_updates' => false,
];

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $apiUrl,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);

$response = curl_exec($ch);

if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
throw new RuntimeException('cURL ошибка при установке webhook: ' . $error);
}

$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($httpCode !== 200) {
throw new RuntimeException('HTTP ошибка ' . $httpCode . ': ' . $response);
}

$result = json_decode((string)$response, true);

if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('Ошибка декодирования JSON ответа Telegram');
}

if (empty($result['ok'])) {
$description = $result['description'] ?? 'Неизвестная ошибка';
throw new RuntimeException('Telegram API вернул ошибку: ' . $description);
}

echo "Webhook успешно установлен: " . $result['description'] . "
";

Шаг 2. Валидация безопасности через secret_token

Параметр secret_token при вызове setWebhook заставляет Telegram передавать заголовок X-Telegram-Bot-Api-Secret-Token при каждом вызове вашего скрипта. Это исключает атаки типа «подделка входящего запроса», когда злоумышленник пытается отправить фейковые входящие сообщения напрямую на ваш публичный URL.

В PHP этот заголовок доступен через суперглобальный массив $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN']. Для безопасного сравнения строк следует использовать функцию hash_equals(), которая предотвращает атаки по времени (timing attacks).

Шаг 3. Защита от дублей: идемпотентность через update_id в MySQL

Сеть неидеальна: Telegram может отправить один и тот же `update` дважды, если сетевой пакет с вашим ответом 200 OK задержится. Чтобы бот не списывал деньги повторно и не дублировал ответы клиенту, обработка должна быть идемпотентной.

Создадим минимальную таблицу в MySQL для сохранения обработанных `update_id`:

CREATE TABLE IF NOT EXISTS `telegram_processed_updates` (
`update_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`update_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

При получении запроса мы делаем INSERT IGNORE (или INSERT ... ON DUPLICATE KEY UPDATE). Если колонка update_id является первичным ключом, повторный запрос вернет `rowCount() === 0`, что позволит мгновенно завершить выполнение скрипта без повторной бизнес-логики.

Шаг 4. Полная реализация вебхука с быстрым ответом 200 OK

Ниже приведён готовый к продакшену скрипт webhook.php. В нём реализованы:

  1. Проверка заголовка X-Telegram-Bot-Api-Secret-Token.
  2. Чтение сырых данных из потокa php://input.
  3. Проверка и фиксация update_id в база данных через PDO.
  4. Отправка ответа HTTP 200 OK и сброс буфера через fastcgi_finish_request(), чтобы клиент Telegram сразу получил подтверждение доставки.
  5. Выполнение основной логики (отправка ответа через cURL).
<?php

declare(strict_types=1);

// 1. Проверка секретного токена заголовка
$expectedSecret = getenv('WEBHOOK_SECRET_TOKEN');
$incomingSecret = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';

if (!$expectedSecret || !hash_equals($expectedSecret, $incomingSecret)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Access denied: invalid secret token']);
exit;
}

// 2. Чтение входящих данных
$rawContent = file_get_contents('php://input');
if (empty($rawContent)) {
http_response_code(400);
exit;
}

$update = json_decode($rawContent, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($update['update_id'])) {
http_response_code(400);
exit;
}

$updateId = (int)$update['update_id'];

// 3. Подключение к БД и проверка идемпотентности
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', getenv('DB_HOST'), getenv('DB_NAME'));
try {
$pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$stmt = $pdo->prepare(
'INSERT IGNORE INTO telegram_processed_updates (update_id, created_at) VALUES (:update_id, NOW())'
);
$stmt->execute([':update_id' => $updateId]);

if ($stmt->rowCount() === 0) {
// Уведомление уже обрабатывалось
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['status' => 'already_processed']);
exit;
}
} catch (PDOException $e) {
error_log('DB Error in Telegram Webhook: ' . $e->getMessage());
http_response_code(500);
exit;
}

// 4. Немедленный возврат 200 OK
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['status' => 'ok']);

if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
} else {
// Резервный вариант для прочих SAPI
ignore_user_abort(true);
ob_start();
header('Content-Length: ' . (string)ob_get_length());
header('Connection: close');
ob_end_flush();
flush();
}

// 5. Выполнение бизнес-логики в фоновом режиме
if (isset($update['message'])) {
$message = $update['message'];
$chatId = $message['chat']['id'] ?? null;
$text = $message['text'] ?? '';

if ($chatId && $text === '/start') {
sendTelegramApiMessage($chatId, 'Добро пожаловать! Ваш запрос успешно обработан.');
}
}

function sendTelegramApiMessage(int $chatId, string $text): void
{
$botToken = getenv('BOT_TOKEN');
if (!$botToken) {
return;
}

$url = 'https://api.telegram.org/bot' . $botToken . '/sendMessage';
$payload = [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
];

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);

$res = curl_exec($ch);
if (curl_errno($ch)) {
error_log('cURL send error: ' . curl_error($ch));
}
curl_close($ch);
}

Типичные ошибки при работе с чистым PHP и Telegram API

При самостоятельной реализации обработчиков разработчики часто допускают критические ошибки, приводящие к падению бота или дублированию сообщений:

  • Использование file_get_contents для отправки запросов к API: встроенный обертка обходит проверки статусов ответа и не позволяет адекватно обрабатывать таймауты и сетевые сбои. Используйте cURL с явным контролем curl_errno и кодов HTTP.
  • Хардкод токенов в исходном коде: токен дает полный доступ к вашему боту. Всегда забирайте ключи из переменных окружения через getenv() или $_ENV.
  • Отсутствие вызова fastcgi_finish_request(): если скрипт выполняет долгую работу (запросы к сторонним сервисам, генерация картинок) до отправки HTTP-ответа, Telegram отвалится по таймауту и начнет засыпать скрипт повторными запросами.
  • Игнорирование секретного токена: открытый эндпоинт без проверки X-Telegram-Bot-Api-Secret-Token подвержен атакам подделки сообщений и переполнению базы данных фейковыми записьми.

Если вам требуется профессиональная разработка сложных ботов и интеграция с CRM, специалисты BotCreator помогут реализовать проект любой сложности.

Новые статьи — в Telegram

Разбираем, что автоматизировать в бизнесе и как это работает на практике. Без спама.