HardenedOS

Device API v1: HardenedOS device ↔︎ HardenedOS infrastructure

This document defines the stable interface between HardenedOS devices in the field and the HardenedOS infrastructure that provisions them. Hosted at *.hardenedos.com (activate.hardenedos.com for activation + manifest, updates.hardenedos.com for OTA, repo.hardenedos.com for APK + branding artifacts).

Both sides MUST conform; neither side MAY break the contract without bumping the major version.

For the reseller-facing API, see partner-api-v1.md.

Versioning

URL-prefix versioned: /v1/. New endpoints and new fields can be added without bumping. Breaking changes require /v2/.

Devices indicate their supported versions in the X-HardenedOS-Versions: 1,2 header on every request. Server returns its versions in X-Server-Versions: 1.

We commit to maintaining /v1/ for at least 24 months after /v2/ ships.

Authentication

A. Activation (one-time, unauthenticated → authenticated)

Used at first boot, before the device has any credentials.

B. Authenticated (JWT bearer)

Authorization: Bearer <device_token>

JWTs are signed by HardenedOS infrastructure. JWKS at https://activate.hardenedos.com/.well-known/jwks.json. Claims:

Claim Type Meaning
sub string device_id (UUID)
rsl string reseller_id
iat int Issued-at, Unix seconds
exp int Expiry — default 90 days
chan enum OTA channel: dev, beta, stable

Tokens reissue on heartbeat if within 7 days of expiry.

If a device fails to refresh (server unreachable for 90 days): device falls back to "last-known-good" mode — it stops fetching manifests, continues running the last-applied configuration, and surfaces a "reconnect" prompt to the user. Device never bricks itself due to lost connectivity.

Endpoints

POST https://activate.hardenedos.com/v1/devices/activate

First-boot pairing. Unauthenticated. Activation code is the credential.

Request:

{
  "activation_code": "ABCD-1234-EFGH-5678",
  "device_serial": "<Pixel device serial>",
  "device_model": "oriole",
  "os_version": "2026050100",
  "device_pubkey": "<Ed25519 public key, base64url>"
}

Response (200):

{
  "device_id": "550e8400-e29b-41d4-a716-446655440000",
  "device_token": "<JWT>",
  "manifest_url": "https://activate.hardenedos.com/v1/devices/{id}/manifest",
  "ota_url": "https://updates.hardenedos.com/v1/{channel}/{model}/",
  "channel": "stable",
  "heartbeat_interval_seconds": 86400
}

Errors:

Code Meaning
400 Invalid activation code format
404 Activation code unknown
409 Activation code already used
410 Activation code expired
429 Rate limited (10 / min per IP, burst 20)

GET https://activate.hardenedos.com/v1/devices/{id}/manifest

What apps + branding + policies to apply. Authenticated.

Response (200):

{
  "manifest_version": 17,
  "issued_at": 1747868400,
  "reseller_id": "rsl_omemoglobal",
  "apps": [
    {
      "package": "ch.threema.app",
      "source": "play_store",
      "license_proof": "B2B-AGREEMENT-2026",
      "install_priority": "required"
    },
    {
      "package": "global.omemo.chat",
      "source": "reseller_repo",
      "url": "https://repo.hardenedos.com/r/rsl_omemoglobal/global.omemo.chat-2.4.0.apk",
      "version_min": "2.4.0",
      "signature_sha256": "a1b2c3...",
      "install_priority": "required"
    }
  ],
  "branding": {
    "bundle_version": 4,
    "bundle_url": "https://repo.hardenedos.com/branding/rsl_omemoglobal/v4.zip",
    "bundle_sha256": "d4e5f6...",
    "manifest": {
      "logo": "logo.png",
      "wallpaper": "wallpaper.jpg",
      "accent_color": "#1A73E8",
      "os_name_override": null,
      "support_url": "https://support.omemo.global"
    }
  },
  "tier": "standard",
  "policies": {
    "screen_lock_required": true,
    "require_biometric_after_seconds": 60,
    "allow_sideloading": false,
    "allow_developer_mode": false,
    "force_installed_apps": [],
    "blocked_apps": [],
    "_forbidden_must_be_false": {
      "audio_record": false,
      "screen_capture_silent": false,
      "keystroke_log": false,
      "comms_intercept": false,
      "browser_history_capture": false,
      "camera_silent_capture": false,
      "location_tracking_hidden": false
    }
  },
  "ota_channel": "stable",
  "home_layout": {
    "version": 3,
    "locked": true,
    "grid": { "rows": 5, "columns": 4 },
    "icon_size": 64,
    "dock_icon_size": 72,
    "pages": [ { "index": 0, "items": [
      { "type": "app", "package": "global.omemo.chat", "row": 0, "column": 0 }
    ] } ]
  }
}

