Telegram Bot API Rate Limits: Handling 429 Retry-After, Queues, and Message Batching

When scaling a Telegram bot or launching bulk notifications, developers inevitably face strict Telegram Bot API limits. Ignoring platform rules leads to HTTP 429 errors, temporary message sending blocks, or a complete freeze of the bot's operations during peak loads.

In this article, we will examine the actual Telegram server limits, build a fault-tolerant PHP HTTP client to handle Retry-After responses, design an outgoing message queue system, and look into data grouping patterns to avoid exceeding the allowed call frequency.

Official and Hidden Telegram Bot API Limits

Telegram documentation describes the main limits, but in practice, their behavior has nuances that must be taken into account when designing the architecture:

  • Global sending limit: no more than 30 messages per second combined across all chats for a single bot token.
  • Personal chat limit: no more than 1 message per second to the same private chat with a user.
  • Groups and channels: no more than 20 messages per minute to the same group or channel.
  • Массовые рассылки: when sending long chains of notifications, the recommended speed is no more than 25–30 messages per second, even if messages are sent to different users.
  • Webhook payload: the size of the incoming request body from Telegram is limited, and the response timeout from your server is 10–60 seconds. If the backend does not respond with a 200 OK code in time, Telegram will start resending the exact same update_id.

If a bot exceeds the allowed call frequency, the API returns an HTTP 429 Too Many Requests status with a JSON response containing the parameters.retry_after field. This value specifies how many seconds to wait before making the next request.

Fault-Tolerant cURL Client with HTTP 429 Handling

Never use file_get_contents to interact with the Telegram API, as this function does not allow flexible handling of HTTP headers, timeouts, and REST-level error codes. Below is a PHP service class that implements proper sending via cURL with automatic 429 interception and a retry after a pause.

<?php

class TelegramApiClient
{
private string $token;
private int $maxRetries;

public function __construct(?string $token = null, int $maxRetries = 3)
{
$this->token = $token ?? (string) getenv('TELEGRAM_BOT_TOKEN');
$this->maxRetries = $maxRetries;

if (empty($this->token)) {
throw new InvalidArgumentException('Telegram bot token is missing.');
}
}

public function execute(string $method, array $params = []): array
{
$url = sprintf('https://api.telegram.org/bot%s/%s', $this->token, $method);
$attempt = 0;

while ($attempt < $this->maxRetries) {
$attempt++;

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);

if ($response === false) {
throw new RuntimeException('cURL error: ' . $curlError);
}

$decoded = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('Invalid JSON response from Telegram API');
}

if ($httpCode === 429) {
$retryAfter = $decoded['parameters']['retry_after'] ?? 1;
sleep((int) $retryAfter);
continue;
}

if ($httpCode === 403) {
// Пользователь заблокировал бота или бот был удален из чата
return [
'ok' => false,
'error_code' => 403,
'description' => $decoded['description'] ?? 'Forbidden',
];
}

if (isset($decoded['ok']) && $decoded['ok'] === true) {
return $decoded;
}

return $decoded;
}

throw new RuntimeException('Exceeded maximum retry attempts for method: ' . $method);
}
}

Outgoing Message Queue Architecture (Rate Limiter)

Calling an API method directly from the Webhook handler is bad practice: during a massive influx of users or when running a broadcast, the web server will exhaust its connection limit, and Telegram will temporarily block sending. The correct approach is to use an asynchronous task queue (based on Redis, RabbitMQ, or a DB table) with a background worker.

The worker must control two pauses: a global interval (no more than 30 requests per second) and a personal per-chat interval (at least 1.05 seconds between messages to a single user).

<?php

