License Status and Renewal

License Status and Renewal

A Huitzo Self-Hosted deployment runs on one Ed25519-signed license.json, issued by Huitzo for that deployment's ID. There is no license server, no online validation and no network call in the licensing path: the backend and the workers read the file and verify it on the host. This page covers what an operator does with that file: place it, check its status, renew it and reload it. Requesting a license is on Request a license.

The license file

A license is one JSON file, signed with Ed25519 over the canonical bytes of every field except signature. Its main fields:

Field Type Meaning
customer_id string The licensed customer.
deployment_id string The deployment ID this license is bound to (hzd_ plus 26 characters). A file for another ID keeps the deployment in setup mode.
purpose demo_poc \| pilot \| nfr \| paid Why the license was issued; it caps the term (Purposes and term caps).
tier preview \| licensed The edition (Editions). enterprise is Huitzo's hosted tier and is INVALID on a self-hosted install; any other value makes the file INVALID too.
licensed_packs array of pack-ids, or ["*"] Pack allow-list; ["*"] allows any Huitzo-signed pack.
issued_at ISO-8601 When the license was signed.
not_before ISO-8601 (optional) When the license becomes active. Before it, the deployment stays in setup mode.
expires_at ISO-8601 When it expires (drives VALID → GRACE → INVALID).
grace_days int How long the deployment keeps serving after expires_at: 7 for Demo/POC, 14 otherwise.
max_team_tenants, max_seats_per_tenant, max_packs, monthly_executions, max_schedules, max_runners int ≥ 0 or null The six signed limit claims; null = unlimited. A value of any other type makes the file INVALID.
key_id string Which Huitzo key signed this file. Files signed by the partner portal's online key also carry that key's issuer_certificate.
features list[str] Signed deployment-level entitlements: white_label, publish_public, saas.
signature base64 Ed25519 over the canonical bytes of all other fields.

Every field is inside the signed bytes, so editing any of them, or even reformatting the file, invalidates it. Raising a limit or extending a term always means a newly signed file.

Editions

The tier field is the edition; the purpose decides which one a license gets. The full comparison, with every Preview limit, is in Core editions.

Core Preview Core Licensed
tier preview licensed
Purposes Demo/POC Pilot, NFR, Paid
Limits The six signed Preview limits None
features None: no white_label or publish_public white_label when requested, publish_public
Publishing Private packs and dashboards only Any visibility
Banner "Preview — not for production use" on every Hub screen None
Expiry grace 7 days 14 days

A Preview can only be smaller: a preview file signed with a limit above the Preview value, or carrying a feature, is INVALID (402). Self-hosted Core has no credits, wallets or Stripe billing.

Limits and refusals

Each limit is checked where the count can grow. The effective limit is min(licence ceiling, tenant quota, code default): a tenant quota may lower a licence limit, never raise it (how limits are enforced). Licensed and Hosted carry no limit claims, so none of these checks runs a count there.

Claim (Preview value) Checked at Refusal
max_team_tenants (1) Team-tenant create; a staff kind correction to team 409 license_limit_exceeded
max_team_tenants (1) A tenant.create coupon redeem 400 coupon_invalid (the uniform coupon refusal; the reason is logged)
max_seats_per_tenant (3) Tenant invite create, staff invite create, tenant tenant.join coupon mint, invite accept, register-by-invite 409 license_limit_exceeded
max_seats_per_tenant (3) A tenant.join coupon redeem 400 coupon_invalid
max_packs (3, deployment-wide) POST /api/v1/packs and the admin pack enable 409 license_limit_exceeded
licensed_packs allowlist Pack create and enable, for a pack not on the list 403 (an authorisation refusal, not a count)
publish_public (absent) Pack and dashboard create with visibility public, unlisted or organization 403 publish_public_required, details {visibility, allowed: ["private"]}
monthly_executions (500, trailing 30 days) REST command execute, MCP tools/call, pipeline execute 409 license_limit_exceeded
monthly_executions (500) Worker scheduled dispatch No HTTP response: the run is skipped and logged as scheduled_dispatch_license_skipped with reason, license_claim, license_limit and license_current
max_schedules (2) Schedule create and re-enable (/schedules and Studio /studio/schedules), a pack publish that adds crons to an enabled pack, and the admin enable of a pack that declares crons 409 license_limit_exceeded
max_runners (1) Runner registration and the admin re-enable of a disabled runner 409 license_limit_exceeded

Shared refusals:

Code When
409 license_limit_exceeded The licence ceiling is the binding limit. details {key, limit, current, attempted_delta, scope}. REST and MCP return the same body.
409 tenant_quota_exceeded A tenant quota tighter than the licence refused first.
422 quota_above_license_ceiling A tenant quota update asks for more than the licence allows. details {key, requested, ceiling}.
503 license_count_unavailable A limit applies and its usage count could not be read. Nothing ran; retry. The worker logs the error and dispatches anyway.
402 LICENSE_INVALID The file itself is missing, tampered, of an unknown tier or past its grace. Distinct from every 409: a 409 means the file is fine and a limit is reached.

monthly_executions is a soft cap: concurrent runs that pass the check together can overshoot it by a few. A refused request records no execution.

Operating a license file

Where the license lives

install.sh --license license.json checks the file offline and places it at license/license.json in the install directory (/opt/huitzo) (Install). The license/ directory is mounted read-only at /etc/huitzo/license/ into the migrate, backend, worker and beat containers. Keep the file mode 0644 and never edit it.

The workers read it too: while the license is INVALID they run no task (log event task_license_refused), and on a Preview they skip scheduled runs once the monthly_executions limit is reached (log event scheduled_dispatch_license_skipped).

