Errors & rate limits
One error shape almost everywhere, and limits at several layers. Knowing which layer rejected you tells you whether to retry, back off, or fix the request.
422. A body that fails to deserialise is
rejected by the web framework before any handler runs, so it answers with a plain-text
explanation rather than the JSON shape below. Parse defensively: check the status before
assuming a code field exists.
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" } }
The codes are stable and the same on both: unauthorized, forbidden,
blocked (403 — a direct directory conversation frozen by a block; sends, reactions,
typing, broadcasts and the RTC join all answer it, on both sides), not_found,
conflict, invalid_request, payload_too_large,
unsupported_media_type, rate_limited, quota_exceeded (402)
and internal. A chat-socket refusal never closes the socket — it is an error ack, and
a fire-and-forget frame (typing.*, broadcast) is answered only
when refused.
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, the scope is missing, or (blocked) a direct conversation frozen by a block. | No. |
404 | Not found — or it exists in another tenant. The two are deliberately indistinguishable. | No. |
409 | Conflict — e.g. the room is closed, a plaintext send into an encrypted room, or a member add once the room holds 5,000. | 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. |
415 | Unsupported media type — the declared MIME is not on the attachment allowlist. | No. |
422 | The body did not deserialise — an unknown field, a wrong type, bad JSON. Most often a stray tenant_id, which the server infers from your credential and refuses in the body. | No. Fix the body. |
429 | Rate limited. Carries Retry-After in seconds, and the message names the limit that fired. | Yes — after Retry-After. |
408 | Request timed out server-side (30 s cap). | Yes, carefully. |
5xx | Server fault. | Yes, with backoff and jitter. |
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 |
broadcast frames | 20 / s, a burst of 40 absorbed | Per chat socket; refused with an error ack naming the seconds to wait, never a close |
| Block list changes, invite links | 60 / minute | Per directory user |
| 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, and 20 calls / minute | Per request, and per directory session — a one-number-per-call address-book sync hits this at 20 |
| Push — message | 1 / 30 s | Per member per room. Storm cooldown, not an error |
| Push — ring | 1 / 300 s | Per member per call_id, so a recent message cannot swallow a ring |
Limiters are fixed-window. Most of them fail open if the cache is unavailable — account signup/login, OTP, the socket send limiter and the operator control plane all degrade enforcement rather than locking everybody out.
ak_ server plane is the exception: it fails closed. If
the rate-limit store is unreachable, every /v1/server/* request answers
500 rather than passing unmetered. Treat a sudden uniform 500 on that plane as
infrastructure, not as your request being wrong, and back off instead of retrying hard.
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.