← All posts
Tutorial

Telegram Bot API 429: Handle retry_after in Your Send Queue

When a Telegram Bot API send returns a flood-control error with parameters.retry_after, wait at least that many seconds before repeating the request. Persist the deadline and coordinate all workers using the same bot; sleeping inside one worker does not slow the others. A network timeout is different: without a response, the send outcome may be unknown, so it must not enter the same automatic retry branch.

Key takeaways

  • Read the structured Bot API response, not a number extracted from description.
  • Treat Telegram’s documented limits as scheduling constraints, not guaranteed throughput.
  • Separate durable inbound receipt from outbound sending and its cooldown.
  • Persist retry state across restarts, and recheck whether a delayed reply is still useful.

What Telegram’s sending limits actually mean

The official Bots FAQ advises avoiding more than one message per second in a single chat, limits group sending to 20 messages per minute, and describes bulk notifications at about 30 messages per second unless paid broadcasts are enabled. Short bursts can still lead to 429 errors. These are different scopes: spreading messages across chats does not remove the bot-wide broadcast constraint.

Do not use those figures as a promise that every request below them will succeed. Method-specific rules and the actual response still matter. This tutorial covers ordinary queued sends, not paid broadcast configuration or every possible reason for a 429.

The Bot API reference defines ResponseParameters.retry_after as an optional integer: the number of seconds left to wait before the request can be repeated when flood control is exceeded. It is a JSON field, not a documented universal HTTP header contract. Missing retry_after must not silently become zero.

Use a separate API request when the worker needs to inspect that result. The webhook-response versus separate sendMessage comparison explains why embedding a method in a Telegram webhook response gives up the method result.

Build a durable send lane, not a sleeping loop

The following is a recommended application design, not an additional Telegram API guarantee.

Before dispatch, store one local job for each intended reply, with the bot identity, destination, approved content, expiry decision, attempt count, and next eligible time. These are application-owned fields, not extra Bot API parameters. Deduplicate the business intention so a repeated incoming update does not create another reply job.

Use three scheduling gates:

  1. Bot gate: coordinate aggregate traffic across every process sharing the bot.
  2. Chat gate: pace each destination separately, including the group constraint.
  3. Job gate: respect the saved retry deadline, expiry, cancellation, and ownership lease.

Claim work atomically so two workers cannot dispatch the same job. Immediately before the network call, check the shared gates again. After a flood-control response, atomically extend the cooldown and release the job back to the scheduler; do not hold a webhook request open while waiting.

Telegram’s retry_after does not identify the exact limiting bucket. A conservative starting policy is to pause that bot’s send lane for the returned interval. This can delay unrelated chats, but avoids pretending the response proves a chat-only limit. Record this as your own policy and refine scheduling from observed evidence, not guessed server internals.

Convert retry_after into a saved deadline

This JavaScript helper illustrates only the classification step. It does not send requests, implement a database, or claim to be a tested production scheduler. receivedAtMs is the time your client received the response; currentNotBeforeMs is the existing shared cooldown loaded from storage.

function telegramFloodDecision(body, receivedAtMs, currentNotBeforeMs = 0) {
  if (body?.ok !== false || body.error_code !== 429) {
    return { action: 'classify_other_result' };
  }

  const seconds = body.parameters?.retry_after;
  if (!Number.isSafeInteger(seconds) || seconds < 0) {
    return { action: 'hold_for_review' };
  }

  const deadline = receivedAtMs + seconds * 1000;
  if (!Number.isSafeInteger(deadline)) {
    return { action: 'hold_for_review' };
  }

  return {
    action: 'reschedule',
    notBeforeMs: Math.max(currentNotBeforeMs, deadline)
  };
}

The returned names are local application values. Persist the maximum deadline transactionally; a normal read followed by an unguarded write can overwrite a newer, longer cooldown. Keep worker clocks synchronized or use a shared scheduling clock. Any jitter must add delay, not shorten Telegram’s wait.

A due job is eligible for another attempt, not guaranteed to succeed. Keep attempt and age budgets. If another response extends the cooldown, save the new deadline. If the job has expired or a human already answered, cancel or review it rather than sending stale content. The budgets and cancellation rules are business policy, not Telegram limits.

Keep rejection, uncertainty, and intake separate

Observed resultRecommended worker decision
Valid Bot API flood response with retry_afterPersist cooldown; retry only after all gates allow it
429 without a usable delayHold for classification; do not spin or assume zero delay
Successful sendMessage resultSave the returned Message; do not reschedule the same intention
Network timeout or lost responseMark outcome unknown; reconcile or review before another send
Other API errorClassify permissions, destination, and request validity separately

A local job ID prevents duplicate scheduling in your system; it is not a remote exactly-once guarantee. A crash after a successful send but before saving the response can still leave an uncertain outcome. Do not describe an outbox alone as eliminating all duplicate messages.

Inbound health is another boundary. Authenticate and durably store incoming updates, then acknowledge intake independently of a delayed send. If updates themselves are missing, use the getWebhookInfo diagnostics guide. pending_update_count measures updates awaiting delivery, not your outbound queue.

Keep the UnifyPort contract separate

UnifyPort’s unofficial interface uses a connected messaging account and a different text-message contract. Its error envelope contains error.code, error.numeric_code, and error.message, with request_id for tracing. The error reference does not define the Bot API’s parameters.retry_after field; do not paste this classifier into a UnifyPort client unchanged.

For a cross-channel worker, keep common job storage but use contract-specific result classifiers. Preserve HTTP status and structured errors. As the request-tracing guide explains, X-Request-Id is not a documented send-idempotency key. An unofficial interface does not remove upstream limits or make uncertain sends safe to repeat.

Also distinguish sending errors from responses your receiver returns. UnifyPort’s webhook delivery reference says delivery retries are immediate and do not honor Retry-After. Returning 429 from that receiver is not a way to schedule your outbound cooldown.

Acceptance checks and FAQ

Use local response fixtures rather than intentionally flooding Telegram. Test two workers sharing one bot, a restart during cooldown, a longer delay arriving after a shorter one, missing retry_after, cancellation while waiting, and a lost send response. Verify that no job dispatches before its saved deadline and that uncertain outcomes remain separate. These are proposed checks, not reported test results.

Is retry_after measured in seconds or milliseconds?

Seconds. Convert it explicitly when your scheduler stores milliseconds.

Can adding workers fix a 429?

Not by itself. Workers sharing a bot also share its constraints; uncoordinated concurrency can increase rejected attempts.

Does waiting guarantee the message will send?

No. It satisfies the returned wait condition. Other scheduling gates, permissions, and later API responses still apply.

Should a timeout use the same retry rule?

No. A timeout without a response is not a confirmed flood-control rejection and can leave the send outcome unknown.

Next step and sources

Add a persisted cooldown to your existing bot sender and test the crash boundaries before increasing concurrency. For a connected-account client, start from the UnifyPort error reference instead of assuming Bot API compatibility.

Official references checked on 2026-10-18:

UnifyPort API

Turn messaging integration into a stable product pipeline.

Start by sending through one API, then bring every inbound message back into your business system with standard events.