When building a Telegram bot that scales past a few hundred users, you will inevitably run into the strict rate limits imposed by the Telegram Bot API. Naive implementations that loop through a database of users and call sendMessage sequentially will quickly fail. They trigger HTTP 429 Too Many Requests errors, block the PHP execution thread, cause gateway timeouts, and can lead to temporary or permanent IP bans from Telegram's servers.
This guide covers the mechanics of Telegram's rate limits, how to design a robust database-backed outbox queue in PHP, how to group messages to prevent chat-specific spam, and how to handle API errors gracefully in production.
Understanding Telegram's Rate Limits
To prevent abuse and ensure platform stability, Telegram enforces several distinct rate limits on bots:
1. Global Limit: A bot can send a maximum of 30 messages per second across all chats. 2. Single Chat Limit: A bot can send no more than 1 message per second to a specific private chat, and no more than 20 messages per minute to a specific group or channel. 3. Bulk/Broadcast Limits: During large broadcasts, Telegram heavily throttles bots that attempt to hit thousands of users simultaneously.
If you exceed these limits, the Bot API returns an HTTP status code 429 with a JSON payload containing a retry_after parameter. This parameter specifies the exact number of seconds your bot must wait before resuming requests to that chat or globally.
{
"ok": false,
"error_code": 429,
"description": "Too Many Requests: retry after 9",
"parameters": {
"retry_after": 9
}
}
The Outbox Pattern: Database Schema
To safely handle these limits, you must decouple message generation from message delivery. Instead of sending a message immediately during an HTTP request (like a webhook callback), you write the message to an "outbox" table in your database. A background worker then processes this table at a controlled rate.
Here is a production-ready SQLite/MySQL schema for an outbox queue:
CREATE TABLE telegram_outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id BIGINT NOT NULL,
method VARCHAR(64) DEFAULT 'sendMessage',
payload TEXT NOT NULL, -- JSON-encoded parameters for the Bot API
status VARCHAR(20) DEFAULT 'pending', -- pending, sending, sent, failed, blocked
retry_after_until DATETIME DEFAULT NULL, -- Lock individual chat if 429 occurs
attempts INTEGER DEFAULT 0,
error_message TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_outbox_status_retry ON telegram_outbox (status, retry_after_until);
CREATE INDEX idx_outbox_chat_status ON telegram_outbox (chat_id, status);
Grouping Messages to Prevent Chat-Specific Spam
If your application generates multiple notifications for a single user in a short window (e.g., multiple order status updates or system alerts), sending them as separate messages will trigger the 1 message/second limit for that chat.
Instead of inserting a new row for every event, your application logic should attempt to group or coalesce pending messages for the same user.
Before inserting a new row into telegram_outbox, check if there is already a pending message for that chat_id. If one exists, append the new text to the existing payload instead of creating a new database record:
<?php
function queueMessage(PDO $db, int $chatId, string $newText): void
{
$db->beginTransaction();
// Check for an existing pending sendMessage payload for this user
$stmt = $db->prepare("
SELECT id, payload
FROM telegram_outbox
WHERE chat_id = :chat_id
AND status = 'pending'
AND method = 'sendMessage'
LIMIT 1 FOR UPDATE
");
$stmt->execute([':chat_id' => $chatId]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
$payload = json_decode($existing['payload'], true);
// Append the new text with a double newline separator
$payload['text'] .= "\n\n" . $newText;
$update = $db->prepare("
UPDATE telegram_outbox
SET payload = :payload, updated_at = CURRENT_TIMESTAMP
WHERE id = :id
");
$update->execute([
':payload' => json_encode($payload),
':id' => $existing['id']
]);
} else {
// Insert a fresh pending message
$payload = [
'chat_id' => $chatId,
'text' => $newText,
'parse_mode' => 'HTML'
];
$insert = $db->prepare("
INSERT INTO telegram_outbox (chat_id, method, payload)
VALUES (:chat_id, 'sendMessage', :payload)
");
$insert->execute([
':chat_id' => $chatId,
':payload' => json_encode($payload)
]);
}
$db->commit();
}
The Outbox Processor Engine
This PHP class handles the actual transmission of queued messages. It respects the global 30 requests/second limit, parses Telegram's responses, handles 429 Too Many Requests by locking the affected chat, and flags users who have blocked the bot (403 Forbidden) so you do not waste API calls on them in future broadcasts.
<?php
class TelegramOutboxProcessor
{
private PDO $db;
private string $botToken;
private int $globalLimitPerSecond = 30;
public function __construct(PDO $db, string $botToken)
{
$this->db = $db;
$this->botToken = $botToken;
}
public function processQueue(): void
{
// Fetch pending messages that are not locked by a retry_after timer
$stmt = $this->db->prepare("
SELECT * FROM telegram_outbox
WHERE status = 'pending'
AND (retry_after_until IS NULL OR retry_after_until < CURRENT_TIMESTAMP)
ORDER BY id ASC
LIMIT :limit
");
// Bind limit to prevent exceeding the global 30 msg/sec limit in this batch
$stmt->bindValue(':limit', $this->globalLimitPerSecond, PDO::PARAM_INT);
$stmt->execute();
$messages = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($messages)) {
return;
}
$startTime = microtime(true);
foreach ($messages as $msg) {
$this->sendMessage($msg);
}
// Maintain the global rate limit of 30 requests per second
$elapsedTime = microtime(true) - $startTime;
$minimumDuration = 1.0; // 1 second
if ($elapsedTime < $minimumDuration) {
usleep((int)(($minimumDuration - $elapsedTime) * 1000000));
}
}
private function sendMessage(array $msg): void
{
$url = sprintf('https://api.telegram.org/bot%s/%s', $this->botToken, $msg['method']);
$payload = json_decode($msg['payload'], true);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false) {
$this->markAsFailed($msg['id'], "cURL Error: " . $curlError);
return;
}
$result = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->markAsFailed($msg['id'], "Invalid JSON response from Telegram");
return;
}
if ($httpCode === 200 && isset($result['ok']) && $result['ok'] === true) {
$this->markAsSent($msg['id']);
return;
}
$this->handleApiError($msg, $httpCode, $result);
}
private function handleApiError(array $msg, int $httpCode, array $result): void
{
$description = $result['description'] ?? 'Unknown error';
if ($httpCode === 429) {
// We hit a rate limit. Extract retry_after.
$retryAfter = $result['parameters']['retry_after'] ?? 5;
$this->lockChat($msg['chat_id'], $retryAfter);
$this->resetToPending($msg['id'], "Rate limited. Retry after {$retryAfter}s");
return;
}
if ($httpCode === 403 || strpos($description, 'bot was blocked by the user') !== false) {
// User blocked the bot. Mark as blocked to prevent future attempts.
$this->markAsBlocked($msg['chat_id'], $msg['id'], $description);
return;
}
// Other API errors (e.g., chat not found, bad request)
$this->markAsFailed($msg['id'], "API Error {$httpCode}: {$description}");
}
private function markAsSent(int $id): void
{
$stmt = $this->db->prepare("UPDATE telegram_outbox SET status = 'sent', updated_at = CURRENT_TIMESTAMP WHERE id = :id");
$stmt->execute([':id' => $id]);
}
private function markAsFailed(int $id, string $error): void
{
$stmt = $this->db->prepare("
UPDATE telegram_outbox
SET status = 'failed',
error_message = :error,
attempts = attempts + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
");
$stmt->execute([':id' => $id, ':error' => $error]);
}
private function resetToPending(int $id, string $reason): void
{
$stmt = $this->db->prepare("
UPDATE telegram_outbox
SET status = 'pending',
error_message = :reason,
attempts = attempts + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
");
$stmt->execute([':id' => $id, ':reason' => $reason]);
}
private function lockChat(int $chatId, int $seconds): void
{
// Lock all pending messages for this specific chat
$stmt = $this->db->prepare("
UPDATE telegram_outbox
SET retry_after_until = datetime('now', '+' || :seconds || ' seconds')
WHERE chat_id = :chat_id
AND status = 'pending'
");
$stmt->execute([
':seconds' => $seconds,
':chat_id' => $chatId
]);
}
private function markAsBlocked(int $chatId, int $msgId, string $reason): void
{
$this->db->beginTransaction();
// Mark current message as failed
$stmt1 = $this->db->prepare("UPDATE telegram_outbox SET status = 'blocked', error_message = :reason WHERE id = :id");
$stmt1->execute([':id' => $msgId, ':reason' => $reason]);
// Cancel all other pending messages for this user
$stmt2 = $this->db->prepare("UPDATE telegram_outbox SET status = 'blocked' WHERE chat_id = :chat_id AND status = 'pending'");
$stmt2->execute([':chat_id' => $chatId]);
$this->db->commit();
}
}
Running the Daemon Worker
To process the queue continuously, run a CLI script as a background daemon. This script should run in an infinite loop, executing the processor and sleeping briefly to prevent CPU spikes.
<?php
// worker.php
require_once 'TelegramOutboxProcessor.php';
$db = new PDO('sqlite:bot_database.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$botToken = getenv('TELEGRAM_BOT_TOKEN');
if (!$botToken) {
die("TELEGRAM_BOT_TOKEN environment variable is missing.\n");
}
$processor = new TelegramOutboxProcessor($db, $botToken);
// Set up basic signal handling for graceful termination
$keepRunning = true;
if (function_exists('pcntl_signal')) {
pcntl_async_signals(true);
pcntl_signal(SIGTERM, function() use (&$keepRunning) {
$keepRunning = false;
});
pcntl_signal(SIGINT, function() use (&$keepRunning) {
$keepRunning = false;
});
}
while ($keepRunning) {
try {
$processor->processQueue();
} catch (Exception $e) {
// Log database or runtime exceptions here
error_log("Worker error: " . $e->getMessage());
}
// Sleep for 1 second before the next batch
sleep(1);
}
Production Considerations
* Database Locking: In high-concurrency environments, SQLite can suffer from database locks. If your bot handles millions of messages, migrate the outbox queue to MySQL/PostgreSQL or use Redis as a queue broker. * Deactivated Bots: When users block your bot, you receive an HTTP 403. It is critical to flag these users in your primary users table and exclude them from future broadcast campaigns. Continuing to send messages to users who have blocked your bot wastes system resources and increases the likelihood of Telegram flagging your bot for spam. * Systemd Service: Run your worker.php script under a process manager like Systemd or Supervisor. This ensures the worker restarts automatically if it crashes due to a database connection drop or memory leak. * Idempotency: When retrying failed messages, ensure you do not send duplicate messages. The outbox pattern naturally prevents this because each message has a unique database ID and status.
For more details on managing Telegram integrations, refer to the official documentation at https://botservice.biz/telegram-bot-api.
BotCreator — studio that ships Telegram bots / Mini Apps.