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
429andretry_aftercorrectly 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)
sendMessageand related methods return429with JSONparameters.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,
editMessageTexton 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_idcursors. Persist them. A restart that reprocesses updates doubles outbound traffic. callback_dataover 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 returns0, sleep at least one second. - For multi-process workers, claim jobs with
SELECT ... FOR UPDATE SKIP LOCKED(PostgreSQL) or aclaimed_atcolumn (MySQL). - Circuit breaker: after ~20 consecutive
429s, pause the worker for 30 seconds. - Log every
429withretry_after,chat_id, and method. - Webhooks must answer Telegram in a few seconds. Enqueue outbound sends and return
200immediately — do not callsendMessagesynchronously 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.