Imagine setting up a Telegram bot for order processing or lead capture. You deploy a public script named webhook.php, register its public URL with Telegram, and handle incoming user interaction. Without authentication and idempotency, this simple endpoint creates major production vulnerabilities. Anyone who discovers or guesses your public endpoint URL can send forged HTTP POST requests containing arbitrary JSON payloads, pretending to be Telegram servers or impersonating real users placing orders.
In addition, network hiccups, database latency, or script execution delays often cause your PHP script to take longer than Telegram's internal timeout threshold (around 5 seconds) to respond. When Telegram does not receive an HTTP 200 OK status code within that window, it terminates the connection and retries sending the exact same payload over and over. If your script lacks idempotency, a single user tap can spawn duplicate order records in your database, dispatch multiple notification emails, or trigger duplicate payment requests.
To build a secure and bulletproof webhook handler in pure PHP without relying on heavy frameworks, you must address three specific requirements: authenticating incoming requests using a shared secret header, enforcing database-level update deduplication using update_id, and terminating execution fast enough to prevent timeout retries.
Registering the Webhook with a Secret Token
The Telegram Bot API allows you to set a secret_token when invoking the setWebhook method. This secret must be a string containing 1 to 256 characters (using uppercase and lowercase letters, numbers, underscores, and hyphens). Once configured, Telegram includes this exact string in the X-Telegram-Bot-Api-Secret-Token HTTP header of every delivery attempt.
Never hardcode your Telegram bot token or webhook secret directly inside web-accessible files. Keep sensitive credentials in environment variables or an external configuration file located outside your web server's document root. Here is a standalone PHP CLI script that configures the webhook using PHP's native cURL extension while validating HTTP status codes and JSON response structure:
<?php
declare(strict_types=1);
$botToken = getenv('TELEGRAM_BOT_TOKEN');
$webhookUrl = getenv('TELEGRAM_WEBHOOK_URL');
$secretToken = getenv('TELEGRAM_SECRET_TOKEN');
if (!$botToken || !$webhookUrl || !$secretToken) {
fwrite(STDERR, "Missing required environment variables.\n");
exit(1);
}
$apiUrl = sprintf('https://api.telegram.org/bot%s/setWebhook', $botToken);
$payload = json_encode([
'url' => $webhookUrl,
'secret_token' => $secretToken,
'allowed_updates' => ['message', 'callback_query'],
'drop_pending_updates' => false,
]);
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false) {
fwrite(STDERR, sprintf("cURL execution failed: %s\n", $curlError));
exit(1);
}
if ($httpCode !== 200) {
fwrite(STDERR, sprintf("Telegram API returned HTTP status %d: %s\n", $httpCode, $response));
exit(1);
}
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
fwrite(STDERR, sprintf("Failed to decode JSON response: %s\n", json_last_error_msg()));
exit(1);
}
if (!isset($data['ok']) || $data['ok'] !== true) {
$description = $data['description'] ?? 'Unknown error';
fwrite(STDERR, sprintf("Telegram returned an error: %s\n", $description));
exit(1);
}
echo "Webhook successfully set up!\n";
Run this script from your terminal to establish the webhook mapping and configure Telegram to transmit the secret token header.
Authenticating Incoming Requests via Header Check
When Telegram delivers an update to webhook.php, the first task is authenticating the request header. Do not parse the incoming request body, query the database, or process business logic before verifying the secret token.
In PHP running behind web servers like Nginx or Apache with FastCGI, incoming custom HTTP headers are injected into the $_SERVER superglobal. The X-Telegram-Bot-Api-Secret-Token header becomes available as $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'].
To eliminate timing attack vulnerabilities—where an attacker attempts to infer the correct secret string by analyzing subtle millisecond differences in response time—always use PHP's timing-attack safe hash_equals() string comparison function. If the header is absent or does not match your expected environment variable, reject the connection immediately with an HTTP 403 Forbidden status code and stop execution.
Parsing php://input and Enforcing Idempotency
Telegram delivers update payloads formatted as raw JSON string bodies. In PHP, the global $_POST array will be empty because Telegram sets the request header to Content-Type: application/json, which PHP does not automatically map to $_POST. To read the payload, fetch the unparsed stream directly from php://input using file_get_contents('php://input').
Every update sent by Telegram contains a unique integer identifier called update_id. Because Telegram retries failed deliveries over a 24-hour window, your system must track incoming update_id numbers in MySQL to recognize and skip duplicate submissions.
First, create a dedicated table in MySQL to store processed update IDs. The update_id column must have a PRIMARY KEY or UNIQUE index constraint:
CREATE TABLE IF NOT EXISTS processed_updates (
update_id BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (update_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
When a request passes header authentication, attempt to write its update_id into processed_updates inside a database transaction before running any application logic. If the row inserts successfully, the update is unique and ready to process. If MySQL returns a duplicate key conflict (SQLSTATE 23000 / error 1062), your application has already handled this update.
Crucially, when a duplicate update is encountered, your script must log the duplication, omit all operational execution, and return an HTTP status 200 OK immediately. Returning a 200 code signals to Telegram that your backend successfully acknowledged the update, preventing unnecessary retry loops.
Here is the complete, secure implementation of webhook.php putting these mechanisms together:
<?php
declare(strict_types=1);
$secretToken = getenv('TELEGRAM_SECRET_TOKEN');
$receivedToken = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';
if ($secretToken === false || !hash_equals($secretToken, $receivedToken)) {
http_response_code(403);
echo 'Forbidden';
exit;
}
$rawInput = file_get_contents('php://input');
if ($rawInput === false || $rawInput === '') {
http_response_code(400);
echo 'Empty body';
exit;
}
$update = json_decode($rawInput, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($update['update_id'])) {
http_response_code(400);
echo 'Invalid JSON payload';
exit;
}
$updateId = (int)$update['update_id'];
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', getenv('DB_HOST'), getenv('DB_NAME'));
$pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
try {
$stmt = $pdo->prepare('INSERT INTO processed_updates (update_id) VALUES (:update_id)');
$stmt->execute([':update_id' => $updateId]);
} catch (PDOException $e) {
if ($e->getCode() === '23000') {
// Duplicate key constraint violation: update was already processed
http_response_code(200);
echo 'OK - Already Processed';
exit;
}
// Generic database error: return 500 so Telegram will retry later
http_response_code(500);
echo 'Database Error';
exit;
}
// Begin business logic for unique update
if (isset($update['message']['text'])) {
$chatId = (int)$update['message']['chat']['id'];
$text = trim($update['message']['text']);
if ($text === '/start') {
$botToken = getenv('TELEGRAM_BOT_TOKEN');
$apiUrl = sprintf('https://api.telegram.org/bot%s/sendMessage', $botToken);
$responsePayload = json_encode([
'chat_id' => $chatId,
'text' => 'Hello! Your request was securely received.',
'parse_mode' => 'HTML',
]);
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $responsePayload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
curl_exec($ch);
curl_close($ch);
}
}
// Always complete request with HTTP 200 OK
http_response_code(200);
echo 'OK';
Production Notes
Early Execution Flushing for Heavy Logic: If your application performs time-consuming tasks (such as calling third-party APIs, generating files, or rendering templates), the total execution time may breach Telegram's 5-second webhook delivery timeout. When running under PHP-FPM, flush the HTTP response body to Telegram immediately after inserting the
update_idby callingfastcgi_finish_request(). This sends an HTTP 200 OK back to Telegram and closes the connection while allowing the underlying PHP worker to execute background work without holding up the connection.Database Pruning Policy: The
processed_updatestable continuously collects rows over time. Because Telegram stops retrying updates after 24 hours, entries older than a few days are no longer needed. Configure a daily MySQL Event or cron job to runDELETE FROM processed_updates WHERE created_at < NOW() - INTERVAL 7 DAY;to keep database size manageable.Rotating the Secret Token: If your secret token is exposed or needs to be changed, simply invoke the
set_webhook.phpmanagement script with a newly generated secret value. Telegram updates its internal routing table instantly and will dispatch subsequent updates using the updated token string without dropping pending traffic.
Building a bulletproof Telegram webhook integration in pure PHP requires strict request authentication via secret headers, stream parsing from raw request inputs, and strict database deduplication. To build tailored, production-ready bot architectures or custom Mini App workflows, reach out to BotCreator. You can also check out the official Telegram Bot API documentation for additional field references.
Top comments (0)