Licence keys and states#
A licence key is a signed statement: who holds it, which plan, how many routers, until when. The agent verifies it offline against a public key compiled into the binary, so a router with no route to the internet still knows what it holds. While it can reach the licence server it refreshes once a day and takes whatever expiry the server hands back.
The gate is on the write plane only. Every read, every page, sign-in, the audit log,
/metrics, the admin plane and wheelhouse-agent plan work without a licence, forever.
The key format#
WHL1.<kid>.<payload>.<signature>| Part | What |
|---|---|
WHL1 | The literal prefix. Anything else is that is not a Wheelhouse licence key. |
<kid> | The id of the signing key. The agent looks it up among the public keys compiled into this build. |
<payload> | Base64url, unpadded: the claims, as JSON. |
<signature> | Base64url, unpadded: an Ed25519 signature over the payload bytes. |
Four dot-separated parts, exactly. The signature is checked before the payload is believed.
The claims#
| Field | Meaning |
|---|---|
v | Format version. Must be 1. |
id | The licence's own identifier. Must be non-empty. |
cust, email, name | Who holds it. |
plan | The plan name. |
features | The feature names this plan carries. Today the agent checks exactly one: fleet. |
routers | How many installations the key covers. |
iat | Issued at, Unix seconds. |
exp | Expires at, Unix seconds. Must be non-zero. |
grace | Seconds after exp during which the write plane stays open. |
iss | The issuer. |
The public keys#
Compiled into the binary as kid:base64, comma-separated, and overridable at build time:
go build -ldflags "-X main.licensePublicKeys=<kid>:<base64>" .To rotate, ship the new key beside the old one and retire the old one on the server. An empty value means no key can verify, so every licence is refused — a build without keys is a build that cannot be licensed, not one licensed for free.
A key signed by an id this build does not know is refused by name:
the licence was signed by a key this build does not know (a1b2c3d4); upgrade the agentThe states#
GET /api/license reports one of seven. Two of them keep the write plane open even
though something is wrong.
state | licensed | Means |
|---|---|---|
unlicensed | false | No key is held. |
valid | true | Verified, and now is before exp. |
grace | true | Past exp, before exp + grace. The error field says when changes stop. |
expired | false | Past exp + grace. |
revoked | false | The licence server answered 403. The token may still verify; the entitlement is gone. |
invalid | false | The stored key does not parse, does not verify, or names an unknown signing key. |
clock-unverified | true | The router's clock reads earlier than the licence was issued. |
clock-unverified is the interesting one. A clock that has jumped backwards cannot be
trusted to judge expiry either, and a forward jump from a hostile or broken time source
would otherwise close the write plane — behind which set service ntp also sits, so the
fix would be locked behind the fault. The agent stays open and says why:
this router's clock reads before the licence was issued; set the time (System → NTP)
so expiry can be judgedGET /api/license#
Viewer role. No licence needed, obviously.
{
"state": "valid",
"licensed": true,
"id": "lic_…",
"plan": "pro",
"email": "…",
"name": "…",
"routers": 5,
"features": ["fleet"],
"expires_at": "2027-01-01T00:00:00Z",
"grace_until": "2027-01-08T00:00:00Z",
"days_left": 121,
"server": "https://license.rhymelikedi.me",
"last_refresh": "2026-09-02T09:00:00Z",
"last_refresh_error": "",
"server_status": "active",
"server_message": "",
"fingerprint": "9c1e4b77a2d0f3e5"
}| Field | Notes |
|---|---|
features | Always an array, never null. |
days_left | exp minus now, in whole days. Negative once expired. |
error | Present on grace, expired, revoked, invalid and clock-unverified, with the sentence the UI shows. |
server | Whatever --license-server is. Empty means refresh is off. |
last_refresh_error | The last network failure. A definite "no" from the server lands in server_status instead. |
fingerprint | This installation's identity: the first 8 bytes of SHA-256(machine-id + "\|wheelhouse"), hex. Falls back to the host name when /etc/machine-id is empty. Stable across restarts, not across reinstalls. |
Entering a key#
curl -sk -X PUT "$R/api/admin/license" -H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' -d '{"key":"WHL1.…"}'Admin role. The key is verified before it is stored — a key that does not parse is a
400 with the reason — then saved into state.json, then refreshed against the server
immediately so the page can show the answer. DELETE on the same path removes it and
the write plane closes at once.
The other way in is the command line: --license-key-file /config/wheelhouse/license-key,
which the shipped systemd unit already names. A key given by flag is also saved into
the state file, so the next start without the flag still holds it.
The daily refresh#
| Endpoint | POST <--license-server>/v1/refresh |
| Sends | key, fingerprint, hostname, version — and nothing else |
| First attempt | 20 seconds after start |
| Then | every 24 hours; every hour after a failure |
| Timeout | 20 seconds |
| Off | --license-server '' |
| Server answer | Effect |
|---|---|
200 with a token | The new token replaces the stored one and is saved. The server's status and message are recorded. |
403 | Revoked. The write plane closes at once, with the server's reason. |
402, 404, 409, 400 | Recorded as the server's status and message; the current token keeps working until its own expiry plus grace. |
| Any other code, or no answer at all | Recorded as a refresh error. Nothing changes. The current token stays good. |
A network failure never costs a router its licence. That is the whole point of verifying offline.
What closes without a licence#
Every route wrapped in licensed(...) answers 402:
/api/stage, /api/stage/remove, /api/commit, /api/commit/confirm, /api/discard,
/api/configure, /api/rollback, /api/config/save, /api/config/load,
/api/reconcile, /api/capture, the seven /api/apps/* write routes, and
DELETE /api/apps/image. The reconcile loop pauses and says so in GET /api/drift.
wheelhouse-agent apply refuses; plan does not.
What stays open: every GET, sign-in and sign-out, password and two-factor changes,
sessions, the audit log, /metrics, users, tokens, settings, power, boot images, and the
licence endpoints themselves. POST /api/diagnostics/traceroute and
POST /api/ids/ips/plan also stay open — they are operator routes that change no
configuration.
Features#
Only one feature name is checked anywhere in the agent: fleet. It gates
GET /api/fleet/{id}/config, GET /api/fleet/{id}/version and
POST /api/fleet/{id}/configure, which answer
{"error": "the fleet feature is not part of this licence's plan", "license": "feature"}GET /api/fleet itself never 402s — it answers enabled: false with a note, so the page
can explain rather than fail.
The router limit#
routers in the claims is the number of installations the key covers, counted by the
licence server against the fingerprint each one sends.
Prices#
<TODO: owner> — the prices are not set. The plan structure exists and the mechanism above is built and tested; the monthly figures for Pro and Business are not decided, and this documentation will not invent, imply or anchor one.
See also#
- Status codes — the
402body and itslicensefield. - Role matrix — which routes carry the licence gate.
- Every flag —
--license-key-file,--license-server. - Files and directories —
license-key, and the state file the UI writes into. - Endpoint index —
/api/license,/api/admin/license. - The licence gate
- Enter a licence
- Licence — the screen.
Checked against#
agent/license.go (parseLicense, licenseClaims,
licenseKeys, licensePublicKeys, licenseState.view, adopt, refresh,
runLicenseRefresher, licenseFingerprint, requireLicense, requireFeature,
handleLicenseGet, handleLicenseSet, handleLicenseDelete, loadLicenseAtStart),
agent/main.go (routes),
agent/license_test.go,
agent/desired.go (reconciler.pass, runPlanApply),
PRICING.md,
docs/deploy.md "Licensing",
docs/privacy.md,
docs/adr/003-licensing.md.