Підключення 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. Читання сирих даних із потоку 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

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