How licensing works
A bugb-server licence is a short Ed25519-signed token carrying claims: which product it is for, who it was issued to, the tier, the seat count, and an expiry. The server checks that signature itself.
Two properties matter more than the rest, and they sound like they contradict each other. They do not.
Verification never leaves your network
Section titled “Verification never leaves your network”The server verifies a licence offline. It does not ask a Bugb service whether your licence is valid, and there is no revocation list to reach.
That is deliberate, and it is why self-hosting is worth doing: a licence check that phones home turns a Bugb outage into your outage. A deployment that already holds a valid token keeps working whatever happens to us.
The key is in the image
Section titled “The key is in the image”A published bugb-server verifies against a key stamped into the image at build
time. It reads no environment variable to change it: BUGB_LICENSE_PUBLIC_KEY
is ignored on a published image, and the server logs one line saying so.
That is a deliberate change from how it used to work, and the reasoning is worth knowing because it decides what a rotation costs. When the key was configuration, there were two doors in the same wall: leave the variable unset and every route was open, or set it to a key you generated yourself and sign your own licence for as many seats as you liked. Paying was therefore a downgrade. Refusing to boot without a key closes only the first door. Pinning the key into the artifact closes both.
What it costs: a key rotation is now an image release. The mechanism is a
keyring, a JSON object mapping key id to key, so two generations verify for the
length of an overlap and the token’s kid selects between them. But the new
generation arrives in a new image, not a config push.
A published server holds a licence or serves nothing
Section titled “A published server holds a licence or serves nothing”With no licence, or one that is malformed, or one signed by somebody who is not
us, a published bugb-server starts, says why in its log, and answers 402 to
every request until it holds one. Not a warning and not a reduced feature set:
the API, the dashboard at /app, and the token-minting script inside the
container all refuse. /openapi.json is the single exception, kept open so a
liveness probe reads “unlicensed” rather than “dead” and nothing restart-loops
over an invoice.
A lapsed licence is a different thing. An expired licence is still a licence: it verifies, reads keep working, the grace window still applies, and only writes refuse. Everything below this line is about that case.
A licence renews itself
Section titled “A licence renews itself”A licence is valid for 30 days. A server configured with a deployment key fetches a fresh one every day, and is handed a new 30 day term for as long as the subscription is live.
Nobody pastes anything at renewal. There is no expiry to diarise and no button for a human to press, and no restart: a refreshed licence is adopted as it arrives and takes effect on the next request.
What that buys, and what it costs:
- A renewal is invisible. The subscription renews, the next daily fetch returns a fresh 30 days, and nothing about the deployment changes.
- A cancellation ends the licence on its own. Refresh stops returning tokens, the held one runs out, and writes stop about 37 days later: 30 days of term plus the grace window.
- An outage is survivable. Fetches fail open. The server keeps the token it has and retries, and every successful fetch leaves most of the term in hand, so the portal can be unreachable for weeks before anything is felt.
The grace window is a quarter of the term, capped at 14 days. A 30 day licence keeps writing for roughly a week past its expiry; an annual one gets the full fortnight. The cap is the verifier’s policy and is not in the token, so changing it never means reissuing anything.
Four states, and what each one does
Section titled “Four states, and what each one does”GET /v1/license is the diagnostic, and it is never gated, because the route
that answers “why am I getting a 402?” must not answer with another one.
state |
active |
Writes | Reads |
|---|---|---|---|
valid |
true |
accepted | 200 |
grace |
true |
accepted | 200 |
expired |
false |
402 |
200 |
null, with a licence that did not verify |
false |
402 |
200 on a dev build; a published image refuses everything |
Inside its grace window a licence is still working:
{ "mode": "licensed", "product": "bugb", "schema_version": 1, "tier": "team", "state": "grace", "active": true, "expires_at": "2026-09-06T19:23:31+00:00", "seats": 25, "seats_used": 2, "features": [], "repo_limit": null}Past it, the same route says so and the write path refuses:
{ "state": "expired", "active": false, "expires_at": "2026-06-03T00:00:00+00:00",Truncated to the three fields that changed.
{"detail":"license expired: acme's team plan expired at 2026-06-03T00:00:00+00:00 and the grace window ended at 2026-06-17T00:00:00+00:00"}HTTP 402The same server, on the read path, at the same moment:
GET /v1/repos -> HTTP 200Reads never lapse
Section titled “Reads never lapse”A lapsed customer keeps every GET, and the dashboard with it. Their findings
are theirs: the event log is an audit trail they may be legally required to
produce, and holding it hostage over a renewal is not a business worth being in.
Billing pressure lands on the write path, where it costs new data rather than
history.
402 and not 403 on purpose. The caller’s token is fine and their role is
fine. What is wrong is a commercial fact about the deployment, so the person who
can fix it is not the person holding the token. A 403 would send them to the
wrong colleague.
Seats are counted at mint time
Section titled “Seats are counted at mint time”A seat is a distinct identity among a tenant’s non-revoked tokens, and
seats_used reports that count beside the seats the licence grants. The cap
is enforced when a credential is minted and nowhere else, so a token that
already exists keeps working: a customer over their cap finds out when somebody
asks for a new credential and is at a keyboard expecting an answer, not as an
outage in a credential CI has been using for a month.
Air-gapped deployments are supported, not degraded
Section titled “Air-gapped deployments are supported, not degraded”Leave the deployment key unset and pin a token instead. With no deployment key configured the server builds no HTTP client and makes no outbound request of any kind. It holds no poll task, no thread, and no timer.
That is a supported shape, and the offline guarantee above is why it can be. Licences for those deployments are issued on a longer term through the engagement, because a token that cannot refresh cannot be renewed silently either.
Set the deployment key or a pinned token, never both
Section titled “Set the deployment key or a pinned token, never both”An explicit BUGB_LICENSE_TOKEN wins over anything refresh fetches. A
deployment configured with both runs on the pinned value and ignores every token
it downloads. The server logs the refusal each time it declines, so the
misconfiguration is visible in the log rather than only at the moment the pinned
token expires.
| Configured | Result |
|---|---|
BUGB_DEPLOYMENT_KEY only |
refreshes daily. The normal deployment |
BUGB_LICENSE_TOKEN only |
pinned, never dials out. The air-gapped deployment |
| Both | pinned wins. Refresh does nothing |
| Neither | a published image serves nothing. A source build gates nothing |
Related
Section titled “Related”- License a deployment: issuing a deployment key and putting it where the server reads it.
- The CLI, the server and CI: what the licence is buying.
- Connect the CLI to a server: what a lapse looks like from a developer’s terminal.

