getUpdates long polling on PHP for local development: offset, timeout and duplicate handling

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.

\n\n

How long polling works: timeout and offset

\n\n

Short 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\n
    \n
  • timeout — how many seconds Telegram holds the request without updates. Usually 25–30.
  • \n
  • offset — the first update_id you want to receive. To confirm processing, the next request should use offset = last_update_id + 1.
  • \n
\n\n

Without shifting offset, Telegram will return the same updates for up to 24 hours.

\n\n
Important: CURLOPT_TIMEOUT in cURL must be at least 5–10 seconds longer than Telegram's timeout. Otherwise the client will cut the connection before receiving the server response.
\n\n

processed_updates table

\n\n

If the script crashes midway through a batch, Telegram will send the same updates upon restart. Check update_id in the database before processing.

\n\n
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\n

Minimal Bot API client on cURL

\n\n

The 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\n

Full getUpdates CLI cycle

\n\n

The script runs in an infinite loop: request with timeout=30, process, record update_id, shift offset.

\n\n
<?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\n

Idempotency in practice

\n\n

Typical 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
  1. Check update_id in processed_updates.
  2. \n
  3. If present — just shift offset.
  4. \n
  5. If absent — process, then INSERT, then set offset = update_id + 1.
  6. \n
\n\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\n

When to switch to webhook

\n\n

Long 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
\n\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.

\n\n
<?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\n

More details on receiving updates in Yii2 — see статье про webhook-контроллер.

\n\n

If you need a ready bot framework with both polling and webhook — check out botservice.biz.

"}

New articles on Telegram

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