Skip to content

Publishing events

POST /v1/events
Authorization: Bearer <key>
Content-Type: application/json

One 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 422extra="forbid" is on, deliberately: a publisher that learns nothing from a typo ships the same bug into a million vehicles.

FieldTypeRequiredNotes
typestringyesOne of the event types. Strict on input.
subtypestring | nullnoFine detail under type, from /v1/meta/taxonomy. A subtype under the wrong parent is a 422 — see below.
latfloatyesWGS84, −90…90. Rounded to 5 decimals (~1 m) on write.
lonfloatyesWGS84, −180…180. Same rounding.
geometryGeoJSON LineString | nullnoFor events that span a segment. 2–512 points.
headingint | nullno0–359, 0 = north. Read the warning below.
road_refstring | nullnoFree text, max 32 chars (A7, N118). Never parsed.
severitylow | medium | high | nullnoYour own assessment.
descriptionstring | nullnoMax 280 chars. Never required to understand the event.
occurred_atRFC 3339 | nullnoWhen you observed it. Must carry an offset.
client_event_idstring | nullnoYour idempotency key. [A-Za-z0-9_.:-], 1–64 chars.
reporter_tokenstring | nullnoWhich 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 typeblack_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.

heading is 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. Not 0.

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.

CallStatusBody
First publish201 Createdthe new event
Same client_event_id, same content200 OKthe original event
Same client_event_id, different content409 Conflictwhich 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_id with different content is refused with 409, naming the first field that differs. Your new position, severity and description never reach the network — send a new client_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}/revisions

A 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 revisableWhy
typeSubscribers 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, descriptionThey describe what was observed, not where it currently reaches.
occurred_at, created_at, client_event_idTrust anchors and identity. created_at is the ordering authority; client_event_id is the event's id.
confidence, verification, the tallies, statusThe 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_vehicle is 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.

StatusWhen
200Revised. Body is the full event.
401Missing, unknown or revoked key.
403Another organisation's event — or your own, from a credential on the other network.
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 instead.
422Unknown 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 PUT and no PATCH. 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_id comes from your key, never from the payload.

Errors

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