Menu

Developer documentation

Licensing a Signet instance

/docs/licensing

How to issue a Signet licence and get an instance to accept it, end to end.

Signet is proprietary. Entitlement is enforced by Warrant, the platform's licensing service — not by anything Signet-specific. Signet's only job is to verify a signed token it is handed.

  • Warrant service: https://warrant.kapable.ai — prod base path is /warrant (the bare /v1/... path returns an HTML error page; that is the single most common wrong turn).
  • Seller console: https://warrant.kapable.ai/warrant/ — create products, edit plans, issue licences, inspect activations.
  • Signet's product: org kapable, product slug signet, fulfillment_type: signed_license.

The model in one paragraph

A product has features (typed gates: boolean / limit / config) bundled into plans. A licence is issued against a product + plan, optionally with an expiry and per-licence overrides. Activating a licence returns a signed token; the customer's binary verifies that token offline and reads its entitlements. Effective features resolve as feature defaults ◀ plan values ◀ licence overrides.

Hosted plan capability gates are not licence tiers

Every refusal names the configured plan, the upgrade plan, and https://signetauth.com/pricing; reaching the agent-token allowance also says that expiry or revocation frees a slot.

This gate is independent of the Warrant licence token. Signet never derives these capabilities from a licence Tier; removing [plan] restores the permissive engine defaults without changing routes. Existing service tokens remain listable, introspectable, and revocable after a downgrade, and user API keys, organisations, invitations, and all people sign-in methods are untouched. Hobby session lifetime is likewise provisioner policy through the existing [session] expires_in and update_age keys; the plan gate adds no second session-duration knob.

[plan]
name = "hobby"
sso = false
service_tokens = false
agent_tokens_max_active = 5
agent_refresh_tokens = false
upgrade_plan = "Team"

Signet's gates today: mau_included (limit) · instances (limit) · tier_kind (config) · on_prem · airgap · sso_saml (boolean — ⚠ the name under-describes since 2026-08-08: the capability it gates is enterprise SSO over BOTH transports, SAML and OIDC). Plans: free ($0), pro ($1900 usd), on-prem-enterprise (non-public — that tier is a sales conversation).

Verification is OFFLINE — and that is load-bearing

The token is warrant.v1.<base64url(claims)>.<base64url(ed25519_sig)>, claims {lid, org, product, plan, fp, features, iat, exp}. Signet verifies the Ed25519 signature against an issuer public key baked into the binary at build time, then compares exp to the clock. Signet never contacts a licence server. An air-gapped instance verifies exactly as a connected one does — which is the whole point, since air-gap capability is what we sell.

The signature covers the base64 TEXT, not the decoded JSON. The signing input is warrant.v1.<claims_b64> — the token minus its last segment. A verifier that re-encodes the claims before checking will break silently.

The issuer key is baked at build time, never read from config. A trusted key a customer could edit is a two-line licence forgery. The private half remains in the issuer's secret store and must never enter the product source. Override for a non-Kapable issuer with SIGNET_LICENSE_ISSUER_PEM (text) or SIGNET_LICENSE_ISSUER_PEM_FILE (path) at build time.

Issuing a licence

Management calls need a wko_ key, org-scoped, sent as x-api-key. Kapable's lives in the vault:

kapable-ops vault inject warrant-kapable -- bash -c '
  curl -s https://warrant.kapable.ai/warrant/v1/products/signet/plans \
    -H "x-api-key: $WARRANT_KAPABLE_API_KEY"'

Mint a licence against a plan:

kapable-ops vault inject warrant-kapable -- bash -c '
  curl -s -X POST https://warrant.kapable.ai/warrant/v1/products/signet/licenses \
    -H "x-api-key: $WARRANT_KAPABLE_API_KEY" -H "Content-Type: application/json" \
    -d "{\"plan_slug\":\"on-prem-enterprise\",\"customer_name\":\"Acme\",\"customer_email\":\"ops@acme.example\"}"'

The response carries a secret licence key (lic_…) shown once. Give that to the customer. Lifecycle verbs: /v1/licenses/{id}/revoke, /suspend, /reactivate.

The field is key, not license_key — and the licence id comes back as license_id, not id. Capture both from the create response in the same breath as the call:

id=$(printf '%s' "$resp" | jq -r .license_id)
key=$(printf '%s' "$resp" | jq -r .key)      # ⚠ .license_key is ALWAYS null — it is not a field

