If you deploy a Telegram bot endpoint in pure PHP without strict request verification, your system is vulnerable to two distinct failure modes: unauthorized actors sending forged payloads directly to your server, and duplicate update execution caused by Telegram's retry mechanism.
Imagine a Telegram bot handling automated lead capturing or inventory allocation. If your webhook URL is publicly accessible, an attacker who discovers your endpoint path can send fake JSON payloads impersonating users, triggering invalid transactions or polluting your database. Conversely, if your server experiences a temporary database latency spike and delays returning an HTTP 200 response, Telegram's servers will timeout, consider the delivery failed, and retry sending the exact same payload multiple times. Without proper safeguards, your application will re-run business logic for every retry attempt.
Building a resilient webhook integration in pure PHP requires three foundational elements: programmatic webhook registration with a secret token, secret token verification upon request receipt, and atomic update deduplication in MySQL before executing any business logic.
Setting Up the Webhook with a Secret Token
Many developers register their Telegram webhooks by manually opening a browser URL containing their bot token. This approach frequently leads to missing parameter configurations and exposes raw tokens in browser histories or shell command logs. Instead, script the registration process using pure PHP and cURL to set both the endpoint URL and a dedicated secret token.
Telegram allows you to supply a secret_token string (1 to 256 characters) when calling setWebhook. Once set, Telegram includes this string in every incoming request under the X-Telegram-Bot-Api-Secret-Token HTTP header. This acts as an API key for your inbound endpoint.
Here is a command-line script to register your webhook securely:
<?php
// register_webhook.php
$botToken = getenv('TELEGRAM_BOT_TOKEN');
$webhookUrl = getenv('TELEGRAM_WEBHOOK_URL');
$secretToken = getenv('TELEGRAM_SECRET_TOKEN');
if (!$botToken || !$webhookUrl || !$secretToken) {
fwrite(STDERR, "Error: Missing required environment variables.
");
exit(1);
}
$apiUrl = sprintf('https://api.telegram.org/bot%s/setWebhook', $botToken);
$payload = [
'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_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($response === false) {
fwrite(STDERR, sprintf("cURL error: %s
", $error));
exit(1);
}
if ($httpCode !== 200) {
fwrite(STDERR, sprintf("HTTP error %d: %s
", $httpCode, $response));
exit(1);
}
$result = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($result['ok']) || $result['ok'] !== true) {
fwrite(STDERR, sprintf("Telegram API error: %s
", $response));
exit(1);
}
echo "Webhook registered successfully.
";
Running this script ensures that Telegram acknowledges the payload format and registers your expected secret token. If you ever suspect your webhook URL has been leaked, you can re-run this script with a newly generated secret token to invalidate incoming forged requests immediately.
Validating the Request Header and Parsing Inbound Data
When Telegram sends an HTTP POST request to your script, PHP populates global server arrays. However, Telegram sends data as raw JSON in the request body, not as standard application/x-www-form-urlencoded POST fields. You cannot access the payload via $_POST. You must read from php://input.
Before allocating memory or opening a database connection to parse the body, inspect the HTTP headers for X-Telegram-Bot-Api-Secret-Token. In pure PHP, web servers like Nginx or Apache expose custom headers through the $_SERVER superglobal, converting the name to uppercase and prefixing it with HTTP_. Thus, X-Telegram-Bot-Api-Secret-Token becomes $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'].
Use hash_equals() to compare the expected secret token with the header value. Standard string comparison operators (===) are vulnerable to timing attacks, where an attacker measures the execution time to infer secret bytes step by step.
Here is how to structure the entry point of your webhook file (index.php):
<?php
// index.php
$expectedSecret = getenv('TELEGRAM_SECRET_TOKEN');
$receivedSecret = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';
if (empty($expectedSecret) || !hash_equals($expectedSecret, $receivedSecret)) {
http_response_code(403);
echo 'Forbidden: Invalid secret token';
exit;
}
$rawPayload = file_get_contents('php://input');
if (empty($rawPayload)) {
http_response_code(400);
echo 'Bad Request: Empty body';
exit;
}
$update = json_decode($rawPayload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
echo 'Bad Request: Invalid JSON';
exit;
}
if (!isset($update['update_id']) || !is_int($update['update_id'])) {
http_response_code(422);
echo 'Unprocessable Entity: Missing update_id';
exit;
}
// Proceed to deduplication and processing
If the header validation fails, the script terminates instantly with an HTTP 403 status code without consuming database connection resources or exposing internal application state.
Atomic Update Deduplication with MySQL
Telegram guarantees at-least-once delivery. If Telegram sends a webhook request and your server takes longer than approximately 5 seconds to answer—or returns any HTTP status code other than 200–299—Telegram assumes the message was dropped. It will queue the update and resend it at increasing intervals.
If your PHP script executes a long database query or makes an external API call during the first attempt, the request might time out on Telegram's side while your server actually completes the work. When Telegram retries the payload seconds later, your script will receive the identical update_id and run the business logic a second time.
To prevent duplicate execution, maintain a dedicated table in your database for tracked update identifiers. You must claim the update_id atomically before executing business logic.
First, create the storage table using MySQL:
CREATE TABLE IF NOT EXISTS telegram_processed_updates (
update_id BIGINT UNSIGNED NOT NULL,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (update_id)
) ENGINE=InnoDB;
Next, use a PHP class with PDO to attempt inserting the incoming update_id. By relying on the primary key constraint, the database engine enforces atomicity. If an insert fails due to a duplicate key error, you know the update is currently being processed or was processed previously.
<?php
// Deduplicator.php
class UpdateDeduplicator
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* Attempts to register the update_id.
* Returns true if this is a NEW update, false if it is a DUPLICATE.
*/
public function registerUpdate(int $updateId): bool
{
$sql = 'INSERT IGNORE INTO telegram_processed_updates (update_id) VALUES (:update_id)';
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':update_id' => $updateId]);
// rowCount() returns 1 if inserted, 0 if duplicate row was ignored
return $stmt->rowCount() > 0;
}
}
Integrate this class into your webhook execution pipeline:
<?php
// Complete execution block inside index.php
$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,
]);
$deduplicator = new UpdateDeduplicator($pdo);
$updateId = (int) $update['update_id'];
if (!$deduplicator->registerUpdate($updateId)) {
// Return HTTP 200 so Telegram stops retrying this update
http_response_code(200);
echo 'OK: Duplicate update ignored';
exit;
}
// Execute business logic safely
if (isset($update['message'])) {
processIncomingMessage($update['message']);
}
http_response_code(200);
echo 'OK';
By returning HTTP 200 when ignoring a duplicate, you explicitly instruct Telegram's retry engine to drop the message from its retry queue, preserving idempotency.
Production Notes
When running this architecture in production environments under high request traffic, keep the following execution constraints in mind:
- Fast Response Times: Telegram expects a prompt response. If your business logic involves sending remote cURL requests, processing image uploads, or hitting third-party APIs, do not perform those operations synchronously inside the webhook request block. Parse the update, record it in your database, queue a job using a Redis background worker (or a database-backed task table), return HTTP 200 immediately, and process the task asynchronously.
-
FastCGI Connection Flushing: If you use PHP-FPM with Nginx, you can send an HTTP 200 response to Telegram and close the client connection before finishing your PHP execution by invoking
fastcgi_finish_request(). Note that long-running operations in the background can still consume PHP worker processes, so a real queue worker remains preferable for heavy workloads. -
Updating Database Indexes: Periodically clean up old rows in the
telegram_processed_updatestable using a scheduled cron job (e.g.,DELETE FROM telegram_processed_updates WHERE processed_at < NOW() - INTERVAL 7 DAY;). Telegram retry windows do not exceed 24 hours, so retaining records for a week provides a sufficient buffer without bloating your database storage. -
Handling Partial Failures: If your business logic throws an unhandled exception after the
update_idhas been inserted into your database, the update will not run again on subsequent Telegram retries. Wrap your processing code intry...catchblocks. If an unrecoverable system error occurs, consider rolling back the database transaction or deleting theupdate_identry so the retry system can attempt processing after your infrastructure recovers.
Building robust Telegram integrations requires careful handling of inbound security and request execution flow. For assistance in architecting high-concurrency automated channels or custom interfaces, reach out to BotCreator — studio that ships Telegram bots / Mini Apps.
Top comments (0)