Using third-party frameworks to process incoming webhooks from Telegram Bot API often creates unnecessary overhead: kernel loading, routing initialization, and service container bootstrap take valuable milliseconds. If your bot is high-loaded or runs as a lightweight microservice, a script in pure PHP (Native PHP) will ensure minimal response time and maximum fault tolerance.
In this article, we will build a full-fledged, isolated Webhook handler script in pure PHP. We will cover URL registration via a POST request with cURL, validation of the secret_token security header, event deduplication using update_id in MySQL, and the technique of immediately returning HTTP 200 OK before executing the main business logic.
Why Process Telegram Webhooks in Pure PHP
Telegram requires the bot server to respond with an HTTP 200 OK status to every incoming request. If your server fails to respond within 5–10 seconds or returns a 5xx/4xx code, Telegram considers the delivery failed and begins resending the same event (retry policy) at increasing intervals.
Benefits of implementing a webhook in pure PHP:
- Minimal latency: no overhead from loading heavy frameworks (Laravel, Symfony).
- Full transparency: you know exactly how headers, request bodies, and DB errors are processed.
- Easy scaling: such a script is easy to run in an isolated PHP-FPM pool or Docker container.
Step 1. Registering the Webhook via cURL and the setWebhook Method
To configure the webhook, you need to call the setWebhook method of the Telegram Bot API. We recommend using the POST method with a JSON-formatted body, passing security parameters and restrictions on update types.
Below is a webhook setup script using cURL, HTTP code checking, json_last_error() error handling, and переменные окружения.
<?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'] . "
";
Step 2. Security Validation via secret_token
The secret_token parameter when calling setWebhook forces Telegram to send the header X-Telegram-Bot-Api-Secret-Token with every invocation of your script. This eliminates request forgery attacks where an attacker attempts to send fake incoming messages directly to your public URL.
In PHP, this header is available via the superglobal array $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN']. For secure string comparison, you should use the hash_equals() function, which prevents timing attacks.
Step 3. Protection Against Duplicates: Idempotency via update_id in MySQL
The network is not ideal: Telegram may send the same `update` twice if the network packet containing your 200 OK response is delayed. To prevent the bot from charging money twice or duplicating responses to the client, processing must be idempotent.
Let's create a minimal table in MySQL to store processed `update_id`s:
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;
Upon receiving a request, we perform an INSERT IGNORE (or INSERT ... ON DUPLICATE KEY UPDATE). If the update_id column is the primary key, a duplicate request will return `rowCount() === 0`, allowing the script execution to terminate instantly without re-running the business logic.
Step 4. Full Webhook Implementation with a Fast 200 OK Response
Below is a production-ready webhook.php script. It implements:
- Validation of the
X-Telegram-Bot-Api-Secret-Tokenheader. - Reading raw data from the
php://inputstream. - Checking and storing the
update_idin the database via PDO. - Sending an HTTP 200 OK response and flushing buffers via
fastcgi_finish_request()so the Telegram client immediately receives delivery confirmation. - Executing the main business logic (sending a response via 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);
}
Common Pitfalls When Working with Pure PHP and the Telegram API
When implementing handlers from scratch, developers often make critical mistakes that lead to bot crashes or duplicated messages:
- Using file_get_contents to send requests to the API: the built-in wrapper bypasses response status checks and does not allow proper handling of timeouts and network failures. Use cURL with explicit monitoring of
curl_errnoand HTTP status codes. - Hardcoding tokens in source code: a token grants full access to your bot. Always retrieve keys from environment variables using
getenv()or$_ENV. - Missing call to fastcgi_finish_request(): if the script performs long-running work (requests to third-party services, image generation) before sending an HTTP response, Telegram will time out and start bombarding the script with retry requests.
- Ignoring the secret token: an open endpoint without
X-Telegram-Bot-Api-Secret-Tokenvalidation is vulnerable to message forgery attacks and database flooding with fake records.
If you require professional development of complex bots and интеграция с CRM, specialists at BotCreator will help you implement a project of any complexity.