DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

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

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'];
}
Enter fullscreen mode Exit fullscreen mode

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.
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 = [];
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)