Documentation menu

Rate limits

Telegram API Rate Limits & Quotas

The six rate classes with their real budgets, how each key is derived, what happens when the limiter loses its store, and how to pace a client.

Verified against the checkout built for v2.2.4 · updated 20 Oct 2026

On this page

Every request is evaluated against one class, and the class is chosen by the route, not by the caller. The numbers below are the defaults compiled into the limiter — a deployment may raise or lower them, and GET /api/admin/ratelimit reports the policies actually in force, which is the honest way to find out what your server enforces.

The six classes

auth30 / min · burst 10
Key
peer address
On failure
fail-closed — refuse while it cannot enforce
Applies to
Every route under /api/auth/ — login, register, code exchange, Mini App sign-in.

A credential-stuffing loop is cheap to send and expensive to verify, so this class is the tightest one in the system.

shield600 / min · burst 120
Key
peer address
On failure
fail-closed — refuse while it cannot enforce
Applies to
The outermost limit, applied before the token is looked at.

Keyed on the address, not the identity: a flood of forged tokens never reaches the core because the caller is never a user in the first place.

read600 / min · burst 120
Key
verified user
On failure
fall back to the in-process limiter and keep serving
Applies to
Dashboard reads — tasks, sessions, logs, stats, queue.

Generous enough for a dashboard that polls, tight enough that one tenant cannot saturate the core for the others.

write120 / min · burst 30
Key
verified user
On failure
fall back to the in-process limiter and keep serving
Applies to
Creating and changing tasks, filters, configs, settings.

Writes cost a database round trip and a validation pass; the budget reflects that rather than the read budget.

admin300 / min · burst 60
Key
verified user
On failure
fall back to the in-process limiter and keep serving
Applies to
Everything under /api/admin/.

Installation-wide operations are not rarer than tenant writes — they are simply not unlimited.

probe1200 / min · burst 300
Key
peer address
On failure
fall back to the in-process limiter and keep serving
Applies to
Health and readiness routes.

A monitoring system polling every second must never be the reason a real caller is refused.

What the limiter does with a request
BehaviourHow it worksWhat a client sees
Token bucket per windowA Lua script evaluates the bucket, decrements it and reports the remaining units in one round trip; the window is refilled continuously rather than reset on the minute.A steady trickle is never refused even at the average rate.
Burst is separateBurst is the bucket depth, so a short spike above the average is allowed and then throttled.An interactive dashboard can fire a dozen parallel reads without a refusal.
Circuit breakerAfter repeated store failures the limiter stops waiting on the store and degrades deliberately instead of hanging every request behind a dead timeout.Requests keep being served on the fallback with X-RateLimit-Degraded: 1.
Shared stateThe budget lives in Redis, so several API replicas share one limit rather than each granting its own.Scaling the API out does not multiply the quota.

How the key is derived

The key is what stops one caller spending another caller's budget, so it is built from proven facts only.

Where each key comes from
ScopeKey sourceWhy not the alternative
ipThe peer address as seen by the API. Behind a reverse proxy the forwarded address is used.That is precisely why the proxy must be the only thing able to set the forwarded header: a client that could forge it would pick whose budget it spends.
uThe numeric user id the token validated to — after ValidateToken answered, never from a header or a body field.A caller-supplied id would let anyone spend somebody else's quota, or probe which ids exist by watching the refusals.
uipBoth, joined: the identity and the address together.Used where limiting per user alone would let one account fan out across addresses.

When the limiter degrades

A limiter that silently stops limiting is worse than no limiter, because it is trusted. So each class declares what happens when the shared store cannot be reached:

ClassStore unreachableReasoning
auth503 rate_limit_unavailableA brute-force budget that disappears with Redis is not a budget.
shield503 rate_limit_unavailableThe outer limit is the one that protects the process itself.
read · write · admin · probeServe on the in-process limiter, flag X-RateLimit-Degraded: 1Availability matters more than a shared count here, and the per-process limit still exists.
Fail-closed refusal: 503
{
  "error": "rate limiting is unavailable; this endpoint refuses requests until it can enforce its limit",
  "code": "rate_limit_unavailable"
}
Over budget: 429
{
  "error": "rate limit exceeded",
  "code": "rate_limited",
  "retry_after_seconds": 12,
  "limit": 120,
  "scope": "u"
}

Pacing a client

The five headers a well-behaved client uses
HeaderTypeReading it
X-RateLimit-LimitintegerThe budget for the class that evaluated this call.
X-RateLimit-RemainingintegerUnits left. Clamp at zero and treat zero as "stop now", not "try once more".
Retry-AftersecondsPresent on a 429 and on a fail-closed 503. Honour it literally.
X-RateLimit-ResetsecondsThe same value, for clients that read a reset header.
X-RateLimit-Degraded1The in-process fallback enforced this call — the limit is per replica, not cluster-wide.
  • 1.Read X-RateLimit-Remaining on every response and slow down before it reaches zero rather than after the first refusal.
  • 2.On a 429, wait retry_after_seconds and retry once. Retrying sooner spends the same budget and adds load to the refusal path.
  • 3.Serialize writes instead of firing them in parallel — the write budget is the one a bulk import actually meets.
  • 4.Poll /api/version and health routes, not a dashboard route, when what you want is a liveness signal.

Per-user cooldowns

Two limits are not about throughput at all — they exist to keep a single-use credential single-use, and they are enforced per user rather than per class:

Cooldowns that protect a credential instead of a rate
SurfaceLimitAnswer while the limit is in force
POST /api/auth/bot-login-link — dashboard login linkOne live code per user; 60-second cooldown between mints429 with Retry-After, and the code already issued keeps working.
Login codes10 minutes to live, exactly one useThe exchange answers 401 for a spent, expired or unknown code — the three cases are deliberately identical.

Worked exchange

Meeting the write budget head-on

429 Too Many Requests

A bulk import fires writes in parallel. The API refuses with a definite wait rather than an ambiguous error, and the client resumes with the same idempotency keys — which is why none of the retries creates a duplicate task.

Request

Meeting the write budget head-on
for i in 1 2 3 4 5 6; do
  curl -s -o /dev/null -w '%{http_code}\n' \
    -X POST https://your-host/api/rules \
    -H "Authorization: Bearer $TOKEN" \
    -H 'Content-Type: application/json' \
    -d "{\"name\":\"bulk-$i\"}" &
done; wait

Response

Response body
200
200
429   Retry-After: 12
429   Retry-After: 12
200
200

# 429 body
{
  "error": "rate limit exceeded",
  "code": "rate_limited",
  "retry_after_seconds": 12,
  "limit": 120,
  "scope": "u"
}