Public API · v1

Reference

The API our own hardware speaks. Every path below is versioned under /v1; only the health check is not.

Concepts

Six objects, and the differences between them are load-bearing rather than organisational.

ObjectAuthored byDescribesVisibilityOn re-observation
Notea humanhow a vehicle was driven the plate's claimanta second Note
Defecta machinea vehicle's condition owner only, never publicdeduplicated
Hazarda machinethe road aggregate onlyrolled up
Detectiona machinea sighting device-localdiscarded
Claima humanconsent the claimantidempotent
Replya humanan answer to a Note with the Notenewest wins

Identifiers

A plate id is canonical REGION-TEXT — matching ^[A-Z0-9]{1,4}-[A-Z0-9]{1,10}$, uppercase, e.g. FL-7HJ260.

Every other id is a ULID: 26 characters of Crockford base32, matching ^[0-9A-HJKMNP-TV-Z]{26}$. You generate them, which is what makes them the end-to-end idempotency key. They also sort lexicographically by time, which is what makes them usable as pagination cursors.

Authentication

Two disjoint planes over one primitive. Both store only a hash of the credential.

Devices — bearer token

Authorization: Bearer <64 hex>. The status codes are a contract, and the difference between them matters to a client that has to decide what to do next:

People — session cookie

Passwordless. Request a link, redeem the token, receive an httpOnly; Secure; SameSite=Lax cookie scoped to the origin. The session is opaque and stored server-side rather than signed into a token, because logout, credential compromise and erasure all require the ability to actually revoke it.

POST/v1/auth/magic-link none { email, invite_code? }. An address we do not know needs an invite. Returns 202 with no body in production.
POST/v1/auth/verify none { token }, single-use, 15-minute lifetime. Sets the session cookie.
GET/v1/auth/me session Returns { user_id }. Used as a cheap session probe.
POST/v1/auth/logout session Revokes the session and clears the cookie.

Enrolling a device

A three-step grant, so that a device identity costs what a device costs. The unit proves a factory secret, a human authorises a short code, and only then is a token minted — the token is delivered to the device and never passes through the person's hands.

POST/v1/devices/enroll/start factory secret { serial, secret }{ enrollment_id, user_code, expires_in, interval }. The user code is eight characters for a human to read off a screen.
POST/v1/devices/enroll/complete session { user_code }. The signed-in human is the authoriser.
POST/v1/devices/enroll/poll none { enrollment_id }pending, expired, or claimed with { device_id, token }. The token is returned exactly once; there is no way to ask for it again.

Writing: sync

Three write endpoints, all the same shape: a device id, an array of at most 50 items, and a per-item verdict back in request order.

POST/v1/sync/notes bearer
POST/v1/sync/defects bearer
POST/v1/sync/detections bearer

The envelope

{
  "device_id": "<uuid>",
  "notes": [ /* 1–50 */ ]
}

A Note

{
  "id":          "01KZK69SG0QW4M7T2XBVHN8RDA",  // your ULID
  "plate_id":    "FL-7HJ260",
  "category":    "let-me-in",                   // one of the ten
  "source":      "voice",                       // voice | kiosk | web
  "text":        "waved me in on the ramp",    // ≤200 chars, nullable
  "observed_at": "2026-08-09T18:42:11Z",
  "lat":         27.9506,                       // nullable
  "lon":         -82.4572                       // nullable
}

There is no auto in source, and there never will be. A machine may not author a Note — machines emit Defects. web widens the set of humans, not the set of authors that are machines.

A Defect

{
  "id":          "01KZK6A1M4…",
  "plate_id":    "FL-4TZ118",
  "kind":        "brake-lamp-out",
  "confidence":  0.97,
  "observed_at": "2026-08-09T05:19:44Z"
}

No text field of any kind, deliberately. There is no human author, so there is nothing to quote — and no field means no moderation surface.

The response

{ "results": [
  { "id": "01KZ…", "status": "accepted" },
  { "id": "01KZ…", "status": "duplicate" },
  { "id": "01KZ…", "status": "rejected", "reason": "unknown-category" }
] }

duplicate counts as success — replaying a batch is safe and expected. rejected is final: never retry one. Reasons you will actually see are unknown-category, unknown-kind, low-confidence, not-plate-keyed, targeting-limit and off-roster.

That last one is the important one. A Detection is persisted only if the plate is already on some site's active roster; everything else is dropped and never written down. It is what stops a fixed camera from accumulating a record of everyone who drives past it.

Reading: plates

GET/v1/plates/:plateId optional session

Without a claim on that plate, the response is the bare vehicle record and nothing else:

{
  "id":            "FL-7HJ260",
  "region":        "FL",
  "plate_text":    "7HJ260",
  "first_seen_at": "2026-05-02T11:07:55Z"
}

With your own active claim on it, three more fields appear:

{
  …
  "note_count":         1514,
  "counts_by_category": { "let-me-in": 402, "courteous": 341, … },
  "recent_notes":       [ /* ≤20 */ ]
}

Note what this is not: an unclaimed plate does not return 403, and a plate with no Notes is byte-identical to a plate you have not claimed. The public surface cannot be used as an oracle for whether a vehicle has been talked about.

POST/v1/plates/:plateId/claim session Idempotent. Two different people may claim the same plate.
DELETE/v1/plates/:plateId/claim session Always 204, whether or not there was a claim.

The owner's surface

GET/v1/me/claims session The plates you track. Keyset paginated.
GET/v1/me/notes session Notes across every plate you have claimed.
GET/v1/plates/:plateId/notes session + claim
{
  "notes":       [ … ],
  "my_replies":  [ { "note_id", "preset_code", "created_at" } ],
  "next_cursor": "01KZ…" | null
}

my_replies is always present and empty when there is nothing, on purpose: an unclaimed caller, a plate that does not exist, and a claimed plate with no Notes all return the identical object.

POST /v1/plates/:plateId/notes/:noteId/replies session + claim { preset_code } — one of disputed, wasnt-driving, fixed-it, thanks. There is no body field.
POST/v1/plates/:plateId/report session { reason_code, reason_detail? }. Detail is required when the reason is other.

Fleets

GET/v1/fleet/defects session · org-scoped Your organisation's private outbox. Defects reach it by delivery, never by query.
POST /v1/fleet/defects/:defectId/resolve session · org-scoped Idempotent. 404 if it is not yours.
GET/v1/fleet/coverage session · org-scoped Per-plate sighting counts: how much of your own fleet you are seeing.

Pagination

Keyset only. There is no OFFSET anywhere in this API and no page numbers.

GET /v1/me/notes?limit=50
  → { …, "next_cursor": "01KZK69SG0QW4M7T2XBVHN8RDA" }

GET /v1/me/notes?limit=50&cursor=01KZK69SG0QW4M7T2XBVHN8RDA
  → { …, "next_cursor": null }   // last page

A cursor is a bare ULID — the last id you saw. limit is 1–100, default 50. A null cursor means the end.

One behaviour to code for: a full final page returns a non-null cursor whose follow-up comes back empty. Tolerate one extra request rather than treating an empty page as an error.

Limits

LimitValueOn exceeding
Requests120 / minute429
Batch size50400
Page size100400
Notes per vehicle per day3targeting-limit
Replies per Note per day3429
Note text200 chars400

Rate limiting buckets per session first, then per token, then per address. The address bucket is the last resort rather than the default, because everyone behind one carrier-grade NAT would otherwise share a single allowance — which presents as “flaky on mobile” and is really a self-inflicted outage.

The daily per-vehicle ceiling is the one worth designing around. Three Notes about the same plate from the same device in 24 hours, and the fourth is refused. Following one car and narrating it is not a supported use of this API.

What does not exist

These are part of the contract. A partner can build on a capability that was never granted to anybody in a way they can never build on a policy that might be revised.

GET /v1/sync/defects There is a POST and there is no GET. A Defect is owner-private and has no read path at all.
GET /v1/plates/:plateId/sightings Nothing answers “where has this plate been?” for any caller.
GET /v1/roster No route hands out a plate list. A roster reaches a device as hashes, out of band.
Plate.owner · Note.author · any person↔plate edge No field on any object. Not restricted, not redacted, not permissioned — absent.
Alert — no schema, no table, no endpoint An in-cabin alert fires and dies there. It has no upload path, and that is the entire point of it.

← Back to the site