Rate limits are a fixed-window counter, applied to a handful of routes with real abuse potential:
| Route | Limit |
|---|---|
| Registration | 5 / window |
| Login | 10 / window |
| API key creation | 20 / window |
| RTC token creation | 60 / window |
| Telemetry ingest | 600 / window |
Window size defaults to 60 seconds.
What the budget is keyed on
The most specific identity a request actually carries, checked in this order:
- The API key's own public id, when a request is authenticated with one. Keyed on the key itself, not its project — two keys on one project don't share a budget, so a noisy or compromised key can't spend its sibling's headroom.
- The signed-in user's id, for dashboard-session routes.
- Client IP, only when neither exists — registration and login have no identity yet to key on.
Identity is never combined with IP once one is available. An authenticated abuser rotating IPs is still one identity and stays capped as one; folding IP back in would only reopen the problem identity-based keying exists to fix — every legitimate user behind one corporate NAT sharing a single bucket.
Reading a 429
{
"code": "RAVEN_RATE_LIMITED",
"message": "Too many requests — please try again later",
"retryAfterSeconds": 42,
"requestId": "req_9f2c41ab77e0c3d5b1a4e8f2"
}retryAfterSeconds is read from the limiter's own remaining window —
use it rather than a fixed backoff.
Chat and signaling have their own limiters
Chat's rate limits (send, react, type, subscribe, connect) are separate and already keyed per-subject — a chat token always carries an identity by the time a limited action happens, so it never had the IP-only problem. Signaling's WebSocket-upgrade limiter is IP-only by necessity: it guards the connection attempt itself, before any token is verified — the same situation login and registration are in.
See WebSocket Protocol for the close code a chat-side limit produces.