Written down because guessing .license_key yields null rather than an error, so the call looks like it succeeded, the licence really is minted and active, and the only copy of its key is gone. The sole remedy is to revoke the licence and mint another (2026-08-14: two licences burned this way). The response also carries key_prefix and the reminder "Save key now — it is shown only once." — neither of which helps once the body is discarded.

Activating, and what the customer does

The customer exchanges the lic_… key for a signed token, binding it to their machine:

curl -s -X POST https://warrant.kapable.ai/warrant/v1/activate \
  -H 'Content-Type: application/json' \
  -d '{"license_key":"lic_…","fingerprint":"<stable machine id>","hostname":"<host>"}'

The response's signed_token is what Signet consumes. Configure it out-of-band so it never lands in a file in a repo:

[license]
token   = "env:SIGNET_LICENSE_TOKEN"   # or file:/etc/signet/licence.token, or a literal
enforce = false                        # true = refuse to boot unless the licence is valid

SIGNET_LICENSE_TOKEN also works on its own with no [license] section.

Confirm before trusting it — this runs the same gate the server runs:

signet --check

Enforcement: off by default, on purpose

enforce defaults false. An absent, expired, or unverifiable licence logs one actionable warning and serves anyway:

WARN signet::license: licence unlicensed: no licence token is configured: set [license] token …
     (this instance is serving anyway because [license] enforce is false)

That default exists so a licensing change can never brick a running deployment — the live instance at auth.kapable.kapable.run runs with no [license] section at all. Flip enforce = true only after signet --check passes on that instance.

⚠ One case is fatal regardless of enforce: a dangling ref, e.g. token = "env:MISSING". A deliberate licence config that cannot resolve fails loudly rather than silently degrading to "unlicensed" — consistent with admin.key and database.dsn.

Where to look when it does not work

SymptomCause
unlicensedNo token configured. Expected on an unlicensed instance.
invalidSignature did not verify — wrong issuer key baked in, or a tampered token. Baking a key buys verification, not blanket trust: a token from another issuer is correctly rejected.
expiredexp is in the past. Amend or reissue the licence in Warrant.
Warrant returns HTMLMissing the /warrant base path in prod.
Management call 401Wrong credential class — Warrant wants a wko_ org key, not kapable-auth's ADMIN_API_KEY.

Licence state is shown on the admin console's Instance Receipt at /admin (platform-credential gated). It is deliberately absent from the public /certification surface.

What the licence key ACTUALLY encodes (answered 2026-08-08)

Recorded because the value looks like opaque base64 wherever it is stored, and nobody outside this crate could say what was inside it. It is not opaque, and it is not a blob:

warrant.v1.<base64url-nopad(claims JSON)>.<base64url-nopad(Ed25519 signature)>

Claims: lid (licence id) · org (licensee organisation UUID) · product · plan · fp (machine fingerprint; empty = unbound) · features (a map of boolean/number/string gates) · iat · exp (absent = perpetual).

Three facts a reader usually assumes wrongly, so they are stated here:

  1. There is no human-readable licensee NAME in the token. org is a UUID. Anything that needs to render "Acme Corporation" must source it elsewhere — today the only in-binary source is the watermark's customer slug, which is per-buyer and irreversible.
  2. The features map is parsed and never enforced. The accessors exist and are tested, but no product code reads them; the gate names that appear in issuing tools are issuer-side definitions this engine never consults. Treat every feature gate as descriptive until that changes.
  3. The machine fingerprint is carried and never checked. Nothing compares fp to the host. Activation caps bind at issue time, online, only — an activated token copied to a second machine verifies there identically. Per the standing ruling, do not tell a prospect the licence limits how many machines they run.

Signature trust. The issuer public key is BAKED IN at build time, never read from config — a config-supplied trust root would make forgery a two-line edit. Note for anyone reimplementing a verifier: the signature covers the base64 text, not the decoded JSON.

Enforcement, in one line: the only enforcement is boot refusal, and it is OFF unless the operator sets enforce = true; otherwise an unlicensed, expired, or invalid licence produces one WARN and the instance serves.

Surfacing: licence state renders in exactly one place, the platform-credential-gated Instance Receipt. It must never appear on an anonymous surface — that absence is test-pinned against served bodies, alongside the same rule for build identity.

Enter to open · Esc to close