Set Up a Secure and Idempotent Telegram Webhook in Pure PHP

Setting up a Telegram webhook in pure PHP requires more than just pointing a URL to a script. To build a production-ready endpoint, you must secure incoming requests, handle network retries gracefully without duplicating actions (idempotency), and respond quickly to prevent Telegram from retrying the connection.

This guide covers how to register a webhook with a secret token, validate incoming payloads, enforce idempotency using a MySQL database, and return a fast HTTP 200 OK response. We do not cover complex queue systems or framework-specific routing; this is a clean, native PHP implementation designed for direct deployment.

Step 1: Registering the Webhook with a Secret Token

To prevent unauthorized actors from hitting your webhook endpoint, you must specify a secret_token when calling the setWebhook method. Telegram will include this token in the X-Telegram-Bot-Api-Secret-Token header of every request.

The following CLI script registers your webhook. Run this once from your terminal or deployment pipeline.

<?php
// register.php

$botToken = getenv('TELEGRAM_BOT_TOKEN');
$webhookUrl = 'https://yourdomain.com/webhook.php';
$secretToken = getenv('TELEGRAM_SECRET_TOKEN'); // A secure random string (1-256 characters)

if (!$botToken || !$secretToken) {
die("Missing environment variables: TELEGRAM_BOT_TOKEN or TELEGRAM_SECRET_TOKEN.\n");
}

$url = "https://api.telegram.org/bot{$botToken}/setWebhook";
$payload = [
'url' => $webhookUrl,
'secret_token' => $secretToken,
'allowed_updates' => ['message', 'callback_query']
];

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

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

if (curl_errno($ch)) {
die('cURL Error: ' . curl_error($ch) . "\n");
}
curl_close($ch);

if ($httpStatus !== 200) {
die("HTTP Error {$httpStatus}: {$response}\n");
}

$result = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
die("Failed to parse JSON response.\n");
}

if (!($result['ok'] ?? false)) {
die("Telegram API Error: " . ($result['description'] ?? 'Unknown error') . "\n");
}

echo "Webhook successfully registered.\n";

Step 2: Database Schema for Idempotency

Telegram guarantees "at least once" delivery. If your server takes too long to respond, or if a network hiccup occurs, Telegram will retry sending the same update. To prevent processing the same message multiple times, you must track processed update_id values.

Create a dedicated table with a primary key constraint on update_id:

CREATE TABLE `telegram_processed_updates` (
`update_id` BIGINT UNSIGNED NOT NULL,
`processed_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`update_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Step 3: The Webhook Handler

The webhook handler must perform four critical tasks: 1. Send a fast HTTP 200 OK response to Telegram so it closes the connection. 2. Validate the incoming X-Telegram-Bot-Api-Secret-Token header. 3. Parse the JSON payload safely. 4. Perform an atomic database insert to verify idempotency before executing any business logic.

<?php
// webhook.php

// 1. Send fast HTTP 200 OK response
// This releases the connection immediately if PHP-FPM is used.
ob_start();
echo json_encode(['status' => 'ok']);
header('Connection: close');
header('Content-Length: ' . ob_get_length());
header('Content-Type: application/json');
ob_end_flush();
flush();

if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}

// 2. Validate Secret Token
$headers = getallheaders();
$receivedToken = $headers['X-Telegram-Bot-Api-Secret-Token'] ?? '';
$expectedToken = getenv('TELEGRAM_SECRET_TOKEN');

if (empty($expectedToken) || !hash_equals($expectedToken, $receivedToken)) {
http_response_code(403);
exit('Unauthorized');
}

// 3. Read and parse payload
$rawInput = file_get_contents('php://input');
$update = json_decode($rawInput, true);

if (json_last_error() !== JSON_ERROR_NONE || !isset($update['update_id'])) {
http_response_code(400);
exit('Invalid JSON');
}

$updateId = (int)$update['update_id'];

// 4. Enforce Idempotency via Database
$dsn = "mysql:host=" . getenv('DB_HOST') . ";dbname=" . getenv('DB_NAME') . ";charset=utf8mb4";
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,
]);

// Attempt to insert the update_id
$stmt = $pdo->prepare("INSERT INTO telegram_processed_updates (update_id) VALUES (:update_id)");
$stmt->execute([':update_id' => $updateId]);
} catch (PDOException $e) {
// SQLSTATE 23000 indicates a duplicate key violation (update already processed)
if ($e->getCode() == 23000) {
exit('Duplicate update');
}
error_log("Database error: " . $e->getMessage());
exit;
}

// 5. Process the update safely
if (isset($update['message'])) {
$message = $update['message'];
$chatId = $message['chat']['id'] ?? null;
$text = $message['text'] ?? '';

if ($chatId && $text === '/start') {
sendTelegramMessage($chatId, "Hello! Your update ID <b>" . htmlspecialchars((string)$updateId, ENT_QUOTES, 'UTF-8') . "</b> was processed securely.");
}
}

function sendTelegramMessage($chatId, $text) {
$botToken = getenv('TELEGRAM_BOT_TOKEN');
$url = "https://api.telegram.org/bot{$botToken}/sendMessage";
$payload = [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML'
];

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

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

if (!curl_errno($ch) && $httpStatus === 200) {
$result = json_decode($response, true);
if (json_last_error() === JSON_ERROR_NONE && !($result['ok'] ?? false)) {
error_log("Telegram API Error: " . ($result['description'] ?? 'Unknown error'));
}
}
curl_close($ch);
}

Production Notes

- Token Security: Always load TELEGRAM_BOT_TOKEN and TELEGRAM_SECRET_TOKEN via environment variables or a secure configuration file. Never commit raw tokens to your repository. - Timing Attacks: The hash_equals() function is used instead of a standard == comparison to prevent timing attacks when validating the secret token. - Database Cleanup: The telegram_processed_updates table will grow over time. Set up a cron job to delete records older than 3 to 7 days, as Telegram will not retry updates older than 24 hours. - Error Logging: Ensure that PHP's error_log is configured correctly. If the database connection fails, you want to log the error without exposing database credentials or stack traces to the client.

BotCreator — studio that ships Telegram bots / Mini Apps.

New articles on Telegram

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