Local development of webhooks is hindered by the need for public HTTPS, a tunnel, and SSL. The getUpdates method (long polling) allows pushing Telegram-бота directly from the machine without an external IP.
How long polling works: timeout and offset
\n\nShort polling repeatedly asks the API \"are there updates?\" every second. Long polling keeps the HTTP connection open until an event occurs or the timeout expires.
- \n
- timeout — how many seconds Telegram holds the request without updates. Usually
25–30. \n - offset — the first
update_idyou want to receive. To confirm processing, the next request should useoffset = last_update_id + 1. \n
Without shifting offset, Telegram will return the same updates for up to 24 hours.
Important:\n\nCURLOPT_TIMEOUTin cURL must be at least 5–10 seconds longer than Telegram'stimeout. Otherwise the client will cut the connection before receiving the server response.
processed_updates table
\n\nIf the script crashes midway through a batch, Telegram will send the same updates upon restart. Check update_id in the database before processing.
CREATE TABLE processed_updates (
update_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
processed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- периодическая чистка (Telegram хранит апдейты ~24ч)
DELETE FROM processed_updates
WHERE processed_at < NOW() - INTERVAL 2 DAY;\n\nMinimal Bot API client on cURL
\n\nThe token comes only from the environment. For long polling, the client timeout is higher than the server-side one.
\n\n<?php
declare(strict_types=1);
function tg(string $method, array $params = [], int $curlTimeout = 40): array
{
$token = getenv('TG_BOT_TOKEN');
if (!$token) {
throw new RuntimeException('TG_BOT_TOKEN is not set');
}
$ch = curl_init("https://api.telegram.org/bot{$token}/{$method}");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => $curlTimeout,
CURLOPT_POSTFIELDS => http_build_query($params),
]);
$body = curl_exec($ch);
if ($body === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException("cURL: {$err}");
}
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($body, true);
if (!is_array($data) || empty($data['ok'])) {
$desc = is_array($data) ? ($data['description'] ?? 'unknown') : 'bad JSON';
throw new RuntimeException("TG {$status}: {$desc}");
}
return $data['result'];
}\n\nFull getUpdates CLI cycle
\n\nThe script runs in an infinite loop: request with timeout=30, process, record update_id, shift offset.
<?php
declare(strict_types=1);
require __DIR__ . '/tg_client.php'; // функция tg() выше
$pdo = new PDO(
getenv('DSN') ?: 'mysql:host=127.0.0.1;dbname=bot;charset=utf8mb4',
getenv('DB_USER') ?: 'bot',
getenv('DB_PASS') ?: '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$insert = $pdo->prepare(
'INSERT IGNORE INTO processed_updates (update_id) VALUES (:id)'
);
$exists = $pdo->prepare(
'SELECT 1 FROM processed_updates WHERE update_id = :id LIMIT 1'
);
$offset = 0;
$tgTimeout = 30;
fwrite(STDERR, "Polling started (timeout={$tgTimeout})\n");
while (true) {
try {
$updates = tg('getUpdates', [
'offset' => $offset,
'timeout' => $tgTimeout,
'allowed_updates' => json_encode(['message', 'callback_query']),
], $tgTimeout + 10);
} catch (Throwable $e) {
fwrite(STDERR, 'getUpdates error: ' . $e->getMessage() . "\n");
sleep(2);
continue;
}
foreach ($updates as $update) {
$updateId = (int) $update['update_id'];
$exists->execute([':id' => $updateId]);
if ($exists->fetchColumn()) {
$offset = $updateId + 1;
continue;
}
handleUpdate($update); // ваша бизнес-логика
$insert->execute([':id' => $updateId]);
$offset = $updateId + 1;
}
}
function handleUpdate(array $update): void
{
if (isset($update['message']['text'])) {
$chatId = $update['message']['chat']['id'];
$text = $update['message']['text'];
tg('sendMessage', [
'chat_id' => $chatId,
'text' => 'Echo: ' . $text,
], 15);
}
}\n\nIdempotency in practice
\n\nTypical failure: received 5 updates, processed 3, then crashed on the 4th. Without the table, on restart the first three would be sent again. Pattern:
\n\n- \n
- Check
update_idinprocessed_updates. \n - If present — just shift
offset. \n - If absent — process, then
INSERT, then setoffset = update_id + 1. \n
function markProcessed(PDO $pdo, int $updateId): bool
{
$stmt = $pdo->prepare(
'INSERT IGNORE INTO processed_updates (update_id) VALUES (?)'
);
$stmt->execute([$updateId]);
// rowCount() === 1 — первый раз; 0 — уже был
return $stmt->rowCount() === 1;
}\n\nWhen to switch to webhook
\n\nLong polling is convenient for local development, demos, and simple bots. In production under load, webhook is better:
\n\n- \n
- web-server parallelizes requests, polling uses a single thread; \n
- no persistent open connection to the API; \n
- lower delivery latency. \n
Before switching, stop polling (Ctrl+C) and call setWebhook. Both modes cannot run simultaneously: Telegram delivers updates either via polling or to the URL.
<?php
// один раз при деплое
tg('deleteWebhook', ['drop_pending_updates' => false], 15);
tg('setWebhook', [
'url' => 'https://example.com/telegram/webhook',
'secret_token' => getenv('TG_WEBHOOK_SECRET'),
'allowed_updates' => json_encode(['message', 'callback_query']),
], 15);\n\nMore details on receiving updates in Yii2 — see статье про webhook-контроллер.
\n\nIf you need a ready bot framework with both polling and webhook — check out botservice.biz.
"}