Setting Up a Telegram Webhook in Pure PHP: secret_token, Idempotency, and Fast 200 Response

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:

  1. Validation of the X-Telegram-Bot-Api-Secret-Token header.
  2. Reading raw data from the php://input stream.
  3. Checking and storing the update_id in the database via PDO.
  4. Sending an HTTP 200 OK response and flushing buffers via fastcgi_finish_request() so the Telegram client immediately receives delivery confirmation.
  5. 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_errno and 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-Token validation 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.

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.