When developing Telegram bots locally, setting up public HTTPS webhooks usually requires third-party tunneling tools like Ngrok or Cloudflare Tunnels. Long polling via the getUpdates method is a simpler alternative for local development. It allows your local PHP script to pull updates directly from Telegram's servers without exposing a public port.
However, writing a naive long polling loop can lead to high CPU usage, missed updates, or duplicate message processing. This guide demonstrates how to implement a robust CLI-based long polling loop in native PHP that handles timeouts, tracks offsets, and prevents duplicate processing.
The Mechanics of getUpdates
To implement long polling correctly, you must understand three key parameters of the getUpdates method:
-
offset: Identifier of the first update to be returned. To acknowledge receipt of an update and prevent Telegram from sending it again, you must callgetUpdateswith anoffsetequal tolast_processed_update_id + 1. -
timeout: The timeout in seconds for long polling. By setting this (e.g., to30), Telegram will keep the connection open for up to 30 seconds if no new updates are available, reducing unnecessary HTTP requests. -
limit: Limits the number of updates to be retrieved (1–100).
The Long Polling Script
Create a script named bot.php. This script runs as a persistent CLI process. It uses cURL to poll the Telegram API, dynamically updates the offset, and handles common network and API errors.
<?php
// bot.php
// Retrieve the bot token from environment variables
$token = getenv('TELEGRAM_BOT_TOKEN');
if (!$token) {
fwrite(STDERR, "Error: TELEGRAM_BOT_TOKEN environment variable is not set.\n");
exit(1);
}
$offset = 0;
$timeout = 30; // Seconds Telegram should hold the connection open
echo "Starting Telegram long polling loop...\n";
while (true) {
$url = "https://api.telegram.org/bot{$token}/getUpdates";
$params = [
'offset' => $offset,
'timeout' => $timeout,
'limit' => 100,
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url . '?' . http_build_query($params));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// The cURL timeout must be slightly longer than the Telegram API timeout
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout + 5);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
$response = curl_exec($ch);
$curlError = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false) {
fwrite(STDERR, "cURL Error: {$curlError}. Retrying in 5 seconds...\n");
sleep(5);
continue;
}
if ($httpCode !== 200) {
fwrite(STDERR, "HTTP Error: Status code {$httpCode}. Retrying in 5 seconds...\n");
sleep(5);
continue;
}
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
fwrite(STDERR, "JSON Decode Error: " . json_last_error_msg() . ". Retrying...\n");
sleep(5);
continue;
}
if (!isset($data['ok']) || !$data['ok']) {
$description = $data['description'] ?? 'Unknown error';
fwrite(STDERR, "Telegram API Error: {$description}. Retrying...\n");
sleep(5);
continue;
}
$updates = $data['result'] ?? [];
foreach ($updates as $update) {
$updateId = $update['update_id'];
try {
processUpdate($update);
} catch (Exception $e) {
fwrite(STDERR, "Error processing update {$updateId}: " . $e->getMessage() . "\n");
}
// Update the offset to acknowledge this update
$offset = $updateId + 1;
}
}
function processUpdate(array $update): void {
if (isset($update['message'])) {
$message = $update['message'];
$chatId = $message['chat']['id'] ?? null;
$text = $message['text'] ?? '';
if ($chatId && $text) {
echo "Processed message from Chat ID {$chatId}: " . htmlspecialchars($text) . "\n";
}
}
}
To run this script locally, export your bot token and execute the file from your terminal:
export TELEGRAM_BOT_TOKEN="123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ"
php bot.php
Preventing Duplicate Processing (Idempotency)
In a standard long polling loop, if your script fetches a batch of 10 updates, successfully processes 5 of them, and then crashes or encounters a network timeout before sending the next getUpdates request, Telegram will re-deliver all 10 updates on the next cycle. This leads to duplicate processing.
To prevent this, you should track processed update_ids in a persistent storage layer (such as SQLite or Redis) and check them before executing any business logic.
Here is how to implement a lightweight SQLite-based idempotency check:
function isUpdateProcessed(int $updateId): bool {
static $db = null;
if ($db === null) {
$db = new PDO('sqlite:' . __DIR__ . '/processed_updates.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec("CREATE TABLE IF NOT EXISTS processed (update_id INTEGER PRIMARY KEY, processed_at TEXT)");
}
try {
$stmt = $db->prepare("INSERT INTO processed (update_id, processed_at) VALUES (:id, datetime('now'))");
$stmt->execute([':id' => $updateId]);
return false; // Not processed before, successfully inserted
} catch (PDOException $e) {
// Integrity constraint violation means the update_id already exists
if ($e->getCode() === '23000') {
return true;
}
throw $e;
}
}
Integrate this check at the beginning of your processUpdate function:
function processUpdate(array $update): void {
$updateId = $update['update_id'];
if (isUpdateProcessed($updateId)) {
echo "Update {$updateId} already processed. Skipping.\n";
return;
}
// Proceed with business logic...
}
When to Switch to Webhooks
While long polling is ideal for local development, it is rarely suitable for production environments for several reasons:
- Concurrency: Long polling processes updates sequentially in a single thread. Webhooks allow your web server (Nginx/Apache) to handle thousands of incoming updates concurrently using PHP-FPM workers.
- Resource Consumption: Keeping a persistent CLI script running requires process monitoring (like Supervisor) and holds open network connections continuously.
- Latency: Webhooks deliver updates instantly as they happen, whereas long polling introduces a slight delay depending on the poll interval and network round-trips.
For production deployments, configure a webhook using the setWebhook method and secure it with a secret_token header to verify that incoming requests originate from Telegram.
For further reading on managing production webhooks, security headers, and API limits, refer to the guides at https://botservice.biz/telegram-bot-api.
BotCreator — studio that ships Telegram bots / Mini Apps.