Updates

Webhooks

A practical guide to setting up a webhook for a Telegram bot in PHP: installation via setWebhook, verification with getWebhookInfo, receiving updates, idempotency, and a production checklist.

In webhook mode, Telegram automatically sends each Update POST request to your public HTTPS endpoint. This is the only option for production: the bot does not maintain a persistent connection but responds to incoming events. The key is to respond within 200 OK seconds, otherwise Telegram will retry delivery with exponential backoff (up to several minutes).

What you need before starting

  • A public domain with a valid TLS certificate (Let's Encrypt works, self-signed ones do not).
  • A PHP script accessible via HTTPS (e.g., https://example.com/telegram/webhook).
  • A secret token for verifying request authenticity (generate once, store in .env).
  • The php://input endpoint must be available on the server (nginx + php-fpm works out of the box).

Installing webhook via setWebhook

We use the function telegramApi() created in chapter Первый запрос: getMe. There we wrote a wrapper around cURL with HTTP status code checking, json_last_error and field ok — here we simply call it.

<?php
// set_webhook.php
require_once __DIR__ . '/src/TelegramClient.php'; // telegramApi()

$token = getenv('TELEGRAM_BOT_TOKEN');
if (!$token) {
die('TELEGRAM_BOT_TOKEN не задан в окружении\n');
}

// Секрет 1–256 символов: A-Z a-z 0-9 _ -
$secret = getenv('TELEGRAM_WEBHOOK_SECRET') ?: bin2hex(random_bytes(16));

$webhookUrl = 'https://example.com/telegram/webhook';

$resp = telegramApi($token, 'setWebhook', [
'url' => $webhookUrl,
'secret_token' => $secret,
'allowed_updates' => ['message', 'callback_query', 'inline_query'],
'drop_pending_updates' => true, // чистим хвост от polling
]);

// Разбор ответа
if (!($resp['ok'] ?? false)) {
echo "Ошибка setWebhook: " . json_encode($resp, JSON_UNESCAPED_UNICODE) . "\n";
exit(1);
}

echo "Webhook установлен: " . $webhookUrl . "\n";
echo "Secret (сохраните в .env): " . $secret . "\n";
// Пример ответа:
// {"ok":true,"result":true,"description":"Webhook was set"}

Example JSON API response:

{
"ok": true,
"result": true,
"description": "Webhook was set"
}

The field ok is the main success indicator. result: true confirms that the URL was accepted. If ok: false — see description (often: "Bad webhook: wrong certificate" or "Bad webhook: wrong URL"). In the terminal run php set_webhook.php — you will see the URL and generated secret. Save the secret in .env as TELEGRAM_WEBHOOK_SECRET=....

Checking current webhook — getWebhookInfo

Useful after deployment or during debugging of duplicate deliveries. The method requires no parameters.

<?php
// get_webhook_info.php
require_once __DIR__ . '/src/TelegramClient.php';

$token = getenv('TELEGRAM_BOT_TOKEN');
$info = telegramApi($token, 'getWebhookInfo');

echo json_encode($info, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";

Example response:

{
"ok": true,
"result": {
"url": "https://example.com/telegram/webhook",
"has_custom_certificate": false,
"pending_update_count": 0,
"last_error_date": 1700000000,
"last_error_message": "Wrong response from the webhook: 500 Internal Server Error",
"max_connections": 40,
"allowed_updates": ["message", "callback_query"]
}
}

Check pending_update_count (number of updates in queue), last_error_message (last delivery error) and last_error_date (Unix timestamp). If there are no errors — fields last_error_* are absent. Run php get_webhook_info.php and verify: pending_update_count: 0 present and last_error_message absent — means everything is clean.

Receiving updates: webhook endpoint

The script should: verify the secret, parse JSON, return 200 OK immediately, and offload heavy work to a queue (DB, Redis, RabbitMQ). Below is a minimal working example without a queue (for understanding).

<?php
// public/telegram/webhook.php
require_once __DIR__ . '/../../src/TelegramClient.php';

$secret = getenv('TELEGRAM_WEBHOOK_SECRET');
if (!$secret) {
error_log('TELEGRAM_WEBHOOK_SECRET не задан');
http_response_code(500);
exit;
}

// 1. Проверка секрета (заголовок X-Telegram-Bot-Api-Secret-Token)
$header = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';
if (!hash_equals($secret, $header)) {
error_log('Invalid secret token');
http_response_code(401);
exit;
}

// 2. Чтение raw body
$raw = file_get_contents('php://input');
if ($raw === false) {
http_response_code(400);
exit;
}

// 3. Парсинг JSON
$update = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($update)) {
error_log('Invalid JSON: ' . json_last_error_msg());
http_response_code(400);
exit;
}

// 4. Логируем для отладки (в production — только update_id)
error_log('Update received: ' . json_encode([
'update_id' => $update['update_id'] ?? null,
'type' => array_keys(array_filter($update, 'is_array', ARRAY_FILTER_USE_KEY))[0] ?? 'unknown'
]));

// 5. Идемпотентность: сохраняем update_id в БД с UNIQUE
// $pdo->prepare('INSERT IGNORE INTO processed_updates (update_id) VALUES (?)')->execute([$update['update_id']]);
// if ($pdo->lastInsertId() === 0) { http_response_code(200); exit; } // дубликат

// 6. Быстрый ACK — Telegram ждёт 200
http_response_code(200);
echo 'ok';

// 7. Дальше — асинхронная обработка (Job/Worker)
// processUpdate($update); // функция из главы Long polling

To see what arrived: in the terminal tail -f /var/log/nginx/error.log (or your php-fpm log) — you will see lines Update received: {...}. For detailed inspection temporarily add:

error_log('FULL UPDATE: ' . json_encode($update, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));

After this, the log will contain the full JSON with fields message, callback_query and so on — convenient for understanding the structure during development.

Retries and idempotency

Telegram may send one Update multiple times (network failure, script timeout, redeployment). Store processed update_id in a table with a UNIQUE index and skip duplicates on input. A single in-memory cursor won't help when there are multiple instances (php-fpm workers, Kubernetes pods). Example table schema:

CREATE TABLE processed_updates (
update_id BIGINT PRIMARY KEY,
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

In the code (uncomment block 5 in webhook.php) — INSERT IGNORE silently skips duplicates, lastInsertId() === 0 shows that an update has already been received.

Deleting webhook (switch back to polling)

<?php
// delete_webhook.php
require_once __DIR__ . '/src/TelegramClient.php';

$token = getenv('TELEGRAM_BOT_TOKEN');
$resp = telegramApi($token, 'deleteWebhook', [
'drop_pending_updates' => true, // сбросить накопившуюся очередь
]);
var_dump($resp);

After deleteWebhook you can use getUpdates again with long polling. The response will be {"ok":true,"result":true,"description":"Webhook deleted"}.

Checklist before production

  • setWebhook returned ok: true (see output php set_webhook.php above).
  • getWebhookInfo: pending_update_count: 0 and no last_error_message.
  • The endpoint responds 200 OK faster than 1 second on a test Update (verification via curl — below).
  • The secret goes into the header X-Telegram-Bot-Api-Secret-Token, not the query string — otherwise it would appear in nginx access logs.
  • Idempotency: table processed_updates with UNIQUE on update_id.
  • Heavy logic (outbound HTTP, DB, CRM) — runs in a worker; the webhook only accepts the update and responds ok.

How to run a test update via curl

This simulates a request from Telegram: you send the JSON to your own HTTPS endpoint yourself and look at the response code.

Two values are needed, which have already appeared in this chapter:

  • Webhook URL — the same address passed to setWebhook (section Установка webhook через setWebhook).
  • Secret — the string secret_token / variable TELEGRAM_WEBHOOK_SECRET. We generated it and saved it in .env in the same section Установка webhook; Telegram then sends it in header X-Telegram-Bot-Api-Secret-Token. Without this header your script will respond 401.

The bot token (TELEGRAM_BOT_TOKEN) for this test curl is not needed — you hit your own server, not api.telegram.org. The token is needed only when calling Bot API via telegramApi() (chapter Первый запрос: getMe).

Save the update body to a file (it's easier than one giant line in the terminal):

cat > /tmp/tg-update.json <<'EOF'
{
"update_id": 1,
"message": {
"message_id": 1,
"date": 1700000000,
"chat": { "id": 123, "type": "private" },
"from": { "id": 123, "is_bot": false, "first_name": "Test" },
"text": "/start"
}
}
EOF

Send a POST. Substitute the same TELEGRAM_WEBHOOK_SECRET in $secret that you saved after setWebhook (from .env or the output of php set_webhook.php):

export secret="$TELEGRAM_WEBHOOK_SECRET"
# или: export secret='ваш_секрет_из_env'

curl -sS -o /tmp/tg-webhook-body.txt -w "HTTP %{http_code} time %{time_total}s\n" \
-X POST "https://example.com/telegram/webhook" \
-H "Content-Type: application/json" \
-H "X-Telegram-Bot-Api-Secret-Token: $secret" \
--data-binary @/tmp/tg-update.json

Expected result in the terminal: a line like HTTP 200 time 0.05s (response time noticeably under 1 second). The response body is usually ok — you can view it like this:

cat /tmp/tg-webhook-body.txt

If you receive 401 — the secret didn't match. 400 — corrupted JSON. 500 — PHP error (check error.log). Until stable 200 appears, Telegram will accumulate pending_update_count and write last_error_message to getWebhookInfo in getWebhookInfo.

Related chapters: getUpdates и update_id, Long polling, HTTP-клиент на PHP, project Заявка с сайта → Telegram.

Next: С polling на webhook — how to correctly migrate a running bot without losing updates.

"}