class MessageQueueWorker
{
private TelegramApiClient $apiClient;
private PDO $db;

public function __construct(TelegramApiClient $apiClient, PDO $db)
{
$this->apiClient = $apiClient;
$this->db = $db;
}

public function processNext(): bool
{
// Выбираем из базы наиболее старое непрочитанное сообщение
$stmt = $this->db->prepare("
SELECT * FROM bot_outbox
WHERE status = 'pending' AND scheduled_at <= NOW()
ORDER BY id ASC LIMIT 1 FOR UPDATE SKIP LOCKED
");
$stmt->execute();
$job = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$job) {
return false;
}

$chatId = $job['chat_id'];

// Проверяем время последней отправки в этот конкретный чат
$lastSentStmt = $this->db->prepare("SELECT last_sent_at FROM bot_chats WHERE chat_id = :chat_id");
$lastSentStmt->execute([':chat_id' => $chatId]);
$lastSentAt = $lastSentStmt->fetchColumn();

if ($lastSentAt) {
$timePassed = microtime(true) - (float) $lastSentAt;
if ($timePassed < 1.05) {
// Задерживаем обработку для соблюдения лимита 1 сообщ/сек на чат
usleep((int) ((1.05 - $timePassed) * 1000000));
}
}

$payload = json_decode($job['payload'], true);
$result = $this->apiClient->execute('sendMessage', $payload);

if (isset($result['ok']) && $result['ok'] === true) {
$this->markAsSent($job['id']);
$this->updateLastSentTime($chatId, microtime(true));
} else {
$errorCode = $result['error_code'] ?? 0;
if ($errorCode === 403) {
// Деактивируем пользователя, если он заблокировал бота
$this->markUserAsBlocked($chatId);
$this->markAsFailed($job['id'], 'User blocked bot');
} else {
$this->markAsFailed($job['id'], $result['description'] ?? 'Unknown error');
}
}

// Глобальный паузер между любыми исходящими запросами (максимум 30 TPS)
usleep(35000); // ~35 мс пауза = до 28 запросов в секунду

return true;
}

private function markAsSent(int $id): void
{
$stmt = $this->db->prepare("UPDATE bot_outbox SET status = 'sent', sent_at = NOW() WHERE id = :id");
$stmt->execute([':id' => $id]);
}

private function markAsFailed(int $id, string $reason): void
{
$stmt = $this->db->prepare("UPDATE bot_outbox SET status = 'failed', error_message = :err WHERE id = :id");
$stmt->execute([':err' => $reason, ':id' => $id]);
}

private function updateLastSentTime(int $chatId, float $microtime): void
{
$stmt = $this->db->prepare("
INSERT INTO bot_chats (chat_id, last_sent_at) VALUES (:chat_id, :last_sent)
ON DUPLICATE KEY UPDATE last_sent_at = :last_sent
");
$stmt->execute([':chat_id' => $chatId, ':last_sent' => $microtime]);
}

private function markUserAsBlocked(int $chatId): void
{
$stmt = $this->db->prepare("UPDATE bot_users SET is_active = 0 WHERE chat_id = :chat_id");
$stmt->execute([':chat_id' => $chatId]);
}
}

Grouping and Batching Messages into a Single Chat

A common mistake is sending several related messages in a row (for example, a text header, then a photo, followed by a block with order details). This wastes the 1 msg/sec limit and forces the user to read fragmented information.

1. Combining Text and Keyboard

Build a single HTML/Markdown template with all the order data and attach an inline keyboard to it in a single sendMessage call. The maximum message text length in Telegram is 4096 characters, which is sufficient for most business scenarios.

2. Using sendMediaGroup

If you need to send multiple photos or documents, group them using the sendMediaGroup method (from 2 to 10 files in a single album). This counts as 1 request to the Telegram API.

What Breaks During Bulk Broadcasts and How to Handle It

When sending notifications to a large user base (10,000+ recipients), you will inevitably encounter the following situations:

  1. Wave of 403 Forbidden errors: some users will delete or block the bot. If the worker continues hammering the API with requests to these users, the platform will lower the bot's overall trust score. Upon receiving a 403, immediately disable sending for that chat_id in the DB.
  2. Cascading 429: if a worker receives a 429 and your system runs multiple parallel sending processes, they will all start receiving 429 simultaneously. Use a shared pause in Redis (Rate Limiter Circuit Breaker) so that when a 429 occurs in one worker, all other processes pause for the retry_after interval specified.
  3. Webhook clogging during broadcasts: sending should not block the synchronous processing of incoming user commands. Separate the queues: assign high priority to responses to incoming messages (such as button clicks) and low priority to background broadcasts.

Development of fault-tolerant Telegram-бот s and integrations of any complexity — BotCreator.

New articles on Telegram

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