Check status

Where Shows Needs
Dashboard, admin settings, license card State, edition, expiry, deployment ID An admin signed in
GET https://<host>/api/v1/setup/status license.state (valid, grace or invalid), tier, purpose, expiry, grace end, detail, deployment_id, setup_mode Nothing: it answers without sign-in, even while the license gate is stopped
GET https://<host>/api/v1/license/status The above plus edition, licensed_deployment_id, the licensed packs, white_label, and each limit's {limit, current, source} (current only for a tenant admin or owner) A signed-in user's token; sign-in itself is refused (402) once the license is INVALID
huitzo license status The same fields A Huitzo CLI that includes huitzo license install; it falls back to setup/status without a token

The backend logs a warning, and responses carry an X-License-Warning header, while the license is in GRACE.

Renew

A license is renewed by replacing the file with a newly signed one for the same deployment ID; its later expires_at resets the grace clock. There is no auto-renewal and no subscription toggle.

  1. Request the renewal at least 30 days before expires_at, in the partner portal (Request a license). Paid renewals are issued once the client's invoice is paid, and never past its paid-through date.
  2. Download the new license.json from the portal's Licenses page and copy it to the host unchanged.
  3. Install it with one of the methods below, from the install directory.
  4. Confirm that the status shows the new expiry (Check status).

With the Huitzo CLI

# pseudocode: on the host, in the install directory (/opt/huitzo)
huitzo license install license.json

huitzo license install needs a Huitzo CLI that includes the command (check huitzo license --help). It:

  • verifies the file offline with the release's backend image and refuses an INVALID file or one issued for another deployment ID, before writing anything; it asks before installing a file that expires earlier than the current one (--yes skips the question);
  • refuses a file that is already in GRACE (expired, inside its grace window) unless you pass --allow-grace, the same rule as install.sh; huitzo license verify and install print a grace warning for such a file;
  • keeps the current file as license.json.prev and writes the new bytes over the live file in place;
  • activates it: with an admin or owner session on a deployment whose license is VALID or GRACE it calls reload; otherwise it prints docker compose restart backend worker, and --restart runs it;
  • waits for the deployment to report the new expiry, and restores license.json.prev if the deployment reports the new file INVALID.

With install.sh

# pseudocode: on the host
/opt/huitzo/install.sh --license license.json

The installer runs its offline license preflight and refuses an INVALID file, one issued for another deployment ID, or (without --allow-grace) one already in GRACE, leaving the live file unchanged. A usable file is written over license/license.json in place. Then reload, or wait for the hourly re-read.

By hand

On a host without the CLI, write the new bytes over the live file and restart the services that read it:

# pseudocode: on the host, in the install directory (/opt/huitzo)
cp license/license.json license/license.json.prev
cat ~/license.json > license/license.json
docker compose restart backend worker

This skips the offline check: an INVALID file stops the deployment at the restart. If it does, write license.json.prev back the same way and restart.

Reload

The backend and the workers re-read the file at most hourly. To apply a new file immediately without a restart, call reload through the dashboard origin with the token of a tenant admin or owner (other members get 403):

# pseudocode
curl -X POST https://<host>/api/v1/license/reload \
  -H "Authorization: Bearer <admin or owner token>"

The response carries the new state. A reload also tells the workers to re-read the file before they refuse any work. A replacement file that is itself invalid or expired never advances the state. Once the license is INVALID nobody can sign in to get a token, so restart instead: docker compose restart backend worker.

Change edition

Moving from Preview to Licensed (for example, a Demo/POC that becomes a Pilot) is a license replacement: request the new license for the same deployment ID, install it and reload. No data migration is involved. Moving to Licensed lifts every limit and removes the Preview banner (Moving between editions).

State machine

State Condition Behaviour
VALID The signature verifies, the file is for this deployment ID and expires_at is in the future. Full service.
GRACE Expired, within the signed grace window: 7 days on Demo/POC, 14 otherwise. Full service, with a warning.
INVALID Grace exhausted, or the file is unparseable, tampered, signed by an unknown key, of an unknown tier, over its purpose's term cap, or breaks an edition rule. Hard stop: protected routes return 402 LICENSE_INVALID; the workers run nothing.
Setup mode No file yet, a file for another deployment ID, or a file not active yet (not_before). Only health, /api/v1/setup/* and the license routes serve; everything else returns 402.

Expiry is evaluated on every request. A file that is missing or tampered never enters grace. HUITZO_OFFLINE_GRACE_DAYS may lower the signed grace, never raise it. HUITZO_LICENSE_CHECK_INTERVAL sets the re-read interval and is capped at 3600 seconds; a higher value refuses to boot.

Troubleshooting

Symptom Cause Fix
The deployment stays in setup mode after install The file is for another deployment ID, or not active yet Compare deployment_id and licensed_deployment_id on setup/status; request a license for this ID
402 after previously working Grace exhausted Install a renewed file and restart the backend and workers
X-License-Warning header, service keeps running The license is in GRACE Install a renewed file before grace ends
Reload does not take effect The new file is itself invalid or expired Check the new file's verdict with install.sh --license or huitzo license install before placing it
Reload returns 403 The token is not a tenant admin or owner Use an admin's or owner's token
409 license_limit_exceeded The license is valid; a Preview limit is reached (details.key names it) Stay within the limit, or install a Core Licensed file
403 publish_public_required The license lacks publish_public (every Preview) Create the pack or dashboard private, or install a Core Licensed file
No worker task runs The license is INVALID (log event task_license_refused) Install a valid license and restart the backend and workers

For anything else, contact your Huitzo partner contact or [email protected].

See also