DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

Secure and Idempotent Telegram Webhook Handler in Pure PHP

When building a Telegram bot for production—such as a lead capture system, an e-commerce storefront, or a booking assistant—handling incoming updates reliably is critical. If you simply expose a public PHP script that processes payloads on the fly, your application is vulnerable to two severe issues.

First, because your webhook URL is public, anyone who discovers it can send forged payloads to simulate actions, bypass authorization, or inject malicious data. Second, Telegram expects your server to acknowledge every webhook request with an HTTP 200 OK status within a strict timeout window (typically 5 seconds). If your server experiences a brief network hiccup, database lock, or slow API call, Telegram will retry sending the exact same update. Without idempotency, your bot will process the same action multiple times, leading to duplicate orders, double-posted messages, or corrupted application state.

This tutorial walks through building a secure, production-ready webhook handler in pure PHP. You will implement a secure registration script, verify incoming payloads using a secret token, enforce idempotency using a database, and respond to Telegram instantly to prevent retry loops.

Registering the Webhook with a Secret Token

To prevent unauthorized actors from sending fake updates to your webhook, you must use a secret token. When registering your webhook with the Telegram Bot API, you can pass a secret_token parameter containing an alphanumeric string (1 to 256 characters, including underscores and hyphens). Telegram will then include this token in every incoming request within the X-Telegram-Bot-Api-Secret-Token header.

Do not hardcode your bot token or your secret token. Instead, load them from your environment variables or a secure configuration file. Below is a robust registration script that uses PHP's cURL extension to register your webhook securely. It checks the HTTP status, verifies that the JSON response is valid, and handles potential API errors.

<?php

declare(strict_types=1);

// register.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.\n");
    exit(1);
}

$apiUrl = sprintf('https://api.telegram.org/bot%s/setWebhook', $botToken);
$payload = [
    'url' => $webhookUrl,
    'secret_token' => $secretToken,
    'allowed_updates' => ['message', 'callback_query']
];

$ch = curl_init($apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json'
]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

if (curl_errno($ch)) {
    $errorMsg = curl_error($ch);
    curl_close($ch);
    fwrite(STDERR, sprintf("cURL Error: %s\n", $errorMsg));
    exit(1);
}

curl_close($ch);

if ($httpCode !== 200) {
    fwrite(STDERR, sprintf("HTTP Error: Received status code %d\n", $httpCode));
    exit(1);
}

$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
    fwrite(STDERR, "Error: Failed to parse Telegram API response as JSON.\n");
    exit(1);
}

if (!isset($data['ok']) || $data['ok'] !== true) {
    $description = $data['description'] ?? 'Unknown error';
    fwrite(STDERR, sprintf("Telegram API Error: %s\n", $description));
    exit(1);
}

echo "Webhook successfully registered!\n";
Enter fullscreen mode Exit fullscreen mode

Run this script once from your command line to configure your webhook. If the registration succeeds, Telegram will route all future updates to your specified URL, appending your secret token to the headers of every request.

Verifying the Secret Token and Reading the Payload

When an update arrives at your webhook endpoint, your first line of defense is verifying the X-Telegram-Bot-Api-Secret-Token header. If the header is missing or does not match your expected secret, you must terminate the request immediately with an HTTP 403 Forbidden status.

Depending on your web server configuration (Nginx, Apache, or PHP-FPM), the helper function getallheaders() might not be available. To ensure maximum compatibility across different PHP environments, read the header directly from the $_SERVER superglobal. In PHP, custom HTTP headers are normalized to uppercase, prefixed with HTTP_, and hyphens are replaced with underscores.

Here is how to safely read the raw input stream and verify the secret token:

<?php

declare(strict_types=1);

// webhook.php

$expectedSecret = getenv('TELEGRAM_SECRET_TOKEN');

// Retrieve the secret token from the request headers
$providedSecret = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? null;

if (!$expectedSecret || $providedSecret !== $expectedSecret) {
    http_response_code(403);
    echo "Forbidden: Invalid or missing secret token.";
    exit;
}

// Read the raw input stream
$rawInput = file_get_contents('php://input');
if (empty($rawInput)) {
    http_response_code(400);
    echo "Bad Request: Empty payload.";
    exit;
}

$update = json_decode($rawInput, true);
if (json_last_error() !== JSON_ERROR_NONE) {
    http_response_code(400);
    echo "Bad Request: Invalid JSON.";
    exit;
}

// The payload is verified and parsed successfully
Enter fullscreen mode Exit fullscreen mode

By rejecting unauthorized requests before parsing the JSON or querying your database, you protect your application from denial-of-service attempts and unauthorized payload injection.

