Skip to content

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

StatusWhen
201Created.
200Faithful replay of a known client_event_id — same content, byte for byte. The body is the original event.
401Missing, unknown or revoked key.
409The 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.
422Unknown 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

StatusWhen
200An array, possibly empty.
401Missing, unknown or revoked key.
422radius ≤ 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}

StatusWhen
200The event — including one whose status is expired, invalidated or merged.
401Missing, unknown or revoked key.
404Unknown 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

StatusWhen
200Revised. Body is the full event, and every subscriber gets an updated delta.
401Missing, unknown or revoked key.
403Not 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.
404Unknown event id — or an event your credential is not served at all.
409The event is over (expired, invalidated, merged). A revision never resurrects a hazard; publish a new event.
422Unknown 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

StatusWhen
200Accepted. Body is the full updated event. A repeat of the same action is also 200 and changes nothing.
401Missing, unknown or revoked key.
403A 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.
404Unknown event id — or an event your credential is not served at all (a live key never sees a sandbox event, by id or otherwise).
409The event exists but no longer takes signals — it is expired, invalidated or merged.
422action other than confirm / deny; unknown field; coordinate out of range.
429The 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

StatusWhen
200text/event-stream begins.
401No credential; unknown or revoked key; unknown or expired ticket. A bad Authorization header is a 401 even if a valid ?ticket= is present.
422Same 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

StatusWhen
201{"ticket": "relay_st_…", "expires_at": "…", "expires_in": 300}
401Missing, 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"
}
LimitValueEnforced howRead it from
Query / watch radius9 780 m422/v1/meta/limits
Events per area query500silently truncatednowhere — it is a deployment setting
description280 chars422nowhere
road_ref32 chars422nowhere
Geometry points2 … 512422nowhere
client_event_id64 chars, [A-Za-z0-9_.:-]422nowhere
Coordinate precision5 decimals (~1 m)rounded on writenowhere
occurred_at future slack300 s422nowhere
Stream ticket lifetime300 s401 at connectexpires_in in the mint response
Stream connection lifetime3300 sbye framemax_seconds in the snapshot frame
Stream keepalive interval20 sobserve it
Per-connection delta queue256oldest 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}/signals answers 429 with a Retry-After once 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 no X-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. 409 on a signal means the event is over; 409 on a publish means the client_event_id is spent — mint a new id (the one retry that works is resending the original content unchanged, which is the 200 path). 403 means your credential is on the other network and no amount of waiting changes that.
  • 429 — retry after the Retry-After header 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 same client_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.