> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xpander.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Two-File Licensing

> Air-Gapped installs use two sealed license files. Renewal swaps one of them, with no reinstall.

The install file says who the install is and never expires. The activation file says what it may do, and it is the only file you renew.

![Diagram](https://docs.xpander.ai/images/diagrams/self-hosted-licensing-885d2003.svg)

## The two files

| File | Carries | Read when |
| - | - | - |
| **`install.json`** (`kind: install`) | `license_id`, `org_id`, `edition`, `issued_at` (no expiry) | Helm render and preflight, at install and upgrade |
| **`activation.json`** (`kind: activation`) | expiry, `grace_days`, `max_seats`, `max_agents`, `features` | Continuously at runtime |

The xpander backoffice issues both together, with the same `license_id`, and xpander binds them at runtime. Renewal re-issues only `activation.json`, so a lapsed term never blocks a `helm upgrade`, and your registry mirror and org stay untouched.

> [!NOTE]
>
> This is the **`xpander-license/v2`** format. Older installs with one signed `license.json` (`xpander-license/v1`) keep working through `--license-file`. See [Legacy single-file license](#legacy-single-file-license).

### Sealed and opaque

Each file is a small JSON envelope around one encrypted blob:

```json theme={"dark"}
{ "format": "xpander-license/v2", "kind": "install", "sealed": "<base64>" }
```

The blob is **signed** with an **Ed25519** private key (cannot be edited) and then **sealed** with **libsodium `crypto_box_seal`** (cannot be read). The xpander images carry the keys to open and verify it. Editing the blob invalidates it, and each file is rejected in the other's slot.

Because the installer cannot read the org id, you pass it with `--org`. The backoffice shows it as **ORGANIZATION\_ID**, and services verify the match at boot.

***

## Install with the two files

From a terminal with `kubectl` access to the cluster, run the command the backoffice gives you:

```bash theme={"dark"}
curl -fsSL https://charts.xpander.ai/install.sh | bash -s -- \
  --edition airgap --org <ORG_ID> \
  --install-license-file ./install.json \
  --activation-license-file ./activation.json
```

The installer checks both envelopes (one of each `kind`, not swapped), takes the org from `--org`, and creates the `xpander-license` Secret with both files. All three flags are required. The rest of the install is unchanged: see [Air-gapped deployment](https://docs.xpander.ai/self-hosted/airgap.md).

### Deploying the chart directly (Argo CD / Helm)

With GitOps, Argo CD or Flux, create the Secret yourself:

```bash theme={"dark"}
kubectl -n xpander create secret generic xpander-license \
  --from-file=install.json=./install.json \
  --from-file=activation.json=./activation.json \
  --dry-run=client -o yaml | kubectl -n xpander apply -f -
```

Then set the license values:

```yaml theme={"dark"}
global:
  license:
    secretName: xpander-license
    installKey: install.json        # the sealed install envelope key inside the Secret
    activationKey: activation.json  # the sealed activation envelope key inside the Secret
    publicKey: <license public key> # base64 Ed25519, from the backoffice
```

| Value | Meaning |
| - | - |
| `global.license.secretName` | The Secret with both files (`xpander-license`) |
| `global.license.installKey` | Key of the sealed **install** file (`install.json`) |
| `global.license.activationKey` | Key of the sealed **activation** file (`activation.json`) |
| `global.license.publicKey` | The **license public key** (base64 raw Ed25519) from the backoffice. Feeds both preflight and every service. Empty means the baked cloud key |
| `global.license.sealKey` | The Curve25519 seal **private** key. Baked into the images. Set it **only to rotate** the key with xpander |

> [!NOTE]
>
> Setting `installKey` and `activationKey` selects two-file (`v2`) mode. Leaving both empty falls back to the legacy `global.license.key` (`license.json`). Do not set both modes.

***

## What the license enforces

### Seat and agent caps

`max_seats` and `max_agents` are **enforced**. Unset means unlimited. Creating the **N+1th** user or agent is **refused**, with a message on how to raise the cap ([re-activate](#renewing-re-activate)). **Everything already created keeps working**, even after a smaller activation file is swapped in.

### Expiry and grace

| State | When | Behavior |
| - | - | - |
| **Valid** | More than 14 days before expiry | Normal |
| **Expiring** | Within 14 days of expiry | Normal. A renewal warning is logged. |
| **Grace** | Past expiry, within `grace_days` | **New task creation is blocked**. Running tasks, the UI and data stay up. |
| **Expired** | Past expiry plus `grace_days` | Same as grace |

**Expiry blocks only new tasks.** Nobody is logged out and nothing running stops.

`GET /v1/license/status`, with a signed-in user's bearer token, returns `state` (`valid`, `expiring`, `grace` or `expired`), the deployment and the granted features. Alert on anything other than `valid` or `grace` to catch a missed renewal.

> [!NOTE]
>
> Feature grants (`features`, plus those the Air-gapped deployment implies, such as custom models, BYOK, SSO and custom skills) also come from the activation file. An ungranted feature is refused with a pointer to re-activation.

### Capabilities

The License page's **Install capabilities** card lists each chart-owned capability (voice, text-to-speech, email delivery, actions, code runner, the MCP endpoint) as **Enabled** or **Disabled**, with the Helm value that sets it, for example `global.mcp.publicUrl: https://<your MCP host>`. They are switched in the chart, not on this page.

***

## Renewing: re-activate

Renewal (term, caps or features) re-issues **only the activation file**. Staff use the **Re-activate** action on the license row in the backoffice.

**1. Get the new activation.json**

From your xpander contact. It carries the same `org_id` and `license_id` as your install.

**2. Swap it into the Secret**

Replace only the `activation.json` key:

```bash theme={"dark"}
kubectl -n xpander create secret generic xpander-license \
  --from-file=install.json=./install.json \
  --from-file=activation.json=./activation-new.json \
  --dry-run=client -o yaml | kubectl -n xpander apply -f -
```

**3. Restart xpander**

Roll the services that read the license:

```bash theme={"dark"}
kubectl -n xpander rollout restart deployment/agent-controller deployment/api deployment/billing deployment/deployment-manager
```

The `billing` Deployment exists on chart 0.9.0 and later. Leave it out on an older chart.

Or redeploy: `bash install.sh upgrade`, or your pipeline's re-install with the same values, pins and license flags ([declarative re-install](https://docs.xpander.ai/self-hosted/airgap.md#declarative-re-install-for-ci)). A checksum annotation on the Secret rolls the services.

> [!WARNING]
>
> **Passing your own `--values` to the installer?** Your values file displaces the `global.license` block the installer writes. Add `--set global.license.installKey=install.json --set global.license.activationKey=activation.json --set global.license.publicKey=<license public key>` to every install, re-install and renewal command that carries `--values`.

> [!NOTE]
>
> The new activation file must name the **same org and license\_id** as the install file, or xpander refuses it.

***

## Staff: issuing a license

In the xpander-app admin area (staff-only), **Issue a license** gives one handoff view: **`install.json`** and **`activation.json`**, the **install command** prefilled with the org id, **ORGANIZATION\_ID** for `--org`, and the **License public key** for `global.license.publicKey`. **Re-activate** later issues a fresh `activation.json` only (same org, same `license_id`, new expiry / caps / features).

***

## Legacy single-file license

Older installs use one signed `license.json` (`xpander-license/v1`), installed with `--license-file ./license.json` and wired through `global.license.key`. It still works: see [The License](https://docs.xpander.ai/self-hosted/airgap.md#the-license). Whether `installKey` and `activationKey` are set selects the mode.

***

## Next steps

> **[Install xpander Air-Gapped](https://docs.xpander.ai/self-hosted/airgap.md)**
>
> The full install flow.

> **[Review the Air-Gapped security architecture](https://docs.xpander.ai/resources/security-compliance/air-gapped-deployments.md)**
>
> Zero egress, signed license, local sign-in.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.