Handle Telegram Bot API Rate Limits in PHP: 429, Retry-After, Outbound Queues, and Grouping

What we build

Telegram's Bot API enforces per-chat and global limits. Hit them and you get HTTP 429 Too Many Requests with Retry-After in seconds — or silent drops during broadcasts. This tutorial covers:

  • Parsing 429 and retry_after correctly in PHP with cURL
  • A small outbound queue that serializes sends per chat
  • Buffering bursts into one chat to respect ~1 msg/sec per chat
  • Common breakage during mass mailings

This is not a framework review. It is a working PHP pattern you can drop next to a webhook handler.

Limits you must respect

Official docs are the source of truth. In practice:

  • ~1 message per second to the same private chat; ~20 per minute to the same group
  • ~30 messages per second globally per bot (short bursts allowed)
  • sendMessage and related methods return 429 with JSON parameters.retry_after (seconds)

Ignore these and Telegram throttles you, your queue grows, and broadcasts look broken. Treat retry_after as authoritative — do not invent your own backoff.

1. A cURL wrapper that surfaces Retry-After

Parse the JSON, check ok, and on 429 capture retry_after. Do not sleep a fixed value.

<?php

function tg(string $method, array $params): array
{
$token = getenv('TG_BOT_TOKEN');
$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 => 15,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($body === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException("cURL error: {$err}");
}
curl_close($ch);

$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('Bad JSON: ' . json_last_error_msg());
}

if ($status === 429 && is_array($decoded)) {
$retry = $decoded['parameters']['retry_after'] ?? 1;
$err = new RuntimeException("rate limited: retry after {$retry}s");
$err->retryAfter = (int) $retry;
throw $err;
}
if (empty($decoded['ok'])) {
throw new RuntimeException('TG error: ' . ($decoded['description'] ?? 'unknown'));
}
return $decoded['result'];
}

Use getenv('TG_BOT_TOKEN') — never hardcode the token. http_build_query keeps form encoding predictable for parse_mode=HTML.

2. Outbound queue keyed by chat_id

The simplest correct queue is a per-chat FIFO with a worker loop. Each chat has its own sequence. Sleep on retry_after when Telegram says so; otherwise wait ~1.1s between sends to the same chat.

<?php

final class TgQueue
{
/** @var array<int, array<int, array{0:string,1:array}>> */
private array $byChat = [];
/** @var array<int, true> */
private array $inflight = [];

public function enqueue(int $chatId, string $method, array $params): void
{
$this->byChat[$chatId][] = [$method, $params];
}

public function run(int $maxIdleRounds = 5): void
{
$idle = 0;
$lastPerChat = [];

while (!empty($this->byChat) || !empty($this->inflight)) {
$progress = false;

foreach ($this->byChat as $chatId => $jobs) {
if (isset($this->inflight[$chatId])) {
continue;
}
$now = microtime(true);
if (isset($lastPerChat[$chatId]) && $now - $lastPerChat[$chatId] < 1.1) {
continue;
}

[$method, $params] = array_shift($jobs);
$this->byChat[$chatId] = $jobs;
$this->inflight[$chatId] = true;

try {
tg($method, $params);
} catch (RuntimeException $e) {
if (isset($e->retryAfter)) {
array_unshift($this->byChat[$chatId], [$method, $params]);
sleep(max(1, $e->retryAfter));
} else {
error_log("drop job {$method} chat {$chatId}: " . $e->getMessage());
}
}

$lastPerChat[$chatId] = microtime(true);
unset($this->inflight[$chatId]);
if (empty($this->byChat[$chatId])) {
unset($this->byChat[$chatId]);
}
$progress = true;
}

if (!$progress) {
$idle++;
if ($idle >= $maxIdleRounds) {
break;
}
usleep(200000);
} else {
$idle = 0;
}
// Global cap ~30/s. With 1.1s per chat this rarely fires;
// busy bots should add a token bucket here.
}
}
}

This is intentionally not Redis. For a single-process worker at a few hundred messages per minute it is enough. With multiple workers, use Redis lists keyed by chat_id and an atomic pop.

3. Grouping messages in one chat

If one chat produces many small updates, do not call sendMessage once per event. Prefer:

  • Buffer text and send at most once per second for that chat (the 1.1s guard already helps).
  • For live status, editMessageText on one message instead of sending N new ones.
<?php

final class ChatBuffer
{
private array $pending = []; // chatId => string

public function push(int $chatId, string $text): void
{
$this->pending[$chatId] = ($this->pending[$chatId] ?? '') . $text;
}

public function flush(TgQueue $q): void
{
foreach ($this->pending as $chatId => $text) {
$q->enqueue($chatId, 'sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
]);
}
$this->pending = [];
}
}

Flush on a 1s timer. For HTML, escape user input with htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') before concatenation.

4. What breaks during broadcasts

  • Fan-out faster than ~30 msg/s. A naive loop over subscribers trips the global cap. Route through the queue and add a token bucket near 25/s for headroom.
  • Identical text to many chats at once. Add small jitter (random_int(0, 500) ms) before each enqueue.
  • Creating domain rows after send. Generate an id with bin2hex(random_bytes(7)), INSERT first, then enqueue the notification.
  • In-memory update_id cursors. Persist them. A restart that reprocesses updates doubles outbound traffic.
  • callback_data over 64 bytes. Store a short id, not a full row.
  • Forgetting answerCallbackQuery. The button spins and looks like a rate-limit problem when it is not.

5. Production notes

  • Floor retry_after: even if Telegram returns 0, sleep at least one second.
  • For multi-process workers, claim jobs with SELECT ... FOR UPDATE SKIP LOCKED (PostgreSQL) or a claimed_at column (MySQL).
  • Circuit breaker: after ~20 consecutive 429s, pause the worker for 30 seconds.
  • Log every 429 with retry_after, chat_id, and method.
  • Webhooks must answer Telegram in a few seconds. Enqueue outbound sends and return 200 immediately — do not call sendMessage synchronously in the webhook.

Wrap-up

Honor Retry-After, pace per chat, and keep outbound work behind a queue. That is enough for a bot that survives broadcasts without falling over under load.

If you maintain bots that hit these limits daily and want queues, retries, and pacing built in from day one, start at botservice.biz.

New articles on Telegram

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