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.
- 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. - Download the new
license.jsonfrom the portal's Licenses page and copy it to the host unchanged. - Install it with one of the methods below, from the install directory.
- 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 (
--yesskips the question); - refuses a file that is already in GRACE (expired, inside its grace window)
unless you pass
--allow-grace, the same rule asinstall.sh;huitzo license verifyandinstallprint a grace warning for such a file; - keeps the current file as
license.json.prevand 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--restartruns it; - waits for the deployment to report the new expiry, and restores
license.json.previf 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].