Wheelhouse docs

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>
PartWhat
WHL1The 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#

FieldMeaning
vFormat version. Must be 1.
idThe licence's own identifier. Must be non-empty.
cust, email, nameWho holds it.
planThe plan name.
featuresThe feature names this plan carries. Today the agent checks exactly one: fleet.
routersHow many installations the key covers.
iatIssued at, Unix seconds.
expExpires at, Unix seconds. Must be non-zero.
graceSeconds after exp during which the write plane stays open.
issThe issuer.

The public keys#

Compiled into the binary as kid:base64, comma-separated, and overridable at build time:

bash
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 agent

The states#

GET /api/license reports one of seven. Two of them keep the write plane open even though something is wrong.

statelicensedMeans
unlicensedfalseNo key is held.
validtrueVerified, and now is before exp.
gracetruePast exp, before exp + grace. The error field says when changes stop.
expiredfalsePast exp + grace.
revokedfalseThe licence server answered 403. The token may still verify; the entitlement is gone.
invalidfalseThe stored key does not parse, does not verify, or names an unknown signing key.
clock-unverifiedtrueThe 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 judged

GET /api/license#

Viewer role. No licence needed, obviously.

json
{
  "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"
}
FieldNotes
featuresAlways an array, never null.
days_leftexp minus now, in whole days. Negative once expired.
errorPresent on grace, expired, revoked, invalid and clock-unverified, with the sentence the UI shows.
serverWhatever --license-server is. Empty means refresh is off.
last_refresh_errorThe last network failure. A definite "no" from the server lands in server_status instead.
fingerprintThis 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#

bash
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#

EndpointPOST <--license-server>/v1/refresh
Sendskey, fingerprint, hostname, version — and nothing else
First attempt20 seconds after start
Thenevery 24 hours; every hour after a failure
Timeout20 seconds
Off--license-server ''
Server answerEffect
200 with a tokenThe new token replaces the stored one and is saved. The server's status and message are recorded.
403Revoked. The write plane closes at once, with the server's reason.
402, 404, 409, 400Recorded 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 allRecorded 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

json
{"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#

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.

Updated 2026-09-02 licence licensing states