# Device API - the phone-to-backend contract This is the wire contract between the patched Dialer and the backend. Two codebases implement it - `components/dialer-patch` (Java) and `components/backend/server.rb` + `lib/helpdesk/domain.rb` (Ruby) - so when you change one side, this page and the other side change with it. How the phone decides where and when to send these requests is a separate page, `docs/phone-connectivity.md`. ## Transport In production the phone posts to the device door: `https://moje.al.army:8443/api/v1`. That nginx vhost speaks TLS without a client certificate (the phone can't hold one), allowlists only `/api/v1/calls/`, `/api/v1/device/` and `/api/v1/recordings/` (everything else is a 404), blanks the operator-identity headers, rate-limits at 10 requests/s, and caps bodies at 64 KB - except recordings, which get 64 MB. The backend adds its own 80 MiB cap as a backstop (413 `body_too_large`). In local dev the phone or simulator talks plain HTTP to `:4000` with the built-in dev credentials. ## Correlation - `call_id` - UUIDv4, minted by the phone the moment a call is detected, sent on every request about that call. It is the one key that stitches the webhooks, the recording upload, the transcript, and the event together. - `recording_uuid` - UUIDv4, minted by the phone when a recording file is finalized. One recording per call. Retrying an upload reuses the same uuid, which is what makes retries safe. - `ts` - the device's own UTC ISO-8601 timestamp for the moment the thing happened. The backend stores it as sent; a delayed retry still reports the original time. Delivery is at-least-once everywhere. Retries and out-of-order arrival are normal, not errors: every webhook is an idempotent upsert keyed on `call_id`, and a duplicate returns 200 with the event's current state. ## Authentication Every request carries four headers, built by `DeviceAuthSigner.java` and checked by `authed?` in `server.rb`: | Header | Value | |---|---| | `Authorization` | `Bearer ` | | `X-Request-Ts` | unix seconds, now | | `X-Request-Nonce` | fresh random string per request | | `X-Signature` | lowercase-hex HMAC-SHA256(`device_secret`, `".."`) | The signature covers the exact body bytes, so UTF-8 Czech text can't diverge between the two sides. The backend rejects a timestamp more than 300 seconds off its own clock, and a nonce it has seen in the last 600 seconds. Everything compares constant-time. Failures are a plain 401; malformed JSON is a 422 that the phone must not retry. In production the server refuses to boot with the repo-public dev credentials (`dev-device-token`/`dev-device-secret`); real values come from `/etc/helpdesk/env`. ## Endpoints, device side All paths relative to `/api/v1`. All device-authed. `POST /calls/incoming` - fired at ring, before anyone answers. The phone never waits for the response; ringing must not block on the network. ```jsonc { "call_id": "uuid", "ts": "2026-07-24T09:14:03Z", "number": "+420601234567", // caller, E.164 or null when withheld "presentation": "allowed", // allowed | restricted | unknown | payphone "dialed_did": "+420800111222", // which of our numbers was called; may be omitted "device_id": "pixel10a-office-1" } → 200 { "event_id": 123, "state": "ringing" } ``` The backend resolves the caller to a person and the dialed number to an operator queue, then pushes the screen-pop to that operator's console. `POST /calls/outgoing` - the same, for a call the phone places (usually because the operator clicked dial in the console). Body is `call_id`, `ts`, `number`, `device_id`; there is no `dialed_did` on a call we originate. The event is marked `direction: outbound` and everything downstream works the same. `POST /calls/answered` - `{ "call_id", "ts" }` → 200 `{ event_id, state }`. `POST /calls/ended`: ```jsonc { "call_id": "uuid", "ts": "…", "duration_s": 213, "disconnect_cause": "remote", // local | remote | missed | rejected | error | unknown "recording_uuid": "uuid|null" } // null = no recording ever started → 200 { "event_id": 123, "state": "ended" } ``` If `duration_s` is missing the backend derives it from the answered/ended timestamps. An `ended` without a prior `answered` is fine for missed and rejected calls; for anything else the event gets a `timeline_incomplete` flag, because a webhook evidently got lost. `PUT /recordings/{recording_uuid}` - the audio bytes as the body (the phone records .m4a), with the `X-Call-Id` header linking it to the call. Missing header → 400, because audio without a call id could never be attached. Normally → 200 `{ event_id, state: "recording_uploaded" }` and transcription starts. If the webhooks lost the race and no event exists yet, the audio is quarantined and the response is 200 `{ "quarantined": true, "call_id": … }`; when `/calls/incoming` finally lands, the recording is reattached automatically. Quarantined audio nobody claims is dropped after 24 hours. `GET /device/commands?device_id=…&wait=25` - the reverse channel. Long-poll: the backend holds the request up to `wait` seconds (capped at 30) and answers the moment an operator queues a command, so a console click reaches the phone in about one round trip. Response: ```jsonc { "commands": [ { "id": "uuid", "call_id": "uuid|null", "verb": "answer", "arg": null, "issued_at": "…", "status": "pending" } ] } ``` Draining is destructive - returned commands are gone from the queue, so the phone must act on them. The phone applies each verb through the same Telecom paths as the on-screen buttons, and the result comes back through the ordinary webhooks. There is no separate acknowledgement. `GET /device/heartbeat?device_id=…` - `{ "ok": true }`. Marks the device as present, nothing else. A device counts as connected if it polled commands or heartbeated within the last 300 seconds; that feeds the console's "phone connected" badge and gates click-to-dial. `POST /device/contacts` - the phone pushes its address book, at most every 30 minutes: ```jsonc { "contacts": [ { "name": "Jan Novák", "numbers": ["+420601234567", "601234567"], "emails": ["jan@example.cz"] } ] } → 200 { "upserted": 12 } ``` Matching is by E.164 number. The phone owns name and email; the console owns everything else about a person, and a contact deleted on the phone is never deleted server-side - it still has call history. ## The command verbs `answer reject hangup hold resume mute unmute dtmf route dial`, defined once as `COMMAND_VERBS` in `domain.rb` and mirrored by the phone's `CommandGate.java`. Operators queue call-scoped verbs with `POST /api/v1/calls/{call_id}/command` (operator-authed, `{ "verb": …, "arg": … }`, → 202) and the one device-scoped verb with `POST /api/v1/dial` (`{ "number": … }`). Dial only accepts numbers that normalize to E.164 - anything else (`*21*…#` style MMI codes, short codes) is rejected - and only targets a device that is currently connected, with a queue cap of 20 pending commands. ## The event state machine States rank `ringing(0) < answered(1) < ended(2) < recording_uploaded(3) < transcribed(4) < summarised(5)`, with three side states that carry no rank: `recording_missing`, `no_speech`, `failed`. The one rule: state only moves forward. An arriving webhook may fill in its timestamps at any time, but the state becomes the higher of current and incoming - so a retried `ended` landing after the upload changes nothing, and out-of-order delivery is harmless by construction. `advance!` in `domain.rb` is the whole implementation. After the upload the backend runs the pipeline on its own: transcription (hosted Whisper), then the summary. A failed summary leaves the event at `transcribed` - the transcript is the valuable part and the summary can be retried later. A transcription with no speech ends at `no_speech`; exhausted retries end at `failed` with a reason on the event. ## Reconciliation A watchdog runs every 60 seconds (`reconcile!` in `domain.rb`) and closes whatever the webhooks left hanging: | Situation | After | Result | |---|---|---| | `ringing`, nothing else ever arrived | 15 min | closed as missed | | `answered`, no `ended` | 6 h | closed, no duration, flagged for review | | `ended` said a recording exists, upload never came | 30 min | `recording_missing` - still recoverable, a late upload advances it | | quarantined upload nobody claimed | 24 h | dropped | Every terminal-but-incomplete path still leaves a countable event with an explicit state and flags; calls never silently vanish. ## Errors, uniformly 401 unauthorized (bad token, signature, skew, or replay) · 404 unknown path or call · 413 body too large · 422 malformed JSON or bad verb/argument - do not retry · 500 with `{"error":"server_error"}` and no detail, because the detail may contain caller numbers; the real message is in the server log.