# Chavivim Dispatch — Device Fleet & OTA API — integration guide

> 🆕 **New (2026-07-13).** The distribution/control plane for the **Chavivim Dispatch** APK running on the **Sonim XP5s** keypad phones. chavivim2 hosts the APK binaries (dedicated `chavivim-apks` R2 bucket), tracks every device via a check-in heartbeat, and queues remote commands. This guide is for the developer wiring the **dispatch backend** (FastAPI, non-Cloudflare) to control the fleet — register/enroll devices, assign versions, push commands, publish OTA — with **full parity** to the `/admin/devices` UI. It's also the reference for the on-device app (check-in + OTA). Pair it with the interactive spec on this site (`openapi.yaml`); this is the prose + copy-paste.

---

## TL;DR

- **Base URL:** `https://worker.chavivim.org/api/v1` — the authenticated API is served **only** on this dedicated worker host (plus the `*.workers.dev` staging host), **not** on the `chavivim.org` apex or division domains (those return `404`).
- **Auth:** `Authorization: Bearer cvk_…` on **every** request. Keep the token server-side.
- **One control plane, two front doors:** the device registry + OTA catalog + binaries live **only** here (single source of truth). The `/admin/devices` UI (cookie auth) and your dispatch backend (bearer token) mutate the *same* tables via the *same* handlers — no capability is admin-cookie-only, and there's no duplicated registry.
- **Two consumers, two keys:**
  - **each Sonim device** (baked into the app): `devices:write ota:read` — check in, download updates, report command results.
  - **the dispatch backend** (your FastAPI service): `devices:* ota:read` — register/enroll, list, assign, push commands, publish OTA.
- **Model:** single cross-tenant bearer, like everything else on `/api/v1`. No per-device tokens — the device key authorizes the fleet surface and the registry row is found by the `device_uuid` in the payload. Identity + calls stay on the *separate* dispatch API (`app2.chavivim.org`); nothing here touches dispatch JWTs.
- **Format:** JSON in / JSON out. Timestamps are unix **ms**.

---

## Scopes

Mint keys at `worker.chavivim.org/admin/api-keys`. A `403` with `code: "insufficient_scope"` means the key lacks the scope.

| action | scope |
|---|---|
| check-in, register, assign, push/report/cancel commands, publish versions | `devices:write` |
| list / read devices, list commands | `devices:read` |
| **list versions** (`GET /ota/versions`), OTA manifest (`/ota/latest`), APK download (`/ota/download`) | `ota:read` |

- **Device key** (in the app): `devices:write ota:read`.
- **Dispatch backend key** (your service): `devices:* ota:read` (i.e. read + write + OTA).

---

## The device object

```jsonc
{
  "id": "dev_…",                      // 'dev_<base36-ts>_<rand>'
  "device_uuid": "a1b2c3d4e5f60718",  // Settings.Secure.ANDROID_ID (stable id)
  "unit": "KJ-52",                    // assigned unit/member ref (admin-owned)
  "division": "kj",                   // informational
  "model": "Sonim XP5900",
  "os_version": "11",
  "current_app_version": 3,           // versionCode the device last reported
  "assigned_version": null,           // admin-pinned versionCode; null = follow track
  "track": "stable",                  // stable | beta
  "kiosk_desired": false,             // should the device hold kiosk/lock-task
  "is_device_owner": true,            // device-owner status it reported (silent OTA)
  "status": "active",                 // active | retired
  "last_seen_at": 1752444000000,      // unix ms of last check-in
  "created_at": 1752440000000,
  "updated_at": 1752444000000
}
```

---

## Endpoints

### Check-in (the device heartbeat) — `POST /devices/checkin` · `devices:write`
The app calls this on launch and every 6 h from its foreground service. Upserts by `device_uuid` (first check-in creates the row); device-reported fields refresh, admin-owned assignment fields are never touched.

```sh
curl -X POST https://worker.chavivim.org/api/v1/devices/checkin \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "device_uuid":"a1b2c3d4e5f60718", "model":"Sonim XP5900",
        "os_version":"11", "version_code":3, "is_device_owner":true }'
```
→ **200**
```jsonc
{
  "device": { … },                 // the device object above
  "manifest": {                    // what this device SHOULD run — null if nothing published on its track
    "version_code": 4,
    "version_name": "1.1.0",
    "track": "stable",
    "sha256": "9f2c…64 hex…",       // verify the download against this
    "size_bytes": 18874368,
    "notes": "History tab + SSE fixes",
    "url": "/api/v1/ota/download/4" // RELATIVE — resolve against your base
  },
  "commands": [                    // pending remote commands, oldest first (execution order)
    { "id": "cmd_…", "kind": "ota_check", "payload": null, "status": "pending" }
  ]
}
```
The manifest resolves the device's **pinned** `assigned_version` when set (and published), else the **highest published `version_code` on its track**. If `manifest.version_code` > the app's `BuildConfig.VERSION_CODE`, the app downloads, verifies SHA-256, installs.

### Register / enroll — `POST /devices` · `devices:write`
Bind a `device_uuid` to a unit/division **before** the device first checks in (or to fix an assignment). Idempotent on `device_uuid`.
```sh
curl -X POST https://worker.chavivim.org/api/v1/devices \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "device_uuid":"a1b2c3d4e5f60718", "unit":"KJ-52", "division":"kj" }'
```
→ **201** `{ "device": { … } }`.

### Read the fleet
- `GET /devices` · `devices:read` → `{ "devices": [ … ] }`, most-recently-seen first.
- `GET /devices/:id` · `devices:read` — `:id` accepts the `dev_…` id **or** a `device_uuid` → `{ "device": { … }, "manifest": { … } }`.

### Assign — `POST /devices/:id/assign` · `devices:write`
Send any subset; explicit `null` clears `unit` / `division` / `assigned_version`.
```sh
# Pin to versionCode 3 on beta + kiosk on:
curl -X POST …/devices/dev_abc123/assign -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "assigned_version":3, "track":"beta", "kiosk_desired":true }'
```
→ **200** `{ "device": { … } }` · `404 not_found`.

> `assign` records **desired state**. To make the device act *now*, also queue a command (below) — otherwise it converges on its next 6-hour check-in.

### Remote commands (the queue)
Kinds: `install` · `ota_check` · `kiosk` · `reassign` · `logout` · `pull_logs`. `payload` is kind-specific JSON (e.g. `{ "version_code": 3 }` for `install`, `{ "enabled": true }` for `kiosk`).

- `POST /devices/:id/commands` · `devices:write` → **201** `{ "command": { … } }`.
- `GET /devices/:id/commands` · `devices:read` — history, newest first; `?status=pending` for the device's poll (oldest first).
- `POST /devices/:id/commands/:cid/report` · `devices:write` — `{ "status":"done"|"error", "detail"? }` → `{ "ok": true }` · **409** if already reported/cancelled.
- `POST /devices/:id/commands/:cid/cancel` · `devices:write` → `{ "ok": true }` · **409**.

```sh
# "Update now":
curl -X POST …/devices/dev_abc123/commands -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -d '{ "kind":"ota_check" }'
```

### OTA versions (publish + rollout)
- `POST /ota/versions` · `devices:write` — register a build the release script already uploaded to `chavivim-apks` (key `apk/<track>/<versionCode>/app-release.apk`). → **201** `{ "version": { … } }` · **409** if `(track, version_code)` already registered.
- `GET /ota/versions` · `ota:read` → `{ "versions": [ … ] }`.

The **rollout head** per track = highest `version_code` with `status:"published"`. Set a bad build's status to `withdrawn` (via `/admin/devices`) and the track auto-rolls back to the previous published version.

### OTA manifest + download
- `GET /ota/latest?track=stable&version_code=3` · `ota:read` — `version_code` (the caller's current build) optional → **200** `{ "manifest": { … } | null, "update_available": bool }`.
- `GET /ota/download/:versionCode` · `ota:read` — streams the APK (`application/vnd.android.package-archive`) from `chavivim-apks`. **Byte-range supported** (`Range: bytes=…` → 206) so a flaky-LTE download resumes. Only `published` versions download. Errors: `404 not_found`, `503 unavailable` (bucket/D1 unbound).

---

## Dispatch backend (FastAPI) — implementation

Your service holds the `devices:* ota:read` key **server-side** and calls this API with any HTTP client. Example with `httpx`:

```python
import httpx

BASE = "https://worker.chavivim.org/api/v1"
TOKEN = os.environ["CHAVIVIM_DEVICES_KEY"]   # cvk_… ; NEVER ship to the browser

class DeviceFleet:
    def __init__(self):
        self._c = httpx.Client(base_url=BASE,
            headers={"Authorization": f"Bearer {TOKEN}"}, timeout=15)

    def register(self, device_uuid: str, unit: str, division: str | None = None):
        r = self._c.post("/devices", json={"device_uuid": device_uuid,
                                           "unit": unit, "division": division})
        r.raise_for_status();  return r.json()["device"]

    def list_fleet(self):
        return self._c.get("/devices").raise_for_status().json()["devices"]

    def assign(self, device_id: str, **fields):        # unit / track / assigned_version / kiosk_desired …
        r = self._c.post(f"/devices/{device_id}/assign", json=fields)
        r.raise_for_status();  return r.json()["device"]

    def push(self, device_id: str, kind: str, payload: dict | None = None):
        r = self._c.post(f"/devices/{device_id}/commands",
                         json={"kind": kind, "payload": payload})
        r.raise_for_status();  return r.json()["command"]

    def update_now(self, device_id: str):             # queue an immediate OTA check
        return self.push(device_id, "ota_check")

    def reassign(self, device_id: str, unit: str):    # desired-state + act-now
        self.assign(device_id, unit=unit)
        return self.push(device_id, "reassign", {"unit": unit})

    def remote_logout(self, device_id: str):
        return self.push(device_id, "logout")

    def publish_version(self, **v):                   # version_code, version_name, track, r2_key, sha256, size_bytes, notes
        r = self._c.post("/ota/versions", json=v)
        r.raise_for_status();  return r.json()["version"]
```

**Notes for your integration**
- **Idempotency:** `register` is idempotent on `device_uuid`; `POST /ota/versions` returns **409** on a duplicate `(track, version_code)` — treat 409 as "already published," not a failure.
- **Desired-state vs act-now:** `assign` sets what the device *should* be; the command queue makes it happen *now*. For reassign/kiosk/logout, do both (see `reassign` above).
- **Don't proxy the APK through the browser:** `/ota/download` is for the device (and your release tooling), authenticated with the key server-side.
- **Identity stays separate:** which member owns a unit is the dispatch app's concern; this API only binds a `device_uuid` to a `unit` string for the fleet view.