Ensuring Idempotency with update_id

Every update sent by Telegram contains a unique, sequential integer called update_id. If Telegram does not receive a successful HTTP response from your server, it will retry sending the exact same update with the same update_id at regular intervals.

To prevent duplicate processing, you must track processed update_id values in a persistent storage layer. A relational database table with a unique constraint is the most reliable way to handle this. If a second request with the same update_id arrives, your database will reject the insert, allowing you to catch the exception, stop processing, and return a fast 200 OK to Telegram.

First, create a dedicated table in your database to track processed updates:

CREATE TABLE processed_updates (
    update_id BIGINT UNSIGNED PRIMARY KEY,
    processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
Enter fullscreen mode Exit fullscreen mode

Next, implement the database check in your PHP webhook handler. Wrap the insertion in a try-catch block. If the insert fails due to a duplicate key violation (SQLSTATE 23000), it means your server has already received or is currently processing this update. You must exit immediately with a 200 OK status so Telegram stops retrying.

<?php

// Database connection configuration
$dbHost = getenv('DB_HOST') ?: '127.0.0.1';
$dbName = getenv('DB_NAME');
$dbUser = getenv('DB_USER');
$dbPass = getenv('DB_PASS');

try {
    $pdo = new PDO(
        "mysql:host={$dbHost};dbname={$dbName};charset=utf8mb4",
        $dbUser,
        $dbPass,
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]
    );
} catch (PDOException $e) {
    // If the database is down, return a 500 so Telegram retries later
    http_response_code(500);
    error_log("Database connection failed: " . $e->getMessage());
    exit;
}

$updateId = $update['update_id'] ?? null;
if (!$updateId) {
    http_response_code(400);
    echo "Bad Request: Missing update_id.";
    exit;
}

try {
    // Attempt to register the update_id before performing any business logic
    $stmt = $pdo->prepare("INSERT INTO processed_updates (update_id) VALUES (:update_id)");
    $stmt->execute([':update_id' => $updateId]);
} catch (PDOException $e) {
    // SQLSTATE 23000 represents an integrity constraint violation (duplicate key)
    if ($e->getCode() === '23000') {
        http_response_code(200);
        echo "OK: Update already processed.";
        exit;
    }

    // For other database errors, fail with a 500 to trigger a retry
    http_response_code(500);
    error_log("Database error during idempotency check: " . $e->getMessage());
    exit;
}

// Proceed with your business logic safely
Enter fullscreen mode Exit fullscreen mode

Using this pattern, if your webhook takes longer than 5 seconds to run and Telegram retries, the concurrent retry request will hit the database, detect the duplicate update_id, and terminate cleanly with a 200 OK without executing your business logic twice.

Production Notes and Optimization

To run a high-traffic Telegram bot reliably, keep the following architectural practices in mind:

1. Fast Response with fastcgi_finish_request()

If your bot needs to perform heavy tasks (such as calling external APIs, generating images, or sending emails), you should close the HTTP connection to Telegram immediately after verifying the update and saving the update_id, but before executing the slow tasks.

If you are running PHP-FPM, you can use the fastcgi_finish_request() function. This sends the HTTP response headers and body back to Telegram and closes the connection, while allowing the PHP process to continue running in the background.

// Send response to Telegram
http_response_code(200);
echo "OK";

// Close connection, but keep executing the script
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

// Perform long-running operations here
// (e.g., call external APIs, send messages back to Telegram)
Enter fullscreen mode Exit fullscreen mode

2. Database Cleanup

Because Telegram update_id values are sequential and unique, you do not need to keep them in your database forever. A table with millions of rows will slow down index lookups. Set up a daily cron job to delete records older than 24 or 48 hours:

DELETE FROM processed_updates WHERE processed_at < NOW() - INTERVAL 2 DAY;
Enter fullscreen mode Exit fullscreen mode

3. Graceful Error Handling

Never allow PHP to output raw errors or stack traces to the output stream in production (display_errors = Off in your php.ini). If an unhandled exception occurs, catch it, log it to your server logs, and return an appropriate HTTP status code. If the error is transient (like a third-party API timeout), return a 500 Internal Server Error so Telegram retries the update. If the error is permanent (like a malformed payload), return a 200 OK to discard the broken update and prevent infinite retry loops.

Implementing these security and reliability patterns ensures your PHP bot remains stable under heavy load and resilient against malicious traffic. For more details on managing incoming payloads and configuring webhook parameters, refer to the official documentation at https://botservice.biz/telegram-bot-api.

If you need professional assistance architecting, securing, or scaling your Telegram integration, reach out to BotCreator — studio that ships Telegram bots / Mini Apps.

Top comments (0)