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.
| Object | Authored by | Describes | Visibility | On re-observation |
|---|---|---|---|---|
| Note | a human | how a vehicle was driven | the plate's claimant | a second Note |
| Defect | a machine | a vehicle's condition | owner only, never public | deduplicated |
| Hazard | a machine | the road | aggregate only | rolled up |
| Detection | a machine | a sighting | device-local | discarded |
| Claim | a human | consent | the claimant | idempotent |
| Reply | a human | an answer to a Note | with the Note | newest 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:
401— this credential is unknown. Go and get a new one.-
403— this credential is fine, you are not authorised yet. Back off and hold your outbox; do not discard anything.
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.
{ email, invite_code? }. An address we do not know needs an invite. Returns
202 with no body in production.
{ token }, single-use, 15-minute lifetime. Sets the session cookie.
{ user_id }. Used as a cheap session probe.
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.
{ serial, secret } → { enrollment_id, user_code, expires_in, interval }.
The user code is eight characters for a human to read off a screen.
{ user_code }. The signed-in human is the authoriser.
{ 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.
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
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.
204, whether or not there was a claim.
The owner's surface
{
"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.
{ preset_code } — one of disputed, wasnt-driving,
fixed-it, thanks. There is no body field.
{ reason_code, reason_detail? }. Detail is required when the reason is
other.
Fleets
404 if it is not yours.
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
| Limit | Value | On exceeding |
|---|---|---|
| Requests | 120 / minute | 429 |
| Batch size | 50 | 400 |
| Page size | 100 | 400 |
| Notes per vehicle per day | 3 | targeting-limit |
| Replies per Note per day | 3 | 429 |
| Note text | 200 chars | 400 |
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.