---
title: "License your app"
description: "Gate paid features with signed licence files checked offline, test every state with the testing kit, and check a customer's file from the command line."
section: "guides"
status: "Building for 0.4"
spec: "licence"
canonical: "https://mainsdk.dev/docs/guides/license-your-app"
updated: "2026-10-28"
---

# License your app

> Gate paid features with signed licence files checked offline, test every state with the testing kit, and check a customer's file from the command line.

Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/license-your-app · Updated: 2026-10-28

Your app will check a signed licence file offline, switch the features you sell on and off, and keep serving when the licence expires, when the network is down and when a revocation hasn't arrived. You will pin a root key, name the licensed features, gate them in code, test each state, and check a file with the CLI. It takes about 20 minutes.

> **Note:** `licence` is built in 0.4 (spec `licence`) and runs from the repository; it is not published to npm. Issuing licences from MainSDK Cloud is planned for 0.6 (spec `licence-cloud`). Until then you issue with your own CA, or with the testing kit while you build.

## You need

- A MainSDK app with a `main.yaml` and a `project` id (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)).
- A product name, such as `acme-desktop`, that your licences name.
- A root public key from your issuer. While you build, use the well-known test root `ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ`. `mainsdk check` refuses it in production.

## 1. Declare the licence

```yaml title="main.yaml"
licence:
  product: acme-desktop
  roots: ["ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ"]   # the test root; swap for yours before you ship
  file: ./main.licence
  features: [sso, audit-export]
```

`features` lists every name that needs a licence. A name you pass to the code and don't list is refused. The default driver is `offline`: it reads the file and makes no network call.

## 2. Gate the feature

```ts title="app/api/sso/route.ts"
import { licence } from "mainsdk";

export async function GET() {
  licence.require("sso");   // throws MAIN-LICENCE-0001 (403) outside valid and grace
  return Response.json({ ok: true });
}
```

Use `licence.has("audit-export")` to hide a button, and `licence.state()` to show how many days are left. Both read memory only. Gate only what you sell: the app always starts and serves, and nothing else depends on the licence.

## 3. Test every state

```ts title="lib/licence.test.ts"
import { it } from "node:test";
import assert from "node:assert/strict";
import { createClock, createTestApp, createTestLicenceCA } from "@mainsdk/testing";

const yaml = `schema: 1
project: prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY
licence:
  product: acme-desktop
  roots: ["ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ"]
  features: [sso, audit-export]
`;

it("serves sso in grace and turns it off after", async () => {
  const clock = createClock(Date.UTC(2026, 10, 1, 12));
  const app = createTestApp({ yaml, clock });
  const ca = await createTestLicenceCA({ product: "acme-desktop", project: "prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY" });
  await app.licence.install(await ca.issue({ features: ["sso"] }), { scope: "app" });   // valid until 2027-11-01, 14d grace
  assert.equal(app.licence.has("sso"), true);
  clock.advance("370d");
  assert.equal(app.licence.state().status, "grace");
  assert.equal(app.licence.has("sso"), true);
  clock.advance("20d");
  assert.equal(app.licence.state().status, "expired");
  assert.throws(() => app.licence.require("sso"), { code: "MAIN-LICENCE-0001" });
});
```

`ca.revoke(...)`, `ca.crl()` and `ca.tamper(token, how)` cover revocation and tampering. See [the licence interface](https://mainsdk.dev/docs/interfaces/licence#testing).

## 4. Check a customer's file

```bash
npx mainsdk licence show ./main.licence            # claims, chain, newest revocation list and state
npx mainsdk licence verify ./main.licence --strict   # exit 0 valid, 1 not valid or in grace, 2 usage
```

Run `verify` in CI against a sample licence, or on a customer's file before you send a support reply. A licence bound to a machine needs the machine's id: the customer runs `npx mainsdk licence show --machine` and sends you the `m_…` value.

## 5. Keep installs current

For online installs, use the `http` driver so renewals and revocations arrive without a release:

```yaml title="main.yaml"
licence:
  driver: http
  url: https://licences.acme.dev    # your issuer's refresh endpoint; added to network.allow
  refresh: 6h
```

The app refreshes in the background after it starts listening, never while a request waits. For air-gapped installs, keep `offline` and copy a new licence file and a `licence.trust` bundle in; `licence.update(token)` installs a token at run time and needs the `licence.manage` permission. Give online installs short licences that refresh, because an app that can't reach the endpoint never learns of a revocation.

## 6. Before you ship

Replace the test root with your own and check:

```bash
npx mainsdk check --env prod
```

A pass exits 0 with no `MAIN-LICENCE` finding. Keep the ownership clear: the CA's private key stays with your issuer, never in the app.

## Check it worked

- `npx mainsdk licence verify ./main.licence` exits 0.
- The test above passes: `has("sso")` is true in grace and false after.
- `npx mainsdk check --env prod` reports no `MAIN-LICENCE-0011` and no `MAIN-LICENCE-0014`.

## If it fails

| Code | Cause | Fix |
| --- | --- | --- |
| `MAIN-LICENCE-0001` | The licence doesn't list the feature, or isn't valid or in grace | Run `mainsdk licence show`; install a licence that lists it |
| `MAIN-LICENCE-0004` | The file doesn't verify: wrong root, product or project, an older licence, or an edited byte | Run `mainsdk licence verify --json` and read `reason`; ask the issuer for a new file |
| `MAIN-LICENCE-0007` | No file at `licence.file`, or not valid yet | Put the file there, or set `licence.token` |
| `MAIN-LICENCE-0011` | The `licence` section is invalid | Fix the key the finding names |
| `MAIN-LICENCE-0012` | The code names a feature that isn't under `licence.features` | Add it, or fix the typo |
| `MAIN-LICENCE-0014` | The test root is pinned in production | Pin your own root |

## What it can't do

It keeps honest customers honest and makes your terms auditable. It can't stop someone who controls the machine from patching the SDK, replacing the pinned root, wiping the runtime store or staying offline so a revocation never arrives. Serve features worth stealing from a server you run.

## For agents

```text
Goal: gate the features we sell with signed licences.
Read: docs/specs/licence.md, this page, main.yaml.
Touch only: main.yaml (licence section), the routes that gate a feature, tests.
Do: add the licence section with product, roots and features; call licence.require or licence.has for each
licensed feature; test valid, grace, expired and tampered with createTestLicenceCA.
Verify: npx mainsdk check --json --agent; npx mainsdk licence verify <file>
Never pin the test root for production, never put a CA private key or a licence token in code, and never gate
anything that isn't listed under licence.features.
```

## Next

- [App licensing](https://mainsdk.dev/docs/interfaces/licence), the interface reference
- [Licensing](https://mainsdk.dev/docs/concepts/licensing): app licences compared with TAPS licences, API keys and entitlements
- [Limits, metering and entitlements](https://mainsdk.dev/docs/guides/limits-and-metering)
