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.
Global limits
Section titled “Global limits”| 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.
Per-endpoint limits
Section titled “Per-endpoint limits”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.
WebSocket limits
Section titled “WebSocket limits”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.
Exempt endpoints
Section titled “Exempt endpoints”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.
Rate limit responses
Section titled “Rate limit responses”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 RequestsX-RateLimit-Limit: 60X-RateLimit-Window: 60sRetry-After: 12Content-Type: application/jsonPer-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"}Handling 429 with backoff
Section titled “Handling 429 with backoff”Honor Retry-After when present and fall back to exponential backoff with jitter:
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-Afterinterval. - 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.
