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.
- Device generates an Ed25519 keypair on-device, in StrongBox-backed keystore.
- Device POSTs
/v1/devices/activatewith the activation code and the public key. - Server validates the code, identifies the issuing reseller, returns
a long-lived
device_token(JWT) bound to the device public key + reseller. - All subsequent requests include the JWT as a bearer token.
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):
- The instruction rides this infra-signed heartbeat response (ECDSA, pinned key) — can't be spoofed on the wire.
- Download over the TLS-pinned
url, bytes verified againstsha256. - The downloaded archive's signing cert must equal the running DPC's (same-signer only; Android enforces this for in-place upgrades too).
- The archive's
versionCodemust 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:
- Bump
versionCodeon every hosted release, or devices treat it as not-newer and skip it. - No downgrades (security: blocks
rollback-to-vulnerable). Recovery from a bad release is
roll-forward only — host a higher versionCode
with the fix; you can't re-host an older build. So validate a
new APK on a canary device (
adb install -r) before hosting it fleet-wide. - Monitoring: the per-device installed-app inventory
(
GET /partner/v1/devices/{id}/apps) reports the running DPC'sversion_code, and a stalelast_heartbeat_atflags a device that fell silent after an update — use both to watch a rollout. - Self-update only reaches devices already running a DPC build that contains the self-update code; the first such build still ships via adb / re-enrollment. A failed install self-heals: the device's version stays unchanged, so the next heartbeat re-attempts.
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": "" }
status ∈
installed | 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:
manifest.json— describes which assets to apply- Asset files referenced by the manifest (PNG, JPEG)
signature— HardenedOS signature over the bundle (verified by DPC against the infrastructure signing key pinned in HardenedOSBranding RRO)
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
- No removed fields in
/v1/. Fields can be added; old ones remain. - No semantic changes to existing fields. Add new fields and deprecate old in docs.
- Backward compatibility for at least 24 months past
/v2/ship. - Schema validation server-side on writes. Loose parsing on reads.
Future endpoints (placeholders)
Reserved for v1 evolution:
GET /v1/devices/{id}/attestation-challenge— remote attestation.POST /v1/devices/{id}/wipe-request— reseller-initiated remote wipe.GET /v1/devices/{id}/diagnostics-blob— encrypted diagnostics for support.POST /v1/devices/{id}/policy-ack— DPC acknowledges policy applied.