What we build
Telegram's Bot API applies per-chat and per-method limits. Hit them and you get HTTP 429 Too Many Requests with a Retry-After in seconds, or worse, silent drops during broadcasts. In this tutorial we cover:
- Parsing 429 and Retry-After correctly in PHP cURL. - A small outbound queue that serializes sends per chat and globally throttles. - Grouping bursts into a single chat to avoid the 1 msg/sec/chat ceiling. - Common breakage during mass mailings (same text, fan-out, duplicate sends).
This is not a framework review. It is a working PHP pattern you can drop into a webhook handler.
Limits you must respect
Telegram publishes these in the official docs and they are the source of truth:
- ~1 message per second per chat to the same chat, and ~20 messages per minute to the same group. - ~30 messages per second globally per bot, with short bursts allowed. - sendMessage and friends return 429 with a JSON body containing parameters.retry_after (seconds). getUpdates long polling uses retry_after differently.
If you ignore these, Telegram will throttle you, your queue will grow, and your broadcast will look broken. Treat Retry-After as authoritative; do not invent your own backoff.
1. A cURL wrapper that surfaces Retry-After
The Bot API returns JSON. Parse it, check ok, and on 429 capture retry_after. Do not just sleep a fixed value.
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'];
}
Note the deliberate use of getenv('TG_BOT_TOKEN') — never hardcode the token. http_build_query avoids surprises with parse_mode=HTML parameters and forces percent-encoding.
2. An outbound queue keyed by chat_id
The simplest correct queue is a per-chat FIFO with a small worker loop. Each chat gets its own sequence; the worker sleeps Retry-After when Telegram says so, otherwise ~1.1s between sends to the same chat.
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->inflight[$chatId] = true;
try {
tg($method, $params);
} catch (RuntimeException $e) {
if (isset($e->retryAfter)) {
array_unshift($this->byChat[$chatId], [$method, $params]);
sleep($e->retryAfter);
} else {
// non-recoverable: drop or persist for review
error_log("drop job {$method} chat {$chatId}: " . $e->getMessage());
}
}
$lastPerChat[$chatId] = microtime(true);
unset($this->inflight[$chatId]);
if (empty($jobs)) 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 and many chats this rarely fires,
// but a busy bot should add a token bucket here.
}
}
}
This is intentionally not a Redis queue. For a single-process webhook that handles a few hundred messages per minute it is enough. For production with multiple workers, replace the in-memory arrays with Redis lists keyed by chat_id and use a Lua script for atomic pop.
3. Grouping messages in one chat
If you have a chat where you produce many small updates (for example, a status feed), do not call sendMessage once per event. Use one of:
- Buffer text in your app and call sendMessage at most once per second for that chat. The 1.1s guard above already enforces this. - Use sendMessageGroup only if you genuinely need it; the standard Bot API does not expose it. The legitimate "group messages into one chat" trick is to buffer client-side and send one richer message. - For long updates, use editMessageText on a single pinned message instead of sending N new messages.
A minimal buffering helper:
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 = [];
}
}
Combine ChatBuffer::flush() with a 1s timer. For HTML payloads, escape user input with htmlspecialchars($s, ENT_QUOTED | ENT_SUBSTITUTE, 'UTF-8') before concatenation; the Bot API will reject unescaped < and > otherwise.
4. What breaks during broadcasts
Broadcasts are where teams hit limits hardest. Common failure modes:
- Fan-out faster than 30 msg/s. A naive loop that calls sendMessage for each subscriber in series will trip the global cap once you exceed a few thousand recipients. Fan-out via the queue and add a token bucket capped near 25/s to leave headroom. - Same text sent to many chats at the same moment. Telegram treats identical bursts as suspicious and may temporarily block. Add small jitter per recipient (random_int(0, 500) ms) before each enqueue. - Lead ids generated after sendMessage. If your flow is: create lead, then notify, and you skip the lead insert on Telegram error, you will lose events. Generate the id with bin2hex(random_bytes(7)), INSERT first, then enqueue the notification. - Single-cursor dedup on webhooks. When you also use getUpdates to recover from outages, do not rely on one in-memory update_id; persist it. Otherwise a restart loses the cursor and you re-process messages, doubling your send rate right when you can least afford it. - callback_data over 64 bytes. Telegram silently truncates. If you encode a lead id in a button, store the short id, not the full row. - Forgetting answerCallbackQuery. The button "spins" until it times out and you may look rate-limited even though you are not. Always answer, even with an empty string.
5. Production notes
- Wrap Retry-After with a small floor: even if Telegram returns 0, sleep at least one second before retrying. - For multi-process workers, use a database row with SELECT ... FOR UPDATE SKIP LOCKED (PostgreSQL) or a claimed_at column (MySQL) to make the per-chat queue transactional. - Add a circuit breaker: if you observe 20 consecutive 429s, pause the worker for 30 seconds. Telegram occasionally tightens limits on noisy bots. - Log every 429 with retry_after, chat_id, and method. Patterns are easier to spot in logs than in dashboards. - Remember that webhooks must respond to Telegram within a few seconds. Do not call sendMessage synchronously inside the webhook handler — enqueue and return 200 immediately.
These patterns are enough to ship a bot that survives broadcasts and does not fall over under load. Keep the Retry-After source of truth, and treat the queue as the boundary between your code and Telegram's pace.
---
If you maintain bots that hit these limits daily and want a studio that ships this kind of plumbing as a default rather than a retrofit, BotCreator builds Telegram bots and Mini Apps with queues, retries, and observability wired in. Further reading: