Errors & rate limits
One error shape everywhere, and limits at several layers. Knowing which layer rejected you tells you whether to retry, back off, or fix the request.
Error shape #
{ "code": "forbidden", "message": "principal is not a member of this room" } On the socket, the same error is correlated by your request_id:
{ "v": 1, "type": "error", "request_id": "0b0f2b1e-…",
"error": { "code": "rate_limited", "message": "too many messages" } } Statuses #
| Status | Means | Retry? |
|---|---|---|
400 | Malformed or invalid — a field is missing, wrong type, or out of range. | No. Fix it. |
401 | Missing, malformed or expired credential. | Re-mint, then once. |
403 | Authenticated but not permitted — not a member, or the scope is missing. | No. |
404 | Not found — or it exists in another tenant. The two are deliberately indistinguishable. | No. |
409 | Conflict — e.g. the room is closed, or a plaintext send into an encrypted room. | No. |
402 | Quota exceeded — the tenant's monthly message or byte cap. | Not until the cap resets or the plan changes. |
413 | Payload too large. | No. |
429 | Rate limited. | Yes, with backoff. |
408 | Request timed out server-side (30 s cap). | Yes, carefully. |
5xx | Server fault. | Yes, with backoff and jitter. |
404 is a security feature. Asking for another tenant's room returns "not found",
not "forbidden" — the API will not confirm that an id exists somewhere else.
Rate limits #
| Layer | Default | Scope |
|---|---|---|
| Customer server key | 120 requests / 10 s (per plan; free 60, pro 600) | Per ak_ key |
| Message send | 30 / 10 s | Per principal — same on REST and socket |
| Operator control plane | 1000 / 10 s | Per signing key (a backstop, not a product limit) |
| Account signup / login | 20 / 30 per minute | Per IP |
| OTP request | 5 per phone, 30 per IP, 300 per app, per minute | Plus a 3-per-minute resend cap |
| Contacts lookup | 500 numbers per call | Per request |
| Push per member per room | 1 / 30 s | Storm cooldown, not an error |
Rate limiters are fixed-window and fail open if the cache is unavailable — a cache blip degrades enforcement rather than locking everybody out.
Quotas #
Separate from rate limits: plans may carry monthly caps on messages sent and attachment bytes
stored. Exceeding one returns 402 quota_exceeded on the metered write only — reads,
receipts and control-plane calls keep working, and an idempotent replay of an already-counted
operation is never blocked. Check consumption with GET /v1/server/usage.
Retrying safely #
- Use exponential backoff with jitter. Synchronised retries after an outage are their own outage.
- Reuse the original
client_message_idwhen retrying a send — that is what makes it idempotent. A fresh id creates a duplicate message. - Reuse
external_refwhen retrying room creation. - On
401, mint a fresh token once and retry; do not loop. - Never retry
400,403,404,409or413— nothing about the request will change.