For production bots, webhook — the main working mode. Long polling is convenient for local debugging, but on a production server it adds unnecessary load and scales worse.
\nTelegram requires HTTPS with a valid certificate, port 443 / 80 / 88 / 8443, and a 200 OK response within a few seconds. Below is a minimal stack using plain PHP 8.1+ without a framework: webhook registration, secret checking, idempotency by update_id, and fast response.
Requirements for server and PHP
\nRequired extensions are curl, json, mbstring, openssl and pdo. Checking:
php -m | grep -E 'curl|json|pdo|mbstring|openssl'\nThe web server must serve the script at a public HTTPS URL, e.g. https://bot.example.com/webhook.ph p. Make sure post_max_size and upload_max_filesize do not truncate the request body (for update files the payload may be larger than normal JSON).
Registration of webhook: setWebhook
\nDo not call the Bot API via file_get_contents — there is no proper HTTP status code and timeout control. A one-time CLI script is convenient during deployment and token rotation.
The secret_token parameter should be generated once and stored in the environment. Telegram will send it in the header X-Telegram-Bot-Api-Secret-Token — this protects against unauthorized POST requests to your endpoint. drop_pending_updates clears the queue upon re-registration; max_connections limits parallel delivery (1–100).
<?php
// set_webhook.php — запускать из CLI
declare(strict_types=1);
$token = getenv('TELEGRAM_BOT_TOKEN') ?: exit("TELEGRAM_BOT_TOKEN missing\n");
$secret = getenv('TELEGRAM_WEBHOOK_SECRET') ?: exit("TELEGRAM_WEBHOOK_SECRET missing\n");
$url = 'https://bot.example.com/webhook.ph p';
$ch = curl_init('https://api.telegram.org/bo t' . $token . '/setWebhook');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'url' => $url,
'secret_token' => $secret,
'max_connections' => 40,
'drop_pending_updates' => true,
'allowed_updates' => ['message', 'callback_query'],
], JSON_UNESCAPED_UNICODE),
CURLOPT_TIMEOUT => 20,
]);
$response = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo "HTTP {$code}\n{$response}\n";\nA secret can be generated like this:
\nphp -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'\n\nEntry point: webhook.php
\nThe script should run quickly: read the body, verify the secret using hash_equals, parse JSON, record update_id, enqueue the task and return 200. Heavy logic (messaging, external APIs, lead recording) should be performed in a worker, not in the HTTP request.
<?php
// webhook.php
declare(strict_types=1);
$secret = getenv('TELEGRAM_WEBHOOK_SECRET') ?: '';
$header = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';
if ($secret === '' || !hash_equals($secret, $header)) {
http_response_code(401);
exit;
}
$raw = file_get_contents('php://input');
$update = json_decode((string) $raw, true, 512, JSON_BIGINT_AS_STRING);
if (!is_array($update) || !isset($update['update_id'])) {
http_response_code(400);
exit;
}
$updateId = (string) $update['update_id'];
// PDO: INSERT IGNORE / ON CONFLICT — дубликат update_id = уже обработан
$pdo = new PDO(getenv('DSN'), getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $pdo->prepare(
'INSERT IGNORE INTO processed_updates (update_id, received_at) VALUES (?, NOW())'
);
$stmt->execute([$updateId]);
if ($stmt->rowCount() === 0) {
// повторная доставка того же update_id
http_response_code(200);
echo 'ok';
exit;
}
$job = $pdo->prepare(
'INSERT INTO jobs (payload, status, created_at) VALUES (?, 0, NOW())'
);
$job->execute([$raw]);
http_response_code(200);
echo 'ok';\n\n\n\nImportant: the
\nJSON_BIGINT_AS_STRINGflag preservesupdate_id,user.idandchat.idas strings. This way you don't lose precision on 32-bit PHP builds and with large chat IDs.
Database schema for idempotency
\nTelegram may send the same Update again. Store processed update_id with a unique key and skip duplicates. The table should be in the same transactional database as business data.
CREATE TABLE processed_updates (
update_id VARCHAR(32) NOT NULL,
received_at DATETIME NOT NULL,
PRIMARY KEY (update_id)
) ENGINE=InnoDB;
CREATE TABLE jobs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
payload MEDIUMTEXT NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0 new, 1 running, 2 done, 3 failed
attempts INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NULL,
PRIMARY KEY (id),
KEY idx_jobs_status (status, id)
) ENGINE=InnoDB;\nOld entries in processed_updates can be cleaned up via cron — Telegram guarantees monotonic growth of update_id, so very old IDs will no longer be returned:
DELETE FROM processed_updates
WHERE received_at < NOW() - INTERVAL 14 DAY;\n\nHandling updates in the worker
\nThe worker picks tasks with status = 0, sets status = 1, parses JSON and executes the bot logic. On success — status = 2. On error, increment attempts and apply backoff; after the limit — status = 3 and alert. Since HTTP webhooks always respond 200 in fractions of a second, business logic won't break due to Telegram timeouts.
<?php
// worker.php — крутить через supervisor / systemd
declare(strict_types=1);
$pdo = new PDO(getenv('DSN'), getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
while (true) {
$pdo->beginTransaction();
$row = $pdo->query(
'SELECT id, payload, attempts FROM jobs WHERE status = 0 ORDER BY id ASC LIMIT 1 FOR UPDATE'
)->fetch(PDO::FETCH_ASSOC);
if (!$row) {
$pdo->commit();
usleep(300000);
continue;
}
$pdo->prepare('UPDATE jobs SET status = 1, updated_at = NOW() WHERE id = ?')
->execute([$row['id']]);
$pdo->commit();
try {
$update = json_decode($row['payload'], true, 512, JSON_BIGINT_AS_STRING);
// handleUpdate($update); — ваша логика бота
$pdo->prepare('UPDATE jobs SET status = 2, updated_at = NOW() WHERE id = ?')
->execute([$row['id']]);
} catch (Throwable $e) {
$attempts = (int) $row['attempts'] + 1;
$status = $attempts >= 5 ? 3 : 0;
$pdo->prepare(
'UPDATE jobs SET status = ?, attempts = ?, updated_at = NOW() WHERE id = ?'
)->execute([$status, $attempts, $row['id']]);
error_log($e->getMessage());
sleep(min(30, $attempts * 2));
}
}\n\nVerification
\nAfter deployment call getWebhookInfo: your URL should appear, there should be no delivery errors and the expected list of allowed_updates should be present.
curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getWebhookInf o" | jq .\nRelated materials: long polling для локальной разработки, webhook-контроллер на Yii2, guide chapter Webhook для Telegram-бота.
"}