Reference

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.

The one exception is 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 #

StatusMeansRetry?
400Malformed or invalid — a field is missing, wrong type, or out of range.No. Fix it.
401Missing, malformed or expired credential.Re-mint, then once.
403Authenticated but not permitted — not a member, the scope is missing, or (blocked) a direct conversation frozen by a block.No.
404Not found — or it exists in another tenant. The two are deliberately indistinguishable.No.
409Conflict — e.g. the room is closed, a plaintext send into an encrypted room, or a member add once the room holds 5,000.No.
402Quota exceeded — the tenant's monthly message or byte cap.Not until the cap resets or the plan changes.
413Payload too large.No.
415Unsupported media type — the declared MIME is not on the attachment allowlist.No.
422The 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.
429Rate limited. Carries Retry-After in seconds, and the message names the limit that fired.Yes — after Retry-After.
408Request timed out server-side (30 s cap).Yes, carefully.
5xxServer 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 #

LayerDefaultScope
Customer server key120 requests / 10 s (per plan; free 60, pro 600)Per ak_ key
Message send30 / 10 sPer principal — same on REST and socket
broadcast frames20 / s, a burst of 40 absorbedPer chat socket; refused with an error ack naming the seconds to wait, never a close
Block list changes, invite links60 / minutePer directory user
Operator control plane1000 / 10 sPer signing key (a backstop, not a product limit)
Account signup / login20 / 30 per minutePer IP
OTP request5 per phone, 30 per IP, 300 per app, per minutePlus a 3-per-minute resend cap
Contacts lookup500 numbers per call, and 20 calls / minutePer request, and per directory session — a one-number-per-call address-book sync hits this at 20
Push — message1 / 30 sPer member per room. Storm cooldown, not an error
Push — ring1 / 300 sPer 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.

The 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_id when retrying a send — that is what makes it idempotent. A fresh id creates a duplicate message.
  • Reuse external_ref when retrying room creation.
  • On 401, mint a fresh token once and retry; do not loop.
  • Never retry 400, 403, 404, 409 or 413 — nothing about the request will change.