tier is informational. Pre-v0.2 it gated which policy keys a reseller could set (personal / corporate / government); v0.2 collapsed that — every reseller can now set every policy from a single universal capability set. New rows write "standard"; legacy values (personal / corporate / government) on existing rows resolve to the same set. See docs/policy-tiers.md for the full policy schema and the surveillance ceiling that no policy may breach.

manifest_version increments on every change. Manifests are idempotent — re-applying the same version is a no-op.

home_layout (optional; omitted when the reseller hasn't configured one) is the managed-launcher layout spec — the home-screen grid/pages, dock, and app-drawer arrangement. It is presentation, not policy (it is not under policies and is not subject to the surveillance ceiling). It carries its own monotonic version, independent of manifest_version; the managed launcher applies a pushed layout only when its version exceeds the one it last applied, and ignores layouts referencing not-yet-installed packages (reserving the cell until the package arrives). Absent ⇒ the launcher uses its default arrangement. The server light-validates on write (grid bounds, package-name shape, cell overlap, ≤ 32 KB) but otherwise passes the object through verbatim; the launcher owns the full schema. Set it via PATCH /partner/v1/devices/{id}/manifest (see partner-api-v1.md).

source values:

Source Behavior
reseller_repo Silent install from reseller's slice of repo.hardenedos.com (HardenedOS-resigned APK)
play_store Silent install via sandboxed Play Store API
external_link Surface a "Tap to install" UI; no silent install

install_priority:

Priority DPC behavior
required Install on activation; reinstall if user removes
optional Install on activation; respect user removal
recommended Surface to user but don't auto-install

POST https://activate.hardenedos.com/v1/devices/{id}/heartbeat

Periodic device status. Authenticated. Default cadence: every 24 h, jittered.

Request:

{
  "os_version": "2026050100",
  "channel": "stable",
  "uptime_seconds": 42893,
  "battery_pct": 78,
  "battery_charging": false,
  "storage_free_mb": 89234,
  "manifest_version_applied": 17,
  "branding_version_applied": 4,
  "installed_apps": [
    {
      "package": "global.omemo.chat",
      "version_code": 240,
      "version_name": "2.4.0",
      "signing_cert_sha256": "a1b2c3...",
      "is_system": false
    }
  ],

  "data_period_start": "2026-05-11T18:00:00Z",
  "data_period_end":   "2026-05-12T18:00:00Z",
  "bytes_mobile_rx":   123456789,
  "bytes_mobile_tx":   23456789,
  "bytes_wifi_rx":     1023456789,
  "bytes_wifi_tx":     102345678
}

installed_apps is the device's full current package set, reported on each heartbeat. Per app:

field type notes
package string Android package name
version_code int PackageInfo.getLongVersionCode()
version_name string human version, e.g. "2.4.0"
signing_cert_sha256 string lower-case hex SHA-256 of the signing cert; matches the reseller catalog's signing_cert_sha256. Empty ("") for unsigned APKs — which then match no catalog entry
is_system bool ApplicationInfo.FLAG_SYSTEM

The server replaces the device's stored inventory with each non-empty report (upsert reported + prune missing). An empty array (older stub DPCs) is ignored, so a pre-feature build never wipes a populated inventory. Requires QUERY_ALL_PACKAGES on the DPC. The reseller reads it back via GET /partner/v1/devices/{id}/apps (reconciled against policy) — see partner-api-v1.md.

The bottom six fields are optional — a one-shot device-aggregate data-usage sample covering the window since the previous heartbeat. The DPC derives bytes from cumulative TrafficStats counters diffed across heartbeats (no usage-access permission required, so it works on a locked-down GrapheneOS device); wifi = total − mobile. Aggregate only — never per-app, per the surveillance ceiling. period_start equals the previous heartbeat's period_end (the DPC re-anchors a checkpoint in shared prefs each call), so successive samples line up edge-to-edge. The first heartbeat after activation/reboot has no prior checkpoint and omits the sample. If the DPC build doesn't ship the feature, all six fields are omitted and the server treats the heartbeat as having no sample attached.

The reseller can query the resulting aggregates via the partner API's GET /partner/v1/devices/{id}/data-usage and GET /partner/v1/data-usage endpoints (see partner-api-v1.md § Data usage).

Response (200):

{
  "manifest_url": "https://activate.hardenedos.com/v1/devices/{id}/manifest",
  "manifest_version_latest": 17,
  "branding_version_latest": 4,
  "ota_available": false,
  "ota_priority": "normal",
  "device_token_refresh": null,
  "dpc_update": {
    "url": "https://activate.hardenedos.com/dpc.apk",
    "sha256": "4b0029383c63ad95aa78c49e88e420419c12af8c272e5b48161e27dca10f4ac4"
  }
}

Device reapplies manifest if manifest_version_latest > manifest_version_applied. Device refetches branding bundle if branding_version_latest > branding_version_applied.

dpc_update — DPC self-update (optional; present only when the server has a hosted DPC APK, i.e. DPC_APK_PATH is set). Points at the currently hosted DPC build: url (a TLS-pinned host — activate.hardenedos.com/dpc.apk by default, configurable via DPC_UPDATE_URL) and sha256 of its exact bytes. The same value is sent on every heartbeat; the DPC decides whether to act.

Integrity chain the DPC enforces before installing (each link required):

  1. The instruction rides this infra-signed heartbeat response (ECDSA, pinned key) — can't be spoofed on the wire.
  2. Download over the TLS-pinned url, bytes verified against sha256.
  3. The downloaded archive's signing cert must equal the running DPC's (same-signer only; Android enforces this for in-place upgrades too).
  4. The archive's versionCode must be strictly greater than the installed one (no downgrades).

Then it Device-Owner silent-installs over itself; activation state survives the in-place upgrade. A build failing a definitive gate (wrong package / wrong signer / not-newer) is recorded and not re-downloaded until the hosted sha256 changes; an inconclusive check (e.g. a transient cert-read failure) is retried rather than wedged.

url MUST be on the DPC's TLS-pinned host (activate.hardenedos.com). The server validates DPC_UPDATE_URL at startup and the DPC enforces the same allowlist independently — a download from any other host is refused even if a (signed) heartbeat advertises it.

Operator notes:

ota_priority:

Priority Updater behavior
normal Schedule update during next idle / charging window
high Notify user; install on next reboot
critical Surface immediate prompt; for emergency security patches

POST https://activate.hardenedos.com/v1/devices/{id}/esim-result

Authenticated (device JWT; subject must match {id}). The device reports the outcome of an eSIM provision push (from the EuiccManager download result), correlated by the command_id it was delivered as. Updates the server's provision record so the partner panel / admin can show install status. Best-effort — the device fires this after the user finishes (or cancels) the system eSIM flow.

Request:

{ "command_id": "...", "status": "installed", "detail": "" }

statusinstalled | failed | declined | unsupported. detail is an optional short, non-PII string (e.g. an error code). Response (200): {"ok": true}. 400 INVALID_STATUS for a status outside the set. An unknown/expired command_id is a benign no-op.

GET https://activate.hardenedos.com/v1/devices/{id}/ota

OTA artifact metadata. Authenticated. Updater polls daily.

Response (200):

{
  "current_version": "2026050100",
  "channel": "stable",
  "available_versions": [
    {
      "version": "2026060100",
      "type": "incremental",
      "from_version": "2026050100",
      "url": "https://updates.hardenedos.com/v1/stable/oriole/incremental_2026050100_2026060100.zip",
      "size_bytes": 142000000,
      "signature_url": "https://updates.hardenedos.com/v1/stable/oriole/incremental_2026050100_2026060100.zip.asc"
    }
  ]
}

The .asc signature must verify against our pinned AVB key before the Updater applies the artifact.

Branding bundle fetch

Branding bundles are static assets served from repo.hardenedos.com. URL shape: https://repo.hardenedos.com/branding/<reseller_id>/v<version>.zip.

Bundle contents:

The DPC verifies the signature before unpacking. Tampered or unsigned bundles are rejected.

Manifest schema

Formal JSON Schema lives at device-manifest.schema.json (TBD).

Backward-compat rule: schemas only add optional fields in /v1/. Devices parse tolerantly — unknown optional fields are ignored.

Error format

All non-200 responses share this body shape:

{
  "error": {
    "code": "INVALID_ACTIVATION_CODE",
    "message": "Human-readable description.",
    "request_id": "req_2026050800123abc"
  }
}

Error codes are stable across /v1/ and machine-readable.

Code Meaning
INVALID_ACTIVATION_CODE Code format wrong or unknown
ACTIVATION_CODE_USED Code valid but already redeemed
ACTIVATION_CODE_EXPIRED Code valid but past expiry
RESELLER_SUSPENDED Reseller's account is suspended; activation refused
RESELLER_BALANCE_DEPLETED Reseller out of balance; new activations refused
DEVICE_NOT_FOUND device_id does not exist
INVALID_TOKEN JWT expired, revoked, or malformed
RATE_LIMITED Exceeded rate limit; back off and retry
INTERNAL_ERROR Server fault; retry with backoff
MAINTENANCE Scheduled maintenance; retry per Retry-After

Rate limits

Endpoint Limit Burst
POST /v1/devices/activate 10 / min per IP 20
GET /v1/devices/{id}/manifest unlimited (device-token-gated)
POST /v1/devices/{id}/heartbeat unlimited (device-token-gated)
GET /v1/devices/{id}/ota unlimited (device-token-gated)

The activate-endpoint limit is a per-IP token bucket — refills continuously based on wall-clock time, no Retry-After header, clients should back off exponentially from the first 429.

Authenticated device endpoints aren't rate-limited at the HTTP layer today; misbehaving clients are caught at the manifest-policy layer (devices that lose hardware attestation get suspended on next heartbeat). A per-device rate gate may land later if telemetry indicates a need.

Stability guarantees

Future endpoints (placeholders)

Reserved for v1 evolution:

Implementation status