Device <-> Licence Register — API Contract v1.0 (FINAL, verified against live code)
Status: server side is LIVE (YAVU licence register, backend/iot_router.py, IoT-Router v2).
The manufacturer implements client calls only — no server development is needed or in scope.
Principles (from the server): "dumb device, smart server" · fail-open (server silence NEVER locks
a device — grace default 72 h) · settings can only throttle, never exceed hardware limits · the
60 µl limit is mechanical and has NO server-side lever.
Base & authentication
- Base URL:
https://<register-host>/api/v1/iot - Every device call: HTTPS + header
Authorization: Bearer <device_token> - The
device_tokenis issued ONCE at provisioning (admin:POST /admin/devices/provisionwithlicense_id,device_serial,device_type=IRI-FP,hardware_rev). Only a hash is stored server-side — the device must persist the token in NVS (encrypted flash). Revoked = 401.
0. POST /enroll — cable-free pairing (device has no token yet)
No bearer header (the device has nothing yet); the pinned CA still applies. Firmware
calls this every poll_seconds while unprovisioned (LicenceClient::enroll, driven by
DeviceController::servicePairing, only in LOCKED/UNCOCKED/READY).
Send: { "device_serial": "IRI-PRO-000123", "device_type": "IRI-FP", "hardware_rev": "A1",
"fw_version": "0.1.0", "enroll_nonce": "<from the first reply, then always>" }
Receive while waiting: { "status": "pending", "pairing_code": "483920", "enroll_nonce": "…",
"poll_seconds": 15 } — the device shows the 6 digits (IDisplayUi::showPairing).
Receive once the admin has paired serial + code to a licence in the register:
{ "status": "approved", "device_token": "…", "unlock_secret_hex": "<64 hex>" } — once;
the device stores both in NVS (dev_token, unlock_key) and re-runs the licence check.
403 = nonce mismatch (device forgets its nonce and starts over), 409 = serial already
provisioned another way. Pending entries expire after 24 h.
No USB, no factory secret: the pairing code is the proof of possession, the nonce keeps a
third party from collecting the token after approval. Manufacturers may still use the classic
POST /admin/devices/provision (token + secret returned once) and write both at the factory.
1. POST /heartbeat — the one roundtrip that tells the device everything
Send (default every 15 min, configurable 5–60 via settings):
{ "fw_version": "0.1.0", "rssi": -61, "battery_pct": 87,
"shots_total": 1234, "dosing_heads_used": 17, "error_code": null, "counters": {} }
Receive:
{ "action": "ok | remind | lock",
"settings": { "max_level": 20, "heartbeat_minutes": 15 },
"settings_version": "<hash>",
"content_version": 3,
"balances": { "S600": 12345 },
"grace_hours": 72,
"fw_update_available": false,
"server_time": "2026-07-28T..." }
Device behaviour: apply settings (they are pre-clamped server-side); on action=lock enter
LOCKED state; on remind show balance reminder on display; if the server is unreachable, keep
treating for grace_hours since last_online (fail-open), then offline-unlock codes apply.
2. POST /events/shots — offline-capable metering (IRI-FP)
Queue shots locally (flash ring buffer), send in batches (max 500):
{ "shots": [ { "ts": "2026-07-28T10:00:00Z", "seq": 1235, "level": 12,
"volume_ul": 36.0, "product": "S600" } ] }
seq= strictly monotonic per device, NEVER reset (replay/dedup protection server-side; response returnslast_seq— delete queued entries with seq <= last_seq).volume_ul= ACTUAL dispensed volume (dose is variable; 60 µl is the mechanical cap — values > 60 are flagged server-side and debited capped).- Billing runs in µl against the licence balance — accuracy matters.
3. GET /license — on-demand status
Returns action, licence status/tier, studio_name, balances. Use at boot and before
entering LOCKED to double-check.
4. GET /fw/manifest — OTA
Returns fw_latest, fw_url, fw_sha256, update_available. The device MUST verify the
sha256 of the downloaded image before flashing (A/B slots recommended).
5. GET /content/manifest — display playlist
Returns version + playlist (tier-dependent; GOLD/DIAMOND may include the studio's own
promo video first). Cache by content_version from heartbeat.
6. Emergency offline unlock codes (spec — /core implementation)
Bridges outages beyond the 72 h grace: register/support generates a TIME-LIMITED code (HMAC-SHA256 over device serial + date window, truncated to 8-10 digits); studio enters it on the device (or via studio portal + BLE). Device verifies OFFLINE with its per-device secret, extends operation for a bounded window (e.g. 7 days), logs the event and reports it on reconnect. Codes are device-bound and expire — never a permanent bypass.
Server side (YAVU, done 2026-08-18, branch feature/offline-unlock-code):
POST /admin/devices/provision now also returns unlock_secret_hex (32 bytes, ONCE) —
provisioning must write it into the device's secure NVS/eFuse and pass it to
ControllerConfig::unlockSecret. POST /admin/devices/{id}/unlock-code computes the
current (or next) week's code; POST /admin/devices/{id}/unlock-secret/rotate issues a new
secret. The device serial used at provisioning must be the exact string the firmware
uses as cfg.serial — it is part of the HMAC message.
Algorithm (identical on both sides): window = (epoch/86400)/7; msg = "<serial>|<window>";
mac = HMAC-SHA256(secret, msg); off = mac[31] & 0x0f;
bin = ((mac[off]&0x7f)<<24)|(mac[off+1]<<16)|(mac[off+2]<<8)|mac[off+3]; code = bin mod 10^8,
printed with leading zeros. Device accepts window ±1.
Test vector (verified against the C++ truncation and the Python generator):
secret = 000102…1e1f (bytes 0..31), serial = IRI-PRO-000001, window = 2947 → code 53641176.
Device side: the code is typed on the four panel keys while LOCKED (confirm opens the dialog, dose ± sets a digit, confirm advances / submits, end-treatment cancels); five wrong codes lock the dialog for 15 minutes, persisted across power cycles.
7. POST /events/treatments — generic metering for FUTURE device types
IRI-FP keeps using /events/shots. New platform devices (CELLIQ, AIRBOX, ...) report here —
same seq/replay logic, generic consumption list. Listed for completeness.
Device duties summary (firmware /core)
- Persist device_token + per-device secret in encrypted NVS.
- Heartbeat scheduler (settings-driven interval) + fail-open grace timer since last success.
- Shot queue with monotonic seq, batch upload + last_seq pruning; volume_ul from dose setting.
- Enforce
action(ok/remind/lock) + software dose mirror (<= 60 µl) — mechanical stop stays primary. - Offline unlock code verification + logging.
- OTA: manifest poll, sha256 verify, A/B flash.
Source: firmware repo docs/API_CONTRACT.md · confidential · NDA