Pulp Engine Document Rendering
Get started

Licence key format — signed-licence-v1

This page documents the wire format of Pulp Engine commercial licence tokens, the env vars operators set, the /health/ready payload that exposes verifier state, and the runbook for diagnosing a non-licensed deployment.

For the design context and what’s deferred, see docs/initiatives/signed-licence-v1.md.

Wire format

{iat_ms}.{exp_ms}.{customerId_b64url}.{claims_b64url_json}.{sig_b64url}

Five base64url segments separated by dots. Total length capped at 4096 chars.

The signed payload (UTF-8 bytes, fed verbatim to crypto.sign(null, ...)):

licence-v1:{iat_ms}:{exp_ms}:{customerId}:{claims_json}

Where:

Segment / fieldFormatNotes
iat_mspositive integer (ms since Unix epoch)matches ^[1-9]\d*$; safe-integer range
exp_mspositive integer (ms since Unix epoch)same; rejected if now > exp_ms
customerId^[A-Za-z0-9._-]{1,64}$base64url-encoded in the segment, raw form in the signed payload
claims_jsonJSON object — v1: { "licenceId": "<id>" }licenceId matches ^[A-Za-z0-9._-]{1,64}$
sigEd25519 signature, base64url-encodedover the signed payload above

v1 claims object — minimum-viable. Future additive fields (edition tier, deployment hints) will not break v1 verifiers because the slot is JSON.

Environment variables

VarRequired?FormatEffect
PULP_LICENCE_KEYnofull 5-part tokenabsence (or empty/whitespace) → evaluation mode (watermark)
PULP_LICENCE_PUBLIC_KEYnoraw 32-byte Ed25519 public key, base64urloverrides the baked public key

The override accepts only the same format the baked constant uses (raw 32-byte JWK x coordinate). PEM/SPKI is not accepted on purpose — one format everywhere reduces surprises during rotation. A malformed override is logged once at startup and falls back to the baked key; the API still boots.

The render workers (RENDER_MODE=child-process, container, or socket) need both env vars forwarded into their isolated environment. The dispatchers do this automatically — no manual plumbing required.

/health/ready payload

Schema: apps/api/src/schemas/shared.ts.

/health/ready adds two licence-related fields to the existing readiness shape:

{
  "status": "ok",
  "version": "0.77.0",
  "timestamp": "2026-04-25T12:34:56.789Z",
  "checks": {
    "storage": "ok",
    "assetBinaryStore": "ok",
    "renderer": "ok",
    "licence": "ok"
  },
  "licence": {
    "kind": "licensed",
    "customerId": "acme-corp",
    "expiresAt": "2027-04-01T00:00:00.000Z",
    "daysUntilExpiry": 341
  }
}

Other states surface as:

  • evaluation mode (no key set):
    { "checks": { "licence": "evaluation" }, "licence": { "kind": "evaluation" } }
  • expired key (or any other verifier failure):
    { "checks": { "licence": "invalid" }, "licence": { "kind": "invalid", "reason": "expired" } }

Possible reason values: malformed, bad-signature, expired, not-yet-valid. Licence state never participates in the 503 calculation — rendering continues to work even when the licence is invalid; the watermark is the enforcement signal. Wire alerts to licence.kind === 'invalid' and licence.daysUntilExpiry < N independently of the readiness HTTP status.

Prometheus alerts

The licence state is also exposed on GET /metrics (the /health/ready JSON body is not Prometheus-scrapable). Two gauges are refreshed on every scrape:

  • pulp_engine_licence_valid1 only for a genuine, verified, unexpired commercial licence; 0 for evaluation or invalid.
  • pulp_engine_licence_days_until_expiry — days remaining when licensed; sentinel -1 in evaluation/invalid state.

Gate the expiry alert on validity so evaluation deployments never page, and alert separately on an invalid licence:

# Licence expiring soon (only fires for a real, valid licence)
pulp_engine_licence_valid == 1 and pulp_engine_licence_days_until_expiry < 14

# Licence outright invalid (expired / bad signature / malformed) — silent
# re-watermarking, so page on it
pulp_engine_licence_valid == 0

(The second query also matches evaluation deployments, which run with valid == 0 by design — scope it to production targets via your scrape labels if evaluation instances share the same Prometheus.)

CLI

Both commands live in packages/cli/src/commands/licence.ts.

  • pulp-engine licence verify <token> — signature-verifies the token against the baked public key (or PULP_LICENCE_PUBLIC_KEY override). Exits 0 on licensed, exits 1 on any failure. Prints a structured JSON result.
  • pulp-engine licence inspect <token> — decodes claims without verifying the signature. Useful for support triage when a customer’s deployment reports kind: 'invalid' and you need to confirm the token’s customerId, licenceId, and expiry without trusting it. Output is prefixed with a non-trust-decision warning.

Operator runbook

A new key fails verification on deployment

  1. Run pulp-engine licence inspect <token> locally — confirm the customerId, licenceId, and that exp is in the future.
  2. Run pulp-engine licence verify <token> against the deployment’s effective public key:
    • If you’ve set PULP_LICENCE_PUBLIC_KEY, export the same value locally before running.
    • Without an override, the CLI verifies against the baked key for the CLI version installed locally — this MUST match the version running in production for the verify result to be meaningful.
  3. Check /health/ready on the deployment — the licence.reason field indicates whether the failure is bad-signature (key mismatch) or expired / not-yet-valid (clock or contract issue).

A licence expires soon

  1. Issue a new token via the operator script scripts/issue-licence.mjs against the same private key.
  2. Replace the value of PULP_LICENCE_KEY in the deployment’s env config.
  3. Restart the API process — getLicenceState() is memoised at startup, so a hot env reload is not enough.

Rotating the public key

See docs/initiatives/signed-licence-v1.md.