Documentation menu
Getting started
New in v2.0
Feature deep dives
Reference
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 10A credential-stuffing loop is cheap to send and expensive to verify, so this class is the tightest one in the system.
shield600 / min · burst 120Keyed 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 120Generous enough for a dashboard that polls, tight enough that one tenant cannot saturate the core for the others.
write120 / min · burst 30Writes cost a database round trip and a validation pass; the budget reflects that rather than the read budget.
admin300 / min · burst 60Installation-wide operations are not rarer than tenant writes — they are simply not unlimited.
probe1200 / min · burst 300A monitoring system polling every second must never be the reason a real caller is refused.
| Behaviour | How it works | What a client sees |
|---|---|---|
| Token bucket per window | A 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 separate | Burst 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 breaker | After 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 state | The 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.
| Scope | Key source | Why not the alternative |
|---|---|---|
ip | The 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. |
u | The 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. |
uip | Both, 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:
| Class | Store unreachable | Reasoning |
|---|---|---|
auth | 503 rate_limit_unavailable | A brute-force budget that disappears with Redis is not a budget. |
shield | 503 rate_limit_unavailable | The outer limit is the one that protects the process itself. |
read · write · admin · probe | Serve on the in-process limiter, flag X-RateLimit-Degraded: 1 | Availability matters more than a shared count here, and the per-process limit still exists. |
{
"error": "rate limiting is unavailable; this endpoint refuses requests until it can enforce its limit",
"code": "rate_limit_unavailable"
}{
"error": "rate limit exceeded",
"code": "rate_limited",
"retry_after_seconds": 12,
"limit": 120,
"scope": "u"
}Pacing a client
| Header | Type | Reading it |
|---|---|---|
X-RateLimit-Limit | integer | The budget for the class that evaluated this call. |
X-RateLimit-Remaining | integer | Units left. Clamp at zero and treat zero as "stop now", not "try once more". |
Retry-After | seconds | Present on a 429 and on a fail-closed 503. Honour it literally. |
X-RateLimit-Reset | seconds | The same value, for clients that read a reset header. |
X-RateLimit-Degraded | 1 | The in-process fallback enforced this call — the limit is per replica, not cluster-wide. |
- 1.Read
X-RateLimit-Remainingon every response and slow down before it reaches zero rather than after the first refusal. - 2.On a 429, wait
retry_after_secondsand 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/versionand 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:
| Surface | Limit | Answer while the limit is in force |
|---|---|---|
POST /api/auth/bot-login-link — dashboard login link | One live code per user; 60-second cooldown between mints | 429 with Retry-After, and the code already issued keeps working. |
| Login codes | 10 minutes to live, exactly one use | The 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 RequestsA 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
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; waitResponse
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"
}