Errors and limits
Error shape
Two shapes, both from FastAPI. Handle both.
// 401, 404, 409, and 422s raised by a route handler: detail is a STRING
{"detail": "invalid or revoked API key"}
// 422s from payload/query validation: detail is an ARRAY
{"detail": [{"type": "extra_forbidden", "loc": ["body", "speed_kph"],
"msg": "Extra inputs are not permitted", "input": 90}]}Log loc and type. Do not pattern-match msg — it is prose written for humans and
we will keep improving it. type and loc are the machine-readable parts.
By route
POST /v1/events
| Status | When |
|---|---|
201 | Created. |
200 | Faithful replay of a known client_event_id — same content, byte for byte. The body is the original event. |
401 | Missing, unknown or revoked key. |
409 | The client_event_id is already taken, two causes: same id with different content (the detail names the first field that differs — send a new id, or resend the original), or same id already used in the other environment (the event id is derived from (org_id, client_event_id) and cannot carry the environment — namespace your sandbox ids). See Publishing. |
422 | Unknown field; unknown type; subtype under the wrong type; coordinate out of range; lat/lon not matching the geometry start; naive or future occurred_at; description > 280; road_ref > 32; geometry with < 2 or > 512 points. |
GET /v1/events
| Status | When |
|---|---|
200 | An array, possibly empty. |
401 | Missing, unknown or revoked key. |
422 | radius ≤ 0 or above max_query_radius_m; unknown value in type; lat/lon out of range; min_confidence outside 0…1. |
GET /v1/events/{event_id}
| Status | When |
|---|---|
200 | The event — including one whose status is expired, invalidated or merged. |
401 | Missing, unknown or revoked key. |
404 | Unknown id — or known and not on your network: a live key asking for a sandbox event gets this, not a 403. |
POST /v1/events/{event_id}/revisions
| Status | When |
|---|---|
200 | Revised. Body is the full event, and every subscriber gets an updated delta. |
401 | Missing, unknown or revoked key. |
403 | Not yours to revise — another organisation's event, or your own from a credential on the other network. Somebody else's event takes a signal, not an edit. |
404 | Unknown event id — or an event your credential is not served at all. |
409 | The event is over (expired, invalidated, merged). A revision never resurrects a hazard; publish a new event. |
422 | Unknown field — which includes every field that is not lat, lon, geometry, severity, subtype; subtype under the event's stored type; coordinate out of range; lat/lon not matching the geometry start. |
POST /v1/events/{event_id}/signals
| Status | When |
|---|---|
200 | Accepted. Body is the full updated event. A repeat of the same action is also 200 and changes nothing. |
401 | Missing, unknown or revoked key. |
403 | A sandbox key on a live event. A sandbox credential is served live events so your integration has real traffic to work against; it cannot vote on them. |
404 | Unknown event id — or an event your credential is not served at all (a live key never sees a sandbox event, by id or otherwise). |
409 | The event exists but no longer takes signals — it is expired, invalidated or merged. |
422 | action other than confirm / deny; unknown field; coordinate out of range. |
429 | The only rate limit in the API. Your organisation's hourly signal ceiling. Read Retry-After and back off; see What is limited below. |
The 409 deserves a sentence, because it looks like a client bug and is not:
{"detail": "event 7fce24f051e857bfacad005c3f497973 is expired and no longer takes signals"}It means the hazard is over — the TTL passed, enough vehicles denied it (a deny quorum
ends an event as expired; it never marks it invalidated — see Trust), or it
was merged. A signal must never resurrect a hazard that is no longer there. It is
deliberately not a 404, so you do not go looking for a fault in your id handling. Drop
the event from your store and move on; retrying will not help.
GET /v1/stream
| Status | When |
|---|---|
200 | text/event-stream begins. |
401 | No credential; unknown or revoked key; unknown or expired ticket. A bad Authorization header is a 401 even if a valid ?ticket= is present. |
422 | Same rules as GET /v1/events. Validated before the stream starts. |
Once the stream has begun there is no status code left to send. Two failures are therefore
reported inside the stream, as a bye frame:
{"reason": "snapshot_unavailable", "reconnect": true}— we could not read the initial state. Reconnect; do not read the empty stream as an empty road.{"reason": "max_connection_age", "reconnect": true}— the normal scheduled close.
POST /v1/stream/tickets
| Status | When |
|---|---|
201 | {"ticket": "relay_st_…", "expires_at": "…", "expires_in": 300} |
401 | Missing, unknown or revoked key. A ticket cannot mint another ticket. |
GET /v1/meta/*, GET /health, GET /
200, no authentication. /v1/meta/* never returns network data.
Limits
curl -s "$RELAY/v1/meta/limits"{
"max_query_radius_m": 9780.0,
"query_level": 5,
"note": "beyond max_query_radius_m, cover the area with several overlapping queries; route-corridor subscriptions are planned and not available"
}| Limit | Value | Enforced how | Read it from |
|---|---|---|---|
| Query / watch radius | 9 780 m | 422 | /v1/meta/limits |
| Events per area query | 500 | silently truncated | nowhere — it is a deployment setting |
description | 280 chars | 422 | nowhere |
road_ref | 32 chars | 422 | nowhere |
| Geometry points | 2 … 512 | 422 | nowhere |
client_event_id | 64 chars, [A-Za-z0-9_.:-] | 422 | nowhere |
| Coordinate precision | 5 decimals (~1 m) | rounded on write | nowhere |
occurred_at future slack | 300 s | 422 | nowhere |
| Stream ticket lifetime | 300 s | 401 at connect | expires_in in the mint response |
| Stream connection lifetime | 3300 s | bye frame | max_seconds in the snapshot frame |
| Stream keepalive interval | 20 s | — | observe it |
| Per-connection delta queue | 256 | oldest dropped, counted in keepalive.dropped | — |
The "read it from nowhere" rows are current values, not contract. They can change with a deployment. Where an endpoint serves a limit, read it at runtime rather than hardcoding it.
The 500-event cap is the one to watch, because it fails silently: there is no
pagination, no cursor and no flag telling you the result was truncated. If you are seeing
round numbers near 500, narrow the radius or filter by type.
What is limited, and what is not
Say it plainly, because you will design around it either way:
- One rate limit exists, and it is on signals.
POST /v1/events/{id}/signalsanswers429with aRetry-Afteronce an organisation has sent 3000 signals in an hour, on one side of the live/sandbox line. It is a ceiling against a runaway client, not a commercial quota, and it sits far above any normal fleet's rate — if yours approaches it, contact us rather than sharding across keys, because the budget is per organisation and a second key does not buy a second one. - Nothing else is rate limited. Publishing, reading, opening streams and minting
tickets have no ceiling and return no
429. There are noX-RateLimit-*headers anywhere in the API, on any route, including that one. - There are no quotas. Not on publishing, not on reading, not on open streams, not on ticket minting. One key can mint tickets in a loop.
- Consumption is counted; nothing is refused for being over a plan. Every read, publish, signal and stream connect is metered per organisation, per environment, per day — the counter the commercial model hangs off exists and runs. No code turns a request away for being over a plan, and that order is deliberate: a limit you cannot measure is a limit you will set wrong. The signal ceiling is not an exception to this; it refuses a rate, and it would refuse the same rate on any plan.
Do not read that as permission. The meter is already running, and the contract
reserves 429 on every metered route. Build a client that backs off on 429 and on
5xx now, while it is nearly free — a fleet that discovers rate limiting in production
discovers it all at once.
Availability
Service-level commitments are a contract matter, set per agreement rather than published as a blanket figure. The engineering target — p95 under one second between publication and reception — holds a wide margin on the reference stack (p95 73 ms); see Streaming.
Retry advice
401— do not retry. Your credential is wrong, or your ticket expired (mint a new one).403,404,409,422— do not retry. Nothing about the request will succeed on a second attempt.409on a signal means the event is over;409on a publish means theclient_event_idis spent — mint a new id (the one retry that works is resending the original content unchanged, which is the200path).403means your credential is on the other network and no amount of waiting changes that.429— retry after theRetry-Afterheader says, not sooner and not in a tight loop. Signals only, and a signal is not urgent: the event is already on the network.5xx, timeouts, connection resets — retry with exponential backoff and jitter. If you are publishing, send the sameclient_event_id: that is exactly what it is for, and it makes the retry free of duplicates.- A stream that ends for any reason — reconnect. The fresh snapshot is authoritative, so reconnection is always correct. Browsers on a ticket must mint a new one first; see Streaming.