Publishing events
POST /v1/events
Authorization: Bearer <key>
Content-Type: application/jsonOne call. No registration, no schema negotiation, no batching protocol. Publishing is deliberately the cheapest thing in the API — every connected actor must enrich the network, so there is never friction here.
curl -sX POST "$RELAY/v1/events" \
-H "Authorization: Bearer $RELAY_KEY" \
-H 'Content-Type: application/json' \
-d '{
"type": "accident",
"lat": 48.8698,
"lon": 2.3078,
"heading": 90,
"road_ref": "A1",
"severity": "high",
"description": "Two-vehicle collision, left lane blocked",
"client_event_id": "fleet-7781-2026-08-03-001"
}'The same call from Python is one standard-library request — there is no SDK to install, deliberately:
import json
import os
import urllib.request
RELAY = os.environ["RELAY"]
RELAY_KEY = os.environ["RELAY_KEY"]
body = {
"type": "accident",
"lat": 48.8698,
"lon": 2.3078,
"heading": 90,
"road_ref": "A1",
"severity": "high",
"description": "Two-vehicle collision, left lane blocked",
"client_event_id": "fleet-7781-2026-08-03-001",
}
req = urllib.request.Request(
f"{RELAY}/v1/events",
data=json.dumps(body).encode(),
headers={"Authorization": f"Bearer {RELAY_KEY}",
"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as res: # res.status: 201 created, 200 faithful replay
event = json.load(res)
print(event["event_id"], event["expires_at"])Returns 201 with the complete event, 200 if this was a faithful replay, or 409 if
the client_event_id is already taken — see Idempotency. In Python every
4xx raises urllib.error.HTTPError; json.load(exc) on the caught error yields the same
detail the curl examples on this page show.
The fields you send
Only these twelve. Anything else is a 422 — extra="forbid" is on, deliberately: a
publisher that learns nothing from a typo ships the same bug into a million vehicles.
| Field | Type | Required | Notes |
|---|---|---|---|
type | string | yes | One of the event types. Strict on input. |
subtype | string | null | no | Fine detail under type, from /v1/meta/taxonomy. A subtype under the wrong parent is a 422 — see below. |
lat | float | yes | WGS84, −90…90. Rounded to 5 decimals (~1 m) on write. |
lon | float | yes | WGS84, −180…180. Same rounding. |
geometry | GeoJSON LineString | null | no | For events that span a segment. 2–512 points. |
heading | int | null | no | 0–359, 0 = north. Read the warning below. |
road_ref | string | null | no | Free text, max 32 chars (A7, N118). Never parsed. |
severity | low | medium | high | null | no | Your own assessment. |
description | string | null | no | Max 280 chars. Never required to understand the event. |
occurred_at | RFC 3339 | null | no | When you observed it. Must carry an offset. |
client_event_id | string | null | no | Your idempotency key. [A-Za-z0-9_.:-], 1–64 chars. |
reporter_token | string | null | no | Which of your vehicles saw this. Same character set and length as client_event_id. See below. |
You do not send org_id, environment, status, confidence, verification or any
timestamp other than occurred_at. Those are resolved from your key or minted by the
server, and a payload that tries to set them is rejected as an unknown field.
environment is the one to notice: the event inherits the environment of the key that
published it, and there is no way to override it per request. A sandbox key publishes onto
the sandbox network — which no live consumer is ever served — and going live is a new key,
not a flag. → The event object
subtype refines; a wrong parent is refused
Optional fine detail under type — black_ice under weather_hazard, pothole under
road_damage. The current vocabulary per type is served by
GET /v1/meta/taxonomy. Omit it
whenever you did not distinguish: type alone is always sufficient to act on.
A subtype filed under the wrong parent is a 422, not silently dropped, and the
message names the parent's actual vocabulary:
unknown subtype 'pothole' for event type 'weather_hazard' (expected one of
['aquaplaning', 'black_ice', 'flooding', 'fog', 'hail', 'heavy_rain', 'slippery_road',
'snow', 'strong_wind'])(A type with no subtypes, like accident, refuses every subtype the same way.) Dropping
the unrecognised value instead would accept a mapping bug in your integration and let it
publish thousands of events before anyone notices a field silently went missing. Strict
in, permissive out — the same asymmetry as extra="forbid".
reporter_token is which vehicle saw it
Your own opaque name for the vehicle behind this observation. The trust quorum counts vehicles, and this field is how a vehicle is counted: send it here and on signals, and your fleet's corroboration becomes evidence. Omit it and your whole organisation counts as one reporter — degraded, never broken.
It is salted with your organisation id on arrival; the raw value is never stored and never
published. Do not reuse client_event_id for it — that identifies a message, so a retry
would look like a new vehicle. Full semantics in Trust.
heading is the direction of the drivers at risk
This is the one field that is dangerous to get backwards, and it is not the intuitive reading.
headingis the direction of travel of the road users the event concerns — the people who need to know. It is never the bearing of whatever caused the event.
For most types the two coincide and nothing bites. On wrong_way_vehicle they are
opposite, and that is exactly where it matters:
- A car is driving north on a southbound carriageway.
- The people at risk are driving south, straight at it.
- Publish
heading: 180. Not0.
Publish the offending vehicle's own bearing and every consumer that compares heading
against its own direction of travel — which is what a head unit does before it decides to
warn a driver — suppresses the alert for exactly the people in its path, and raises it for
the one driver who already knows. The publish looks perfect from your side: 201, a
normal-looking event, a healthy stream. Nobody who needed it heard it.
Relay itself does not filter on heading. Area queries and stream subscriptions
match on position, type and min_confidence only; the field is carried on the wire for
consumers to act on. That makes getting it right more important, not less — the mistake
is invisible on our side and lands entirely in your consumers' logic.
heading is optional. null means both directions. When you are not certain which way
the exposed traffic runs, omit it: a consumer treats null as "concerns everyone here". A
slightly noisy alert is recoverable; a confidently inverted one is not.
lat/lon are required even when you send a geometry
A segment event — a queue, a stretch of black ice, a DATEX II linear location — carries a
geometry. It still carries lat/lon, and they must be the first point of the
geometry.
{
"type": "congestion",
"lat": 44.93,
"lon": 4.89,
"heading": 180,
"road_ref": "A7",
"description": "Heavy queue, roughly 40 km",
"geometry": {
"type": "LineString",
"coordinates": [[4.89, 44.93], [4.82, 44.74], [4.75, 44.55]]
}
}Why both. lat/lon is the reference point, and it is what a client that ignores
geometry entirely still gets — an old integration, an embedded unit with no polyline
support, a fallback path when a geometry is unreadable. Making it optional would mean an
event with no usable position for those clients. It is never optional, in any future
version.
Watch the axis order. GeoJSON is [longitude, latitude] — the reverse of every other
field on this page, and the single most common way to put an event in the wrong country.
We keep GeoJSON's order rather than inventing our own, because you will paste geometry
straight out of your existing tooling.
A mismatch between lat/lon and the first geometry point is a 422, not a silent
correction:
{"detail": [{"type": "value_error", "loc": ["body"],
"msg": "Value error, lat/lon must match the first geometry point (got lat/lon 44.93,4.89 vs geometry start 4.89,44.93 — remember GeoJSON coordinates are [lon, lat])"}]}An infrastructure API that quietly relocates your events teaches you nothing, and the bug surfaces months later as data nobody trusts. Tolerance is ~0.001° (about 100 m), so rounding differences are fine and a transposed pair is not.
Segment events are found from their middle. Relay indexes every geohash cell the polyline covers, and distance is measured to the whole extent — so a subscriber standing in the middle of that 40 km queue finds it, even though it starts 20 km away. That is the single behaviour the whole geo design exists for.
Idempotency
Send a client_event_id and the event id becomes a deterministic function of
(your org_id, client_event_id). A retry over a flaky mobile link lands on the same key
instead of minting a duplicate.
| Call | Status | Body |
|---|---|---|
| First publish | 201 Created | the new event |
Same client_event_id, same content | 200 OK | the original event |
Same client_event_id, different content | 409 Conflict | which field differs |
What it guarantees: exactly one event per client_event_id, forever. There is no
expiry on the key and no window — send the same id an hour later and you still get the
original back.
What it does not do, and this is the trap:
A replay is not an update. Posting the same
client_event_idwith different content is refused with409, naming the first field that differs. Your new position, severity and description never reach the network — send a newclient_event_id, or resend the original content byte for byte.
Verified behaviour, not a theory:
# publish
curl -sX POST "$RELAY/v1/events" -H "Authorization: Bearer $RELAY_KEY" \
-H 'Content-Type: application/json' \
-d '{"type":"wrong_way_vehicle","lat":45.7640,"lon":4.8357,"road_ref":"A7",
"client_event_id":"demo-001"}'
# -> 201, lat 45.764, road_ref "A7"
# same id, different content
curl -sX POST "$RELAY/v1/events" -H "Authorization: Bearer $RELAY_KEY" \
-H 'Content-Type: application/json' \
-d '{"type":"wrong_way_vehicle","lat":45.9999,"lon":4.9999,"road_ref":"CHANGED",
"client_event_id":"demo-001"}'
# -> 409
# {"detail": "client_event_id 'demo-001' was already used for a different event: lat does
# not match the one stored. Reusing an idempotency key for new content would silently
# return the earlier event; send a new client_event_id, or resend the original content."}The refusal is why the 200 path is trustworthy: a 200 now means faithful replay.
Returning the original with 200 on mismatched content — which is what this API did
before the check existed — told a publisher it had succeeded while it held an event it
never described.
So: one client_event_id per real-world observation. Reusing one id to "move" an event
is a 409, never a move — moving one is a revision, which
is a different route.
The other 409 on this route is cross-environment reuse. The event id is derived
from (org_id, client_event_id) and the derivation is frozen forever, so it cannot also
carry the environment — a client_event_id your sandbox key already used is therefore
refused on your live key rather than silently answering 200 with a sandbox event the
live network never received. Namespace the ids your sandbox integration sends.
Without a client_event_id, every POST creates a new event with a random id. That is
fine for a source that never retries; it is not fine for anything on a mobile link.
The event_id is opaque either way. Never parse it.
Revising an event that moves
POST /v1/events/{event_id}/revisionsA queue is not a point and it is not a fixed segment either: its tail walks upstream, often kilometres in minutes. A wrong-way vehicle covers about 1.5 km a minute. A fog bank drifts. Publish once, then restate the extent as it changes — one event on the road, one event in your subscribers' clients.
curl -sX POST "$RELAY/v1/events/$EVENT_ID/revisions" \
-H "Authorization: Bearer $RELAY_KEY" -H 'Content-Type: application/json' \
-d '{"lat":44.93,"lon":4.89,"severity":"high",
"geometry":{"type":"LineString",
"coordinates":[[4.89,44.93],[4.82,44.74],[4.75,44.55]]}}'
# -> 200, the full event. Every subscriber gets an `updated` delta with the new extent.Five fields, and only these five. lat, lon, geometry, severity, subtype —
where the event reaches and how bad it is.
| Not revisable | Why |
|---|---|
type | Subscribers filter on it. Re-typing an event mid-life moves it between subscriber sets and changes its TTL under a created_at the network already believed. A different type is a different event. |
heading, road_ref, description | They describe what was observed, not where it currently reaches. |
occurred_at, created_at, client_event_id | Trust anchors and identity. created_at is the ordering authority; client_event_id is the event's id. |
confidence, verification, the tallies, status | The network's, never yours. |
The fields are applied, not merged. This is a full restatement of the five: omit
severity and it is cleared, omit geometry and the event collapses back to its
reference point (a queue that has cleared back to its head — a real revision). A request
whose absent fields meant "keep" could not express that at all.
Only the publisher may revise, and only on the same network — your sandbox key cannot revise your live event. If you think somebody else's event has changed, that is a signal: evidence the quorum weighs, not an edit nobody can see was made.
A revision buys time, up to the type's ceiling. Restating an event is itself evidence
it is still there, so the expiry is pushed back to a full TTL for its type — capped at
max_ttl_seconds from created_at, which is what stops a revision loop becoming an event
that never dies. /v1/meta/taxonomy serves both numbers per type.
wrong_way_vehicleis the exception, and it is the type that most needs revising. You may say where it is now; you get no extension at all, exactly as no confirmation buys one. A stale wrong-way report is itself a hazard — phantom braking on a road that is now clear — so that type dies on schedule whatever anyone says about it.
| Status | When |
|---|---|
200 | Revised. Body is the full event. |
401 | Missing, unknown or revoked key. |
403 | Another organisation's event — or your own, from a credential on the other network. |
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 instead. |
422 | Unknown field (including any of the non-revisable ones above), subtype under the event's stored type, coordinate out of range, lat/lon not matching the geometry start. |
One caveat, and it is narrow. After a revision, replaying your original POST /v1/events under the same client_event_id returns 409 — the stored content is no
longer what you are sending, which is exactly what that check is for. In practice a retry
window is seconds and a revision cadence is minutes, so the two do not meet; if your client
can retry a publish long after the fact, revise from the returned event_id rather than
from the id you sent.
occurred_at
Send it when the observation is older than the request — buffered telemetry, batch feeds. It defaults to the moment we received the event.
It must carry a UTC offset. A naive timestamp is a 422, not an assumption:
occurred_at must carry a UTC offset (RFC 3339), e.g. '2026-08-02T14:30:00+02:00' or
'2026-08-02T12:30:00Z'. A timestamp with no offset is ambiguous and we will not guess a
timezone for you — omit the field entirely if you cannot supply one, and it defaults to
the moment we received the event.This used to assume UTC. A French publisher sending a local 14:30 had it stored as
14:30Z — a two-hour error baked into an immutable field, and undetectable afterwards,
because wrong-but-plausible is indistinguishable from right. Refusing is the direction we
can undo later; un-corrupting stored data is not.
More than 5 minutes in the future is also a 422. occurred_at is never used for
ordering — created_at is, and updated_at is the version. See
Streaming.
What you cannot do
- You cannot rewrite an event. There is no
PUTand noPATCH. You can restate where your own event reaches and how bad it is —POST /v1/events/{id}/revisions— and nothing else: not its type, not its timestamps, not what the network concluded about it. - You cannot delete or retract your own event. It expires. Confirming or denying it
from the vehicle that published it (the same
reporter_token) is accepted and does nothing — see Trust. - You cannot extend your own event's life from the vehicle that published it. A first confirmation from any other reporter does that — including another vehicle of your own fleet: the quorum counts vehicles, not organisations.
- You cannot publish as another organisation.
org_idcomes from your key, never from the payload.
Errors
| Status | When |
|---|---|
201 | Created. |
200 | Faithful replay of a known client_event_id; body is the original event. |
401 | Missing, unknown or revoked key. |
409 | The client_event_id is already taken: same id with different content (the detail names the first differing field), or the same id already used in the other environment. See Idempotency. |
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 over 280 chars, road_ref over 32, geometry with fewer than 2 or more than 512 points. |
422 bodies are FastAPI validation errors: a detail array where each entry has loc,
msg and type. Log loc and msg; do not pattern-match msg, which is prose and may
be improved. Full list in Errors and limits.