The licensing API
Activate a license, send a heartbeat, and fetch the public keys used to verify a license token.
This is the API of this site — the one that issues and verifies licenses. It is separate from the API of your own instance, and you rarely call it by hand: DockBoard activates and renews itself.
It matters in one situation: you sell or provision DockBoard for other people, and want a machine to come up already licensed with no human step in the middle.
The surfaces
| Prefix | Who calls it | Auth |
|---|---|---|
/api/v1/license/* | DockBoard instances, machine to machine | The license key itself, on a signed request |
/api/admin/* | The back-office, and provisioning integrations | A staff session — or a machine token where a handler was opened to one |
/api/shop/* | Customers, in their account area | A customer session |
This page is about the second one.
Machine tokens
A machine token is a bearer secret with no session, no second factor, and no human at the other end. It goes in a CI variable, a provider’s control panel, a cloud-init script. Mint it in Admin → Tokens, or over the API with a staff session:
curl -X POST https://licenses.example.com/api/admin/machine-tokens \
-H "Authorization: Bearer $STAFF_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "OVH provisioning",
"permissions": [
"customers:view", "customers:manage",
"subscriptions:view", "subscriptions:manage",
"licenses:view", "licenses:manage",
"plans:view", "instances:view", "instances:manage"
]
}'sha256(secret) is stored; no endpoint re-serves it and no support process can recover it.Unknown and denylisted permissions are dropped at mint; if nothing valid survives, the mint is refused rather than producing a weaker token than you asked for. The token routes themselves are session-only, for the same reason key management is: a token that mints tokens outlives its own revocation.
What a machine token can never hold
| Denied | Because |
|---|---|
staff:view staff:manage | Reading or editing staff is how a leaked token mints itself a human SUPERADMIN and stops being a token problem. |
settings:view settings:manage | Signing-key rotation lives here — rotate the key and every license in the field is re-signed by someone else’s. So does token minting. |
payments:manage | Refunds and credit adjustments move real money. payments:view stays grantable — reading a payment history is ordinary provisioning work; moving money is a human act. |
The denylist is enforced in three places, not one: at mint, at resolve, and at the door on every request. A token predating a denylist change gains nothing from it.
What bounds a token
A machine token holds no session to expire and no human to notice it is still there. Three limits stand in for the attention it will never get.
- An allowlist of source addresses
allowedIps— one IP or CIDR range per entry, both families. Present it from anywhere else and it is refused. Matched fail-closed: a call whose source address cannot be determined is refused too, because an implementation that waved those through would silently disable the control everywhere at once. A malformed rule is a400at mint rather than a lockout at 3am, and0.0.0.0/0is refused outright — it reads as a restriction and behaves as none. Leave it empty and the token is reachable from anywhere, which is the default.- An expiry that means something
- Optional in general — a provider’s provisioning credential is meant to outlive a rotation window — but mandatory, and capped at 90 days, for `versions:manage`: that token blesses the release every install auto-updates to, and an immortal one sitting in a CI secret store is the highest-value target on the platform. Prefer GitHub OIDC, which stores no secret at all. Any expiry you do set must be in the future and within 365 days: a past date mints a credential dead on arrival, and
2099-01-01is “never” written so it reads as a rotation policy. - A ceiling on how many exist
- 50 live tokens per install. Revoked and expired rows do not count — they are audit history, and counting them would wedge an install that rotates on schedule, which is exactly the behaviour the 90-day rule above exists to force.
{
"name": "OVH provisioning",
"permissions": ["licenses:manage", "customers:manage"],
"allowedIps": ["203.0.113.0/24", "2001:db8::/32"],
"expiresAt": "2027-02-17T00:00:00.000Z"
}The listing in Admin → Tokens shows each token’s allowlist, and lastUsedIp beside lastUsedAt — “where is this being used from?” is the question you have when you find a token nobody recognises, and it was the one the listing could not answer.
Provisioning: one call, one licensed machine
curl -X POST https://licenses.example.com/api/admin/provisioning \
-H "Authorization: Bearer $DOCKBOARD_MACHINE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "customer@example.com",
"planSlug": "pro",
"seats": 1,
"reference": "order-12345"
}'{
"created": true,
"customer": { "id": "…", "email": "customer@example.com", "created": true },
"subscription": { "id": "…", "planSlug": "pro", "seats": 1, "status": "ACTIVE" },
"license": { "id": "…", "keyId": "lic_…", "expiresAt": "2027-08-17T…" },
"licenseKey": "eyJ…",
"installCommand": "curl -fsSL https://get.dockboard.io/install.sh | sudo DOCKBOARD_LICENSE_KEY='eyJ…' sh"
}Hand installCommand to cloud-init and the customer’s box comes up already licensed — they never see Admin → License. See installing DockBoard for what happens on the receiving end.
| Field | Required | Notes |
|---|---|---|
email | yes | The end customer. An existing account is reused, never modified — not renamed, not re-passworded, not re-roled. |
planSlug | yes | By slug (pro), not id — a slug survives a database restore where an id does not. An inactive plan is refused. |
seats | no | Default 1. |
billingCycle | no | MONTHLY or YEARLY. |
status | no | Default `ACTIVE`, not TRIALING — a provisioning call is made because the customer has already paid the provider. |
currentPeriodEnd | no | ISO-8601. The license expires with it; absent, the default of one year applies. |
maxActivations | no | Defaults to the plan’s cap, raised to the subscription’s previous cap if higher, never lowered. |
reference | no | Idempotency key. See below. |
`reference` is the whole design
The three steps this call composes — account, subscription, license — are each available on their own. They are fused here because the interesting part is not any one of them; it is that the sequence must be safe to retry.
A webhook that redelivers. A pipeline step that re-runs. A control panel where someone clicks twice. Without a key to recognise the repeat by, each of those bills the customer for a second subscription and mints a second license — and because issuing revokes the previous active one, the customer’s running install is then silently on a revoked key.
- The replay check runs first, before anything is created, so a retry is a pure read.
- It returns the subscription’s current active license, not the one that call originally minted — a plan change or a renewal supersedes the old key, and a provider re-reading their reference must get the key that works today.
created: falsein the response says a replay happened, and the audit trail records it as a replay rather than a provision.
400. Re-provisioning it would resurrect access someone deliberately withdrew.What a token can reach
| Area | Base path | Permissions |
|---|---|---|
| Provisioning | POST /api/admin/provisioning | licenses:manage |
| Customers | /api/admin/customers — list, read, create, set status, reset password | customers:view / customers:manage |
| Subscriptions | /api/admin/subscriptions — list, read, create, update, change preview, cancel, payment history | subscriptions:*, payments:view |
| Licenses | /api/admin/licenses — list, read, read the key, issue, revoke, suspend, resume, extend, reissue | licenses:view / licenses:manage |
| Instances | /api/admin/instances — list, read, deactivate, reactivate | instances:view / instances:manage |
| Plans and features | /api/admin/plans, /api/admin/features | plans:*, features:* |
Two entries deserve a note. GET /api/admin/licenses/:id/key returns the signed license token itself, and is open to machine tokens on purpose — a provisioning call that cannot read back the key it just issued cannot install anything. Every read is audited, for a session and a token alike, which is the control that makes it safe. And POST /api/admin/instances/:id/deactivate is what frees the activation slot a rebuilt VM still holds: without it, a provider that recycles machines has customers whose next install is refused for a server that no longer exists.
Anything outside that table — staff, settings, signing keys, refunds — answers a token with:
{
"statusCode": 403,
"message": "This endpoint is not available to machine tokens. Use a staff session."
}Errors
| Status | Means |
|---|---|
401 “Invalid or expired machine token” | Unknown, revoked, or expired. One message for all three — the differences are exactly what a prober would use to map the token space. |
403 “not available to machine tokens” | The handler is session-only. |
403 “may never hold X” | The handler’s permission is on the denylist. |
400 “Plan … is not active” | Selling a withdrawn plan through a provider integration is how a retired price list stays alive for years. |
400 “Reference … has no active license” | The replay target was cancelled. Re-activate the subscription, or provision with a new reference. |
Operating a machine token follows the same rules as operating an API key: rotate by overlap, treat lastUsedAt as a liveness signal rather than an access log, and on a leak revoke first and read the audit trail second.