> ## 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

> How an air-gapped install is licensed with two sealed files, install.json and activation.json, what each one gates, how seat and agent caps and expiry are enforced, and how renewal works without a reinstall

## Overview

An Air-Gapped install is licensed with **two sealed files**, not one:

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

Both files are issued together from the xpander backoffice, both name the same `license_id`, and the platform binds them to each other at runtime. The split exists so that **renewal never touches the install file**: to extend a term, raise a cap, or add a feature, xpander re-issues only a fresh `activation.json`. You swap it in and restart. The install file, your registry mirror, and everything the org seed created stay exactly as they are.

<Note>
  This is the **`xpander-license/v2`** format. Installs issued under the older single signed `license.json` (`xpander-license/v1`) keep working through `--license-file` (see [Legacy single-file license](#legacy-single-file-license)). New air-gap installs use the two-file flow described here.
</Note>

### Why two files

* **Renewal is cheap and low-risk.** A term extension or a cap bump re-issues one small file. There is no reinstall, no re-mirror, no change to the org identity, and no risk of disturbing a running platform beyond a restart.
* **Identity is separated from entitlement.** `install.json` answers who this install is (the org, the edition) and never expires, so a lapsed term can never wedge a `helm upgrade` or block a config change. `activation.json` answers what the install is entitled to right now (term, seats, agents, features) and is the only file that ages.
* **Both are opaque to the customer** (see below).

### Sealed and opaque

Both files are **sealed**: encrypted and unreadable by the customer, by design. Each file on disk is a small JSON envelope wrapping one opaque blob:

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

The `sealed` blob is produced by **sign-then-seal**: xpander signs the license payload with an **Ed25519** private key (so the contents cannot be forged or edited), then seals that signed payload with **libsodium `crypto_box_seal`** (so the contents cannot be read). The platform images carry the seal key needed to open the blob and the Ed25519 public key needed to verify the signature. A customer sees only `format`, `kind`, and base64. Editing the `sealed` blob invalidates it, and the two `kind` values are not interchangeable (the installer and the runtime each reject a file presented in the wrong slot).

Because the files are opaque, the installer cannot read the org id out of them. You pass it explicitly with `--org` (the installer prints a clear error if you omit it), and the consuming services verify at boot that the bound org id matches. The org id is shown in the backoffice at issuance as **ORGANIZATION\_ID**.

***

## Installing with the two files

The backoffice hands you both files plus a ready-to-run command. From a terminal with `kubectl` access to the target cluster:

```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:

1. Validates both envelopes structurally (each is `xpander-license/v2`, one is `kind: install` and the other `kind: activation`). It refuses two of the same kind, or the files swapped between flags.
2. Takes the org binding from `--org`, since the sealed files hide it.
3. Creates the `xpander-license` Secret with **both** files as keys (`install.json` and `activation.json`), and wires the chart values that point at them.

Everything else about the air-gap install is unchanged: registry mirroring, the data-tier choice, the hook pipeline, sign-in. See [Air-Gapped Edition](/self-hosted/airgap) for the full install flow.

<Note>
  Both flags are required together. Passing only one exits with a usage error naming the missing flag. `--org` is required too, because the sealed files are opaque and the installer cannot read the org id from them.
</Note>

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

If you drive Helm yourself instead of the installer (GitOps, Argo CD, Flux), create the Secret with both keys and point the chart at them.

Create the `xpander-license` Secret carrying both sealed files:

```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`    | Name of the Secret holding the two sealed files (`xpander-license`)                                                                                                                                                                                   |
| `global.license.installKey`    | Filename of the sealed **install** envelope inside the Secret (`install.json`)                                                                                                                                                                        |
| `global.license.activationKey` | Filename of the sealed **activation** envelope inside the Secret (`activation.json`)                                                                                                                                                                  |
| `global.license.publicKey`     | The **license public key** (base64 raw Ed25519) shown in the backoffice at issuance. One value feeds both the preflight signature check and every consuming service, so the Secret and the pubkey cannot drift apart. Empty means the baked cloud key |
| `global.license.sealKey`       | The Curve25519 seal **private** key (base64) that opens the sealed envelopes. The images carry a baked default; set this **only to rotate** the seal key                                                                                              |

<Note>
  Setting `installKey` and `activationKey` selects the two-file (`v2`) mode. Leaving both empty falls back to the legacy single-file `global.license.key` (`license.json`). Do not set both modes at once.
</Note>

<Warning>
  The **seal private key is baked into the platform images** and does not need to be supplied. Only set `global.license.sealKey` when you are deliberately rotating the seal key with xpander (an unexpected value here means the platform cannot open the sealed files).
</Warning>

***

## What the license enforces

### Seat and agent caps

`max_seats` and `max_agents` in the activation file are **enforced** (an unset cap means unlimited).

* Creating the **N+1th** user past `max_seats`, or the **N+1th** agent past `max_agents`, is **refused**, with a message explaining the cap and how to raise it (re-activate, see below).
* **Everything already created keeps working.** Existing users sign in, existing agents run. A cap only blocks *new* creation beyond it; it never disables what already exists, even if a smaller activation file is later swapped in.

### Expiry and grace

The activation file's expiry places the install on a `valid`, `expiring`, `grace`, `expired` timeline:

| State        | When                             | Behavior                                                                                            |
| ------------ | -------------------------------- | --------------------------------------------------------------------------------------------------- |
| **Valid**    | More than 14 days before expiry  | Everything normal                                                                                   |
| **Expiring** | Within 14 days of expiry         | Everything normal; a renewal warning is logged                                                      |
| **Grace**    | Past expiry, within `grace_days` | **New task creation is blocked**; running tasks continue, the UI stays up, all data stays available |
| **Expired**  | Past expiry plus `grace_days`    | Same as grace: new tasks blocked, everything else keeps serving                                     |

The key property: **expiry blocks only the creation of new tasks.** It never logs anyone out, never stops a running task, never takes the UI or your data offline. A lapsed term degrades to "read and finish what is running", not "locked out".

<Note>
  Feature grants (`features`, plus the features implied by the air-gap edition such as custom models, BYOK, SSO, and custom connectors) also come from the activation file. A feature the license does not grant is refused with a message pointing at re-activation; everything already enabled keeps working.
</Note>

***

## Renewing: re-activate

Renewal (extending the term, changing a cap, or adjusting features) re-issues **only the activation file**.

**In the backoffice** (staff), the license row has a **Re-activate** action that issues a fresh `activation.json` for the same org and the same `license_id`, with a new expiry and caps. The install file is not reissued.

**On the install:**

<Steps>
  <Step title="Get the new activation.json">
    From your xpander contact or the backoffice Re-activate action. It carries the same `org_id` and `license_id` as your current install.
  </Step>

  <Step title="Swap it into the Secret">
    Replace only the `activation.json` key; leave `install.json` untouched:

    ```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 -
    ```
  </Step>

  <Step title="Restart the platform">
    Roll the license-consuming services so they re-read the file:

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

    Or run `bash install.sh upgrade`. License consumers carry a checksum annotation over the Secret, so an upgrade rolls them onto the new activation file.
  </Step>
</Steps>

No reinstall, no re-mirror, no change to the org or its data. The new term and caps take effect once the services come back up.

<Note>
  The new activation file must name the **same org and license\_id** as the install file. The platform binds the two at runtime and refuses an activation file that belongs to a different license.
</Note>

***

## Staff: issuing a license

From the xpander-app admin area (staff-only, air-gap customers), **Issue a license** produces, in one handoff view:

* **`install.json`** and **`activation.json`**, two downloads.
* The **install command** (the `curl … | bash …` line above), prefilled with the org id.
* **ORGANIZATION\_ID**, the generated org id the customer passes as `--org`.
* The **License public key**, the base64 Ed25519 the customer wires into `global.license.publicKey` for a direct-chart deploy.

The **Re-activate** row action later issues a fresh `activation.json` only (same org, same `license_id`, new expiry / caps / features) for the renewal flow above.

***

## Legacy single-file license

Installs issued before two-file licensing use one signed `license.json` (`xpander-license/v1`), installed with `--license-file ./license.json` and wired through `global.license.key`. That path still works and is documented under [The License](/self-hosted/airgap#the-license) on the Air-Gapped page. New installs and renewals use the two-file flow on this page; the two modes are selected by whether `installKey` and `activationKey` are set.

***

## Next Steps

* [Air-Gapped Edition](/self-hosted/airgap): the full install flow, registry mirroring, data tier, upgrades
* [Air-Gapped Security Architecture](/resources/security-compliance/air-gapped-deployments): zero egress to xpander Cloud, the signed Ed25519 license, local sign-in
