Telegram Bot API Rate Limits in Practice: 429 Retry-After, Queues, and Message Grouping

When scaling a service or running mass notifications, developers inevitably encounter strict limitations Telegram Bot API. Attempting to send hundreds of messages from a cron script without considering rate limits leads to the 429 Too Many Requests error, thread blocking, and lost notifications.

Hard limits of Telegram Bot API: numbers and rules

The official Telegram documentation establishes several levels of request limits:

  • Global bot limit: no more than 30 messages per second across all chats combined.
  • Limit per personal chat: no more than 1 message per second. Short spikes are allowed, but prolonged sending causes the bot to immediately receive HTTP 429.
  • Limit for groups and channels: no more than 20 messages per minute.
  • Method sendMediaGroup: sending an album from multiple files counts as one API request, but is regulated by the same limits per chat.

If a bot exceeds the allowable frequency, the Telegram API returns HTTP status 429 Too Many Requests with a JSON response containing the parameters.retry_after field. Ignoring this value leads to cascading delays and temporary banning of the bot on the Telegram server side.

Handling 429 Retry-After error in pure PHP via cURL

For reliable interaction with api.telegram.org, you cannot use the basic function file_get_contents, as it does not allow flexible reading of response headers and error bodies for non-200 HTTP statuses. A valid HTTP client should check network errors, parse JSON, and correctly extract the wait time.

<?php

function sendTelegramMessage(string $method, array $params): array
{
$token = getenv('TELEGRAM_BOT_TOKEN');
if (!$token) {
throw new \RuntimeException('TELEGRAM_BOT_TOKEN environment variable is not set');
}

$url = "https://api.telegram.org/bot{$token}/{$method}";
$ch = curl_init($url);

curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($params),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
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) {
return [
'ok' => false,
'error_code' => 500,
'description' => 'cURL error: ' . $curlError
];
}

$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return [
'ok' => false,
'error_code' => 500,
'description' => 'Invalid JSON response from Telegram API'
];
}

if ($httpCode === 429) {
$retryAfter = $data['parameters']['retry_after'] ?? 3;
return [
'ok' => false,
'error_code' => 429,
'retry_after' => (int)$retryAfter,
'description' => $data['description'] ?? 'Too Many Requests'
];
}

return $data;
}

Outgoing queue architecture in Laravel with Retry-After support

Sending mass broadcasts or transactional notifications directly from a web request is inefficient. All outgoing messages must be sent through background queues (Laravel Queue, RabbitMQ or Redis).

When receiving a response with code 429, the task should not crash with an exception but return back to the queue with a delay equal to the retry_after value.

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class SendTelegramNotificationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public int $tries = 5;
public int $maxExceptions = 3;

public function __construct(
public int $chatId,
public string $text,
public string $leadId
) {}

public function handle(): void
{
$token = config('services.telegram.bot_token');

$response = Http::timeout(10)
->acceptJson()
->post("https://api.telegram.org/bot{$token}/sendMessage", [
'chat_id' => $this->chatId,
'text' => $this->text,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true,
]);

if ($response->status() === 429) {
$retryAfter = (int) $response->json('parameters.retry_after', 5);
Log::warning("Telegram limit reached. Retrying job for lead {$this->leadId} after {$retryAfter}s.");

// Освобождаем задачу обратно в очередь с паузой
$this->release($retryAfter + 1);
return;
}

$responseData = $response->json();

if (!$response->successful() || !($responseData['ok'] ?? false)) {
$description = $responseData['description'] ?? 'Unknown API Error';

// Если пользователь заблокировал бота, повторы бессмысленны
if ($response->status() === 403) {
Log::notice("Bot was blocked by user {$this->chatId} for lead {$this->leadId}.");
return;
}

Log::error("Failed to send Telegram message to {$this->chatId}: {$description}");
$this->fail(new \RuntimeException("Telegram API Error: {$description}"));
}
}
}

Notification grouping: load reduction strategy for the API

When business logic generates many small events for a single user (e.g., changing order position statuses), it is not possible to send one message per event — the chat will get spammed, and the bot will hit the 1-request-per-second limit.

The correct approach is buffering and combining multiple text fragments into a single final message. Telegram supports up to 4096 characters in a single sendMessage call.

<?php

class TelegramMessageBuffer
{
private array $buffer = [];

public function addNotification(int $chatId, string $line): void
{
$this->buffer[$chatId][] = $line;
}

public function flush(callable $sendCallback): void
{
foreach ($this->buffer as $chatId => $lines) {
$currentChunk = '';

foreach ($lines as $line) {
// Превышение лимита символов в одном сообщении (оставляем запас от 4096)
if (mb_strlen($currentChunk . "
" . $line) > 4000) {
$sendCallback($chatId, trim($currentChunk));
$currentChunk = $line;
usleep(100000); // Задержка 100мс для соблюдения лимитов чата
} else {
$currentChunk .= ($currentChunk === '' ? '' : "

") . $line;
}
}

if ($currentChunk !== '') {
$sendCallback($chatId, trim($currentChunk));
usleep(100000);
}
}

$this->buffer = [];
}
}

// Пример использования буфера
$buffer = new TelegramMessageBuffer();

// Заполняем событиями по лиду
$leadId = bin2hex(random_bytes(7));
$buffer->addNotification(123456789, "<b>Заявка #{$leadId}</b> принята в обработку.");
$buffer->addNotification(123456789, "Назначен менеджер: Иван.");
$buffer->addNotification(123456789, "Статус изменен на: <i>Ожидает оплаты</i>.");

// Сбрасываем накопленные сообщения единым отправлением
$buffer->flush(function (int $chatId, string $text) use ($leadId) {
// В реальности здесь вызывается задача очереди или cURL-клиент
sendTelegramMessage('sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
]);
});

What breaks during mass broadcasts and how to prevent it

During broadcasting to tens of thousands of users, standard scripts face a number of typical problems:

  1. Worker memory exhaustion: when selecting the entire subscriber database into one array, the script quickly exhausts memory_limit. The selection must be done in chunks (chunk() in Laravel or cursors in PDO).
  2. Processing 403 Forbidden: if a user has blocked the bot, Telegram returns status 403. The broadcast script must mark such users in the DB (e.g., set is_active = false) so that they do not consume limits during subsequent runs.
  3. Lack of idempotency: if a worker crashes, the queue may re-execute the same batch of messages. Each notification must be assigned a unique identifier (e.g., lead_id or a send UUID) that is recorded in the DB before making the API call.
When broadcasting, always configure a global rate limit (Rate Limiting) on the queue side. For Redis in Laravel, the optimal setting would be Redis::throttle('telegram-broadcast')->allow(25)->every(1), which allows storing 5 requests per second for priority transactional messages.

If you need development of fault-tolerant bots and key integrations, contact the team BotCreator.

"}

New articles on Telegram

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