Skip to content

Rate limits

The Zentalk API enforces rate limits at several layers — a global per-IP limit, stricter per-endpoint limits, an hourly per-user quota, and WebSocket message limits — and exceeding any of them returns 429 Too Many Requests. This page is the canonical rate-limit reference; other pages link here rather than restating limits.

All limits use a token-bucket algorithm: the limit is the sustained rate over the window, and the burst is how many requests may arrive in quick succession before throttling begins.

Scope Limit Burst Window
Per IP address (all endpoints) 100 requests 20 1 minute
Per authenticated user (wallet address) 1,000 requests 50 1 hour
Per-endpoint default (endpoints not listed below) 100 requests 20 1 minute

The per-user quota is keyed to the verified wallet address from the authenticated session, so it cannot be reset by rotating headers or IPs. See Authentication for how sessions are established.

Stricter limits apply to specific endpoints. Each limit applies identically to the legacy /api/... path and the /api/v1/... path; per-minute window in all cases. See the REST API reference for endpoint details.

Endpoint Limit / min Burst
POST /api/v1/initialize (register) 5 2
POST /api/v1/auth/refresh 10 3
POST /api/v1/auth/session 10 3
POST /api/v1/session/resume 10 3
POST /api/v1/recovery/initiate 3 1
POST /api/v1/recovery/challenge 5 2
POST /api/v1/upload-media 30 10
POST /api/v1/voice/send 20 5
POST /api/v1/video/send 10 3
POST /api/v1/file/send 20 5
POST /api/v1/send 60 20
POST /api/v1/channels (create) 5 2
POST /api/v1/push/subscribe 10 2
POST /api/v1/typing 120 30
POST /api/v1/mark-as-read 120 30
GET /api/v1/peer-info 60 20
GET /api/v1/keys 60 20
GET /api/v1/chats 60 20
GET /api/v1/messages 60 20
POST /api/v1/chat/disappearing-timer 30 10
POST /api/v1/discover 30 10
POST /api/v1/block-contact, unblock-contact, verify-contact 20 5
POST /api/v1/groups/create 5 2
POST /api/v1/groups/join/{inviteCode} 10 3
GET /api/v1/groups 60 20
GET /api/v1/stickers/packs 30 10
POST /api/v1/stickers/send 60 20
/api/v1/stories 20 5
/api/v1/polls 20 5
POST /api/v1/update-username 10 3
POST /api/v1/update-profile 20 5
POST /api/v1/delete-account 3 1
POST /api/v1/errors (error reporting) 10 3
POST /api/v1/mfa/totp/setup, mfa/totp/enable 5 2
POST /api/v1/mfa/verify, mfa/challenge 10 3

Authentication and recovery endpoints are rate limited per IP (they run before authentication); the strict limits on groups/join and update-username protect against invite-code brute forcing and username squatting.

Limits on the WebSocket connection apply per user across all of that user’s connections:

Scope Limit Burst
WebSocket messages 30 messages / minute (default) 10
WebRTC ICE candidates 100 / minute 30
WebRTC signaling (offer, answer, …) 10 / minute 5
ICE candidates per call (both parties combined) 50 total

The WebSocket message limit and burst are server-configurable (WS_RATE_LIMIT_PER_MIN, WS_RATE_LIMIT_BURST); the values above are the defaults. Self-hosted deployments may differ.

The per-user quota does not apply to these public paths:

  • /health (and sub-paths), /metrics, /swagger/
  • /api/initialize, /api/check-username, /api/auth/refresh
  • /api/media/avatar/, /api/avatar/

These paths remain subject to the global per-IP limit.

When a limit is exceeded, the API returns 429 Too Many Requests with these headers:

Header Meaning
X-RateLimit-Limit The limit that was exceeded (requests per window)
X-RateLimit-Window The window the limit applies to (60s, 1m, or 1h)
Retry-After Seconds to wait before retrying
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Window: 60s
Retry-After: 12
Content-Type: application/json

Per-endpoint limit responses include machine-readable retry information in the body:

{
"error": "Rate limit exceeded",
"message": "Too many requests. Please try again in 12 seconds.",
"retry_after": 12,
"limit": 60,
"window": "60s"
}

Global IP and per-user limit responses use the standard error envelope with the code RATE_LIMIT_EXCEEDED (see Error handling):

{
"success": false,
"error": "Rate limit exceeded. Please slow down.",
"code": "RATE_LIMIT_EXCEEDED"
}

Honor Retry-After when present and fall back to exponential backoff with jitter:

Retry with Retry-After and exponential backoff
async function fetchWithBackoff(url: string, options: RequestInit, maxRetries = 3) {
for (let attempt = 0; ; attempt++) {
const response = await fetch(url, options);
if (response.status !== 429 || attempt >= maxRetries) return response;
const retryAfter = response.headers.get('Retry-After');
const delayMs = retryAfter
? parseInt(retryAfter, 10) * 1000
: Math.min(2 ** attempt * 1000, 30_000) + Math.random() * 1000;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}

Client-side guidance:

  • Never retry a 429 immediately; always wait at least the Retry-After interval.
  • Batch reads and cache responses (for example, key bundles and peer info) instead of polling.
  • Spread background work (sync, prefetch) over time rather than bursting at startup.