Public API · OpenAPI 3.1
Send your motorcycle’s data to zerologs
For a third-party OBD dongle, a phone app, or anything else that can read a Zero and speak HTTP. Push telemetry and log files straight into a rider’s own account, with their permission and under a credential they can revoke.
Base URL https://api.zerologs.bike · OpenAPI 3.1 document · sample schema v1
What pushed telemetry is not, yet
Samples you push are stored against the device and read back through this API and the rider’s device page. They are not yet counted in rides, charges, distance totals, battery health or public share links. Every response says so ("promoted": false), so you never have to find out by asking. Log FILES you upload through /ingest/logs go through the ordinary parsing pipeline and do appear everywhere, exactly as a rider’s own upload does.
The OpenAPI document
Everything on this page is generated from the routes the server actually declares, and the same run writes the machine-readable description. It is OpenAPI 3.1, served cross-origin, and it is the thing to point your generator at — not this page.
Both viewers are hosted by their authors and fetch the document from your browser; nothing about your account is involved. The document carries the channel table, the scope registry and the error catalogue below under x-zerologs, so a generated client can read them too.
Quickstart
# 1. Mint a key at https://zerologs.bike/dashboard/devices
# and pair your device to a motorcycle. Both are things the RIDER does, once,
# signed in. A device can never pair itself: a credential that chooses what it
# acts on is not a bounded credential.
KEY=zlk_live_7Qm2xB4vRt9k_...
# 2. Confirm the credential and read the server clock BEFORE the first push.
curl https://api.zerologs.bike/api/v1/ingest/whoami \
-H "Authorization: Bearer $KEY"
# 3. Push a batch.
curl -X POST https://api.zerologs.bike/api/v1/ingest/telemetry \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f14e45f" \
-d '{
"v": 1,
"device_uid": "dongle-1",
"sent_at": "2026-09-17T15:00:00Z",
"samples": [
{"t": "2026-09-17T14:59:58Z", "soc_pct": 63, "pack_voltage_v": 112.4,
"pack_current_a": -12.5, "speed_kmh": 47.2, "battery_temp_c": 24.5},
{"t": "2026-09-17T14:59:59Z", "soc_pct": 63, "pack_voltage_v": 112.3,
"pack_current_a": -12.6, "speed_kmh": 47.9, "battery_temp_c": 24.5}
]
}'
# 207 Multi-Status
# {"batch_id":"...","accepted":2,"duplicates":0,"rejected":0,"promoted":false,
# "results":[{"index":0,"t":"2026-09-17T14:59:58Z","status":"accepted","retryable":false},
# {"index":1,"t":"2026-09-17T14:59:59Z","status":"accepted","retryable":false}]}Authentication
A key looks like zlk_live_<key_id>_<secret><checksum> and travels as Authorization: Bearer <key>. There is no query parameter, no cookie and no X-API-Key header — that last one is refused with a 400 rather than ignored, so a client that thinks it authenticated finds out immediately.
The key is shown once, when it is created. We store a one-way keyed hash and genuinely cannot recover it; if it is lost, rotate. The key_id in the middle is not secret — it is a lookup handle, safe to log and to quote in a support request.
An unknown key, a wrong secret, a revoked key and an expired key all answer with the same 401. That is deliberate: a distinguishable answer would let anyone confirm which keys exist, and would tell whoever leaked one exactly how much of the leak we had noticed.
Scopes
Fixed when the key is issued and narrowable afterwards, never wideable. Widening means minting a new key, which the rider can see.
| telemetry:write | Push telemetry samples for a paired device. |
| logs:write | Upload a log file (.bin or serial console capture) for a paired device. |
| telemetry:read | Read back the samples a paired device has sent. |
| bikes:read | List the devices and motorcycles this key may write to. |
A key is also bound to named motorcycles, so a leak cannot follow an account into the bikes it buys next year.
Which motorcycle
You never send a VIN to choose one. A VIN is stamped on the frame and printed inside every log file, so it identifies a motorcycle and authorises nothing.
Instead the rider pairs the device to a motorcycle once, in their dashboard, while signed in. After that your envelope carries your own device_uid and nothing else. You may include a vin as a cross-check — if it disagrees with the pairing you get a 409 rather than a silent guess. The same rule applies to a log file: if its header names a different motorcycle than the device is paired to, it is refused.
Batches, retries and duplicates
Send batches, not single samples: a twenty-minute tunnel must not become hundreds of round trips when the phone comes back. A batch of one is fine.
Retry freely. A sample is identified by (device, timestamp) permanently, so re-sending your buffer after a week in a garage with no signal writes nothing twice. A duplicate is reported as success, with its own count — under at-least-once delivery duplicates are what a correct client produces, and we are not going to punish that.
Idempotency-Key is a separate, request-level safety net: the same key replays the original response verbatim for 48 hours. Use it for “did my POST actually arrive”, not for deduplication — a device offline for longer retries outside any such window, which is why the permanent key above exists.
Once the envelope parses, the answer is always 207 Multi-Status with one result per input index, echoing your own timestamp. Truncate your queue by identity, never by count. A whole-batch 4xx means nothing was stored: bad credential, unparseable body, missing version, or over a cap.
Limits
| max_request_bytes | 2,097,152 |
| max_decompressed_bytes | 4,194,304 |
| max_samples_per_batch | 500 |
| max_bytes_per_sample | 8,192 |
| raw_clock_future_days | 30 |
| observed_future_seconds | 300 |
| idempotency_hours | 48 |
Read them at runtime from GET /api/v1/ingest/whoami rather than hard-coding them. Content-Encoding: gzip is accepted and recommended.
Clocks
Stamp each sample with your own clock in t, and stamp the envelope with the same clock in sent_at. We subtract: a device whose clock is forty minutes fast still lands with correct times, because the offset cancels. You do not have to be right about the time; you have to be consistent within one request.
The one thing that cannot be recovered is a clock stuck at the epoch, because the timestamp is also the deduplication key — every sample would collapse onto one row. If your hardware has no real-time clock, read server_time from /ingest/whoami first.
Out-of-order and backfilled samples are accepted unconditionally. Nothing here assumes you are sending the newest data.
Units and signs
Everything is metric, and the unit is in the field name — speed_kmh, pack_voltage_v, battery_temp_c. Never convert at the device.
Pack current is positive when the pack is discharging and negative when energy is going in, which covers both regeneration and charging. If your hardware uses the opposite convention, do not flip it in firmware: the rider can flip it per device, and a setting can be corrected later while a season of re-signed data cannot.
A channel we do not recognise is kept rather than refused, so newer firmware never loses data to an older server. A value outside its physical range drops that one channel and keeps the rest of the sample.
Channels
Every key a sample may carry. Send the ones you read and omit the rest; a value outside its range drops that one channel and keeps the rest of the sample. Anything not in this list is kept verbatim under extra, so newer firmware never loses data to an older server.
| Key | Type | Accepted range |
|---|---|---|
| speed_kmh | number | 0 … 400 |
| throttle_pct | number | 0 … 100 |
| motor_rpm | number | -20000 … 20000 |
| torque_nm | number | -1000 … 1000 |
| pack_voltage_v | number | 0 … 500 |
| pack_current_a | number | -1500 … 1500 |
| power_kw | number | -500 … 500 |
| soc_pct | number | 0 … 100 |
| motor_temp_c | number | -60 … 300 |
| battery_temp_c | number | -60 … 150 |
| controller_temp_c | number | -60 … 300 |
| ambient_temp_c | number | -80 … 80 |
| odometer_km | number | 0 … 2000000 |
| discharge_limit_kw | number | 0 … 500 |
| charge_limit_kw | number | 0 … 500 |
| cell_voltage_min_v | number | 0 … 6 |
| cell_voltage_max_v | number | 0 … 6 |
| cell_voltage_delta_v | number | 0 … 6 |
| cell_voltage_ocv_min_v | number | 0 … 6 |
| isolation_kohm | number | 0 … 100000 |
| charge_cycles | number | 0 … 20000 |
| range_km | number | 0 … 2000 |
| wh_per_km | number | 0 … 5000 |
| bike_state | string | up to 24 characters |
| is_charging | boolean | true / false |
| is_under_load | boolean | true / false |
| fault_code | string | up to 40 characters |
| lat | number | -90 … 90 |
| lon | number | -180 … 180 |
| altitude_m | number | -500 … 9000 |
| heading_deg | number | 0 … 360 |
| gps_accuracy_m | number | 0 … 10000 |
Endpoints
Ingest
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/ingest/limits | Read the remaining allowance without spending any |
| POST | /api/v1/ingest/logs | Upload a .bin or serial console capture for a paired device |
| POST | /api/v1/ingest/telemetry | Push a batch of telemetry samples from one paired device |
| GET | /api/v1/ingest/whoami | Confirm the credential and read the server clock |
Devices and read-back
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/devices | List the devices and motorcycles this credential may write to |
| POST | /api/v1/devices | Pair a device to a motorcycle this account owns |
| DELETE | /api/v1/devices/{device_id} | Unpair a device and delete everything it ever sent |
| PATCH | /api/v1/devices/{device_id} | Relabel, block, or change a device's current-sign convention |
| GET | /api/v1/devices/{device_id}/batches | The audit log: what was sent, what landed, and what was refused |
| GET | /api/v1/devices/{device_id}/latest | The newest reading and how old it is |
| GET | /api/v1/devices/{device_id}/samples | Read back the samples a device sent, newest first |
| GET | /api/v1/devices/{device_id}/samples.csv | Export a device's samples as CSV, in the wire channel order |
Key management (rider, signed in)
These need an ordinary session, not a key. A key can never mint, widen or revoke another key — that is what stops one leak from becoming a permanent foothold.
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/users/me/api-keys | List this rider's machine credentials |
| POST | /api/v1/users/me/api-keys | Mint a key — the only response that ever carries the secret |
| GET | /api/v1/users/me/api-keys/scopes | The closed scope registry, with its published wording |
| DELETE | /api/v1/users/me/api-keys/{key_id} | Revoke a key. Soft: the row survives so a leak can be traced |
| PATCH | /api/v1/users/me/api-keys/{key_id} | Relabel a key, or narrow its scopes — never widen them |
| POST | /api/v1/users/me/api-keys/{key_id}/rotate | Mint a successor without killing the incumbent immediately |
Errors
Every error is application/problem+json (RFC 9457). Branch on type, which is stable and links to the explanation below; detail is prose and will be reworded. retryable tells you whether repeating the identical request could ever succeed.
missing-credentialHTTP 401not retryableNo credential presented
Send your key as `Authorization: Bearer zlk_live_…`. There is no query parameter and no cookie.
invalid-credentialHTTP 401not retryableCredential not accepted
The key is unknown, malformed, revoked or expired. All four answer identically on purpose, so this response cannot be used to find out which keys exist.
conflicting-credentialsHTTP 400not retryableTwo credentials presented
The request carried both `Authorization` and `X-API-Key`. Send exactly one; `X-API-Key` is not supported.
unsupported-credential-headerHTTP 400not retryableCredential sent in an unsupported header
`X-API-Key` is not supported. The key is a bearer token: send `Authorization: Bearer zlk_live_…`. It is refused loudly rather than ignored, because a client that believes it authenticated and is treated as anonymous gets a 401 it cannot explain. Note that on the two POST routes the header-only preflight answers first and returns `missing-credential` instead — also a refusal, and also accurate: no bearer credential was presented.
insufficient-scopeHTTP 403not retryableKey lacks the required scope
The credential is valid but was not issued with the scope this operation needs. Scopes are fixed at issue — mint a new key rather than widening this one.
bike-not-in-scopeHTTP 403not retryableKey may not act on this motorcycle
The key is limited to named motorcycles and this is not one of them.
demo-accountHTTP 403not retryableThe demo account is read-only
The public demo account cannot write. Create a free account.
unknown-deviceHTTP 404not retryableNo such paired device
`device_uid` does not name a device paired to this account. Pair it once in the dashboard; pairing is the only place a device is bound to a motorcycle, and it needs a signed-in rider.
device-blockedHTTP 403not retryableDevice is blocked by its owner
The owner has switched this device off. Nothing it sends is stored.
not-foundHTTP 404not retryableNo such device or motorcycle on this account
The id does not name a device or a motorcycle this account owns. It is 404 and never 403 on purpose: "this exists and is not yours" is an existence oracle, so the two answers are deliberately the same one.
vin-mismatchHTTP 409not retryableVIN does not match the paired motorcycle
The envelope volunteered a `vin`, or the uploaded log file contained one, that is not the VIN of the motorcycle this device is paired to. The VIN is a cross-check; it never selects a motorcycle and it can never claim one. Pair a device for that motorcycle and upload it against that device instead.
malformed-envelopeHTTP 400not retryableRequest body is not a valid envelope
The body must be a JSON object with `v`, `device_uid` and `samples`. A bare array is not accepted — it could never carry a schema version.
unsupported-schema-versionHTTP 400not retryableUnknown envelope version
`v` names a payload version this server does not implement.
unsupported-encodingHTTP 415not retryableUnsupported Content-Encoding
Only `gzip` is accepted. Send the body uncompressed or gzipped.
batch-too-largeHTTP 413not retryableBatch exceeds a published limit
One of the three caps was crossed: samples per batch, decompressed bytes, or bytes in a single sample. `GET /api/v1/ingest/whoami` publishes all three; split the batch and resend.
malformed-bodyHTTP 400not retryableBody is not valid JSON
The body did not parse, or the gzip stream was truncated.
idempotency-key-reusedHTTP 422not retryableIdempotency-Key reused with a different body
That key was already used for a different payload. A key identifies ONE request; use a new one for new samples.
idempotency-in-flightHTTP 409retryableA request with this Idempotency-Key is running
The original is still being processed. Retry shortly — you will get its answer, not a second execution.
rate-limitedHTTP 429retryableToo many requests for this key
The per-minute burst allowance for this key is spent. Honour `Retry-After`, and back off exponentially with jitter.
quota-exceededHTTP 429retryableDaily sample quota exhausted
This key has written its daily sample allowance. It resets at the next UTC midnight; `Retry-After` gives the seconds.
sample-time-out-of-rangeHTTP 400not retryableSample timestamp is implausible
`t` is outside the accepted window. A device with no real-time clock should read `server_time` from `GET /api/v1/ingest/whoami` before its first push — a clock stuck at the epoch cannot be corrected, because the timestamp is also the deduplication key.
sample-malformedHTTP 400not retryableSample is not a valid object
A sample must be a JSON object carrying `t`. Unknown channels are kept, not refused — but a sample with no usable timestamp cannot be stored.
Getting started
Keys and device pairing live in your dashboard. Building something and want to talk about it first? Get in touch.