Skip to main content
zerologszerologs

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:writePush telemetry samples for a paired device.
logs:writeUpload a log file (.bin or serial console capture) for a paired device.
telemetry:readRead back the samples a paired device has sent.
bikes:readList 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_bytes2,097,152
max_decompressed_bytes4,194,304
max_samples_per_batch500
max_bytes_per_sample8,192
raw_clock_future_days30
observed_future_seconds300
idempotency_hours48

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.

KeyTypeAccepted range
speed_kmhnumber0 … 400
throttle_pctnumber0 … 100
motor_rpmnumber-20000 … 20000
torque_nmnumber-1000 … 1000
pack_voltage_vnumber0 … 500
pack_current_anumber-1500 … 1500
power_kwnumber-500 … 500
soc_pctnumber0 … 100
motor_temp_cnumber-60 … 300
battery_temp_cnumber-60 … 150
controller_temp_cnumber-60 … 300
ambient_temp_cnumber-80 … 80
odometer_kmnumber0 … 2000000
discharge_limit_kwnumber0 … 500
charge_limit_kwnumber0 … 500
cell_voltage_min_vnumber0 … 6
cell_voltage_max_vnumber0 … 6
cell_voltage_delta_vnumber0 … 6
cell_voltage_ocv_min_vnumber0 … 6
isolation_kohmnumber0 … 100000
charge_cyclesnumber0 … 20000
range_kmnumber0 … 2000
wh_per_kmnumber0 … 5000
bike_statestringup to 24 characters
is_chargingbooleantrue / false
is_under_loadbooleantrue / false
fault_codestringup to 40 characters
latnumber-90 … 90
lonnumber-180 … 180
altitude_mnumber-500 … 9000
heading_degnumber0 … 360
gps_accuracy_mnumber0 … 10000

Endpoints

Ingest

Ingest endpoints
MethodPathWhat it does
GET/api/v1/ingest/limitsRead the remaining allowance without spending any
POST/api/v1/ingest/logsUpload a .bin or serial console capture for a paired device
POST/api/v1/ingest/telemetryPush a batch of telemetry samples from one paired device
GET/api/v1/ingest/whoamiConfirm the credential and read the server clock

Devices and read-back

Device endpoints
MethodPathWhat it does
GET/api/v1/devicesList the devices and motorcycles this credential may write to
POST/api/v1/devicesPair 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}/batchesThe audit log: what was sent, what landed, and what was refused
GET/api/v1/devices/{device_id}/latestThe newest reading and how old it is
GET/api/v1/devices/{device_id}/samplesRead back the samples a device sent, newest first
GET/api/v1/devices/{device_id}/samples.csvExport 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.

API key endpoints
MethodPathWhat it does
GET/api/v1/users/me/api-keysList this rider's machine credentials
POST/api/v1/users/me/api-keysMint a key — the only response that ever carries the secret
GET/api/v1/users/me/api-keys/scopesThe 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}/rotateMint 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 retryable

No credential presented

Send your key as `Authorization: Bearer zlk_live_…`. There is no query parameter and no cookie.

invalid-credentialHTTP 401not retryable

Credential 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 retryable

Two credentials presented

The request carried both `Authorization` and `X-API-Key`. Send exactly one; `X-API-Key` is not supported.

unsupported-credential-headerHTTP 400not retryable

Credential 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 retryable

Key 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 retryable

Key may not act on this motorcycle

The key is limited to named motorcycles and this is not one of them.

demo-accountHTTP 403not retryable

The demo account is read-only

The public demo account cannot write. Create a free account.

unknown-deviceHTTP 404not retryable

No 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 retryable

Device is blocked by its owner

The owner has switched this device off. Nothing it sends is stored.

not-foundHTTP 404not retryable

No 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 retryable

VIN 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 retryable

Request 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 retryable

Unknown envelope version

`v` names a payload version this server does not implement.

unsupported-encodingHTTP 415not retryable

Unsupported Content-Encoding

Only `gzip` is accepted. Send the body uncompressed or gzipped.

batch-too-largeHTTP 413not retryable

Batch 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 retryable

Body is not valid JSON

The body did not parse, or the gzip stream was truncated.

idempotency-key-reusedHTTP 422not retryable

Idempotency-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 409retryable

A 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 429retryable

Too 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 429retryable

Daily 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 retryable

Sample 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 retryable

Sample 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.

This is an independent third-party tool, not affiliated with or endorsed by Zero Motorcycles, Inc. The API writes into an account only with its owner’s explicit, revocable permission, and every credential it issues can be destroyed by the rider at any time.