---
title: "App licensing"
description: "Sell your app with signed licence files that switch features on, verify offline against a pinned key, and keep serving through a grace period."
section: "interfaces"
status: "Building for 0.4 · ships as a experimental API"
spec: "licence"
canonical: "https://mainsdk.dev/docs/interfaces/licence"
updated: "2026-10-28"
---

# App licensing

> Sell your app with signed licence files that switch features on, verify offline against a pinned key, and keep serving through a grace period.

Status: Building for 0.4 · ships as a experimental API · Canonical: https://mainsdk.dev/docs/interfaces/licence · Updated: 2026-10-28

`licence` lets you sell your app with signed licence files. The app checks them offline against a public key you pin, and `licence.require("sso")` switches a licensed feature on or off.

## At a glance

- Status: Building for 0.4 · ships as a experimental API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/licence.md
- Category: Payments and plans

Calls:

- `licence.state()`
- `licence.has(feature)`
- `licence.require(feature)`
- `licence.limit(name)`
- `licence.get()`
- `licence.update(token, { scope? })`
- `licence.refresh()`
- `licence.machine()`
- `licence.watch(scope, fn)`
- `new AppLicence(opts)`
- `storeKv(store)`
- `parseLicenceConfig(raw)`
- `licenceIssues(raw)`
- `createTestLicenceCA(opts?)`

In main.yaml:

```yaml
licence:
  driver: http
  product: acme-desktop
  roots: ["ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ"]  # the well-known test root; pin your own in production
  file: ./main.licence
  features: [sso, audit-export]
  url: https://api.mainsdk.dev
  refresh: 6h
  trust: ./main.licence-trust          # CA certificates and revocation lists
  machine: os                          # os | env
  over: enforce                        # enforce | report
  skew: 5m                             # clock skew tolerated, 0 to 1h
```

Guarantees, whatever the driver:

- Verified offline against pinned root keys; no call home on the request path
- A licence can't be edited, forged or rolled back to an older one through anything the SDK reads
- An unreachable revocation list or refresh endpoint never blocks a valid licence
- Expiry plus grace is the only stop, and it turns off only licensed features; the app always starts
- A licence only narrows plans and limits, never widens them


## Drivers

Pick one in main.yaml with `licence.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Local (a dev licence from the test root) | `local` |  |  |
| Offline (licence and revocation files only) | `offline` | yes |  |
| HTTP refresh (MainSDK Cloud or your own issuer) | `http` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-LICENCE-0011` | check | The `licence` section is invalid: unknown driver or key, no `product`, no `roots` or more than 4, a root that isn't `ed25519:` and 32 bytes, both `file` and `token`, `http` without `url`, `refresh` outside 5m to 7d, `skew` outside 0 to 1h. The app doesn't start |
| `MAIN-LICENCE-0012` | check | A feature name passed to `licence.has` or `licence.require` isn't under `licence.features` |
| `MAIN-LICENCE-0014` | check | The test root is pinned under `licence.roots` in production |
| `MAIN-LICENCE-0001` | runtime | The feature needs a licence and the licence in scope doesn't include it, or isn't valid or in grace (403, with `licence: { feature, status, reason }`) |
| `MAIN-LICENCE-0002` | runtime | A licensed limit is reached: the licence's ceiling is the one that binds (403, with `licence: { name, limit }`) |
| `MAIN-LICENCE-0003` | runtime | A licensed limit was passed with `over: report`; the call was allowed. Logged once per limit and scope per UTC day |
| `MAIN-LICENCE-0004` | runtime | A licence, CA certificate or revocation list doesn't verify; `reason` is `signature`, `chain`, `format`, `subject`, `binding`, `rollback` or `test-root` (400 from `licence.update()`) |
| `MAIN-LICENCE-0005` | runtime | The licence is in grace: expiring, revoked or suspended. Warned once a day with `graceEndsAt` |
| `MAIN-LICENCE-0006` | runtime | The licence is expired, revoked or suspended past its grace; licensed features are off |
| `MAIN-LICENCE-0007` | runtime | No licence, or not yet valid; licensed features are off |
| `MAIN-LICENCE-0008` | runtime | Refresh failed, or the newest revocation list is past its `nextUpdate`; the current state is kept. Logged once per outage |
| `MAIN-LICENCE-0009` | runtime | An older licence (`seq`) or revocation list (`number`), or a different list with the same number, was refused; the newer one stays |
| `MAIN-LICENCE-0010` | runtime | The clock is behind the high-water time by more than `licence.skew`; the high-water time is used for licence decisions |
| `MAIN-LICENCE-0013` | runtime | A feature name that isn't under `licence.features`: `has` returns false and logs once; `require` throws (500) |
| `MAIN-LICENCE-0015` | runtime | The machine id can't be read: no `/etc/machine-id` for `machine: os`, or `MAINSDK_MACHINE_ID` unset for `machine: env` (500) |
| `MAIN-LICENCE-0016` | runtime | `licence.update` refused: no `licence.manage` permission, an app licence outside `tenant.system()` in a tenanted app, or a tenant licence for another tenant (403) |
| `MAIN-LICENCE-0020` | runtime | Cloud: the caller lacks the role, or a write has no passkey session from the last 5 minutes (403) |
| `MAIN-LICENCE-0021` | runtime | Cloud: the licence claims are invalid; `errors` names each field (422) |
| `MAIN-LICENCE-0022` | runtime | Cloud: no such root, CA or licence in this account (404) |
| `MAIN-LICENCE-0023` | runtime | Cloud: the change conflicts with the state, such as renewing a revoked licence or issuing from a retired CA (409) |
| `MAIN-LICENCE-0024` | runtime | Cloud: a second approver is needed; the problem carries `approvalId` |
| `MAIN-LICENCE-0025` | runtime | Cloud: more than 60 refresh calls for one licence in an hour (429, `Retry-After`) |
| `MAIN-LICENCE-0026` | runtime | Cloud: the key service can't sign; nothing was issued or changed (503, retryable) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| DSSE over JCS, Ed25519, root to CA to licence | 1 | 0.4 | `licence#verify` |
| States: valid, grace, expired, revoked, suspended, tampered, missing | 1 | 0.4 | `licence#state` |
| require, has, limit and state reading memory only | 1 | 0.4 | `licence#checks` |
| Signed revocation lists with per-entry grace | 1 | 0.4 | `licence#revocation` |
| Anti-rollback of licences | 1 | 0.4 | `licence#anti-rollback` |
| Licensed features and limits narrow entitlements | 1 | 0.4 | `licence#entitlements` |
| Licence limits lower limits quotas | 1 | 0.4 | `licence#limits` |
| Tenant licences | 1 | 0.4 | `licence#tenant` |
| Background refresh over HTTP | 1 | 0.4 | `licence#refresh` |
| mainsdk licence verify and show | 1 | 0.4 | `licence#cli` |
| Test licence CA and golden vectors | 1 | 0.4 | `licence#testing` |
| Issue, renew, suspend, revoke and rotate in MainSDK Cloud | 2 | 0.6 | `licence-cloud#issue` |

## MainSDK Cloud

Everything on this page is free in your app.
Cloud adds:

| Plan | What it adds |
| --- | --- |
| teams | Roots and issuing CAs in the Cloud key service; issue, renew, suspend, revoke and rotate licences per tenant and environment, with audit, hosted revocation lists and refresh (spec licence-cloud) |


## Quick start

```yaml title="main.yaml"
licence:
  product: acme-desktop
  roots: ["ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ"]   # the well-known test root; pin your own in production
  file: ./main.licence
  features: [sso, audit-export]
```

TypeScript:

```ts
import { licence } from "mainsdk";

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

Rust:

```rust
use mainsdk::prelude::*;
let app = App::test();
let state = app.licence().state().await?;
assert_eq!(state.status, "missing");
```


## Configuration

```yaml title="main.yaml"
licence:
  driver: http                    # offline (default) | http | local
  product: acme-desktop           # must equal the licence's product; a-z, 0-9 and -, 1 to 64 characters
  roots:                          # pinned root public keys, 1 to 4
    - ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ
  file: ./main.licence            # read at start; or token, not both
  # token: secret:MAINSDK_LICENCE
  trust: ./main.licence-trust     # optional: CA certificates and revocation lists
  features: [sso, audit-export, offline-sync]
  url: https://api.mainsdk.dev    # http driver only
  refresh: 6h
  skew: 5m
  over: enforce
  machine: os
```

| Key | What it does |
| --- | --- |
| `driver` | `offline` reads files and tokens only. `http` verifies offline and refreshes in the background. `local` serves a dev licence for `mainsdk dev` and tests |
| `product` | The licence's `product` must equal it, or the licence is `tampered` with reason `subject` |
| `roots` | Pinned root keys: `ed25519:` and the base64url of the 32-byte key. A licence verifies only through a chain that ends at one |
| `file`, `token` | Where the app licence comes from. `token` is a `secret:NAME` holding the compact `mlic1.` form |
| `trust` | A trust bundle file with CA certificates and revocation lists |
| `features` | The feature names that need a licence. A name not listed here is refused with `MAIN-LICENCE-0013` |
| `url` | `http` driver only, and required there: `https`, or `localhost`. Its host is added to `network.allow` |
| `refresh` | `http` driver: 5m to 7d, jittered by up to 10%. A licence with under a third of its lifetime left refreshes at once |
| `skew` | Clock skew tolerated, 0 to 1h |
| `over` | `enforce` refuses a call past a licensed limit. `report` allows it, logs `MAIN-LICENCE-0003` and publishes `mainsdk:licence.over` |
| `machine` | Source of the machine id: `os` (`/etc/machine-id`, Linux) or `env` (`MAINSDK_MACHINE_ID`) |

An invalid section stops the app with `MAIN-LICENCE-0011`; `mainsdk check` finds it first. The full key list is in the [main.yaml reference](https://mainsdk.dev/docs/reference/main-yaml).

## What you get

A customer gets a signed licence file, the way desktop and self-hosted software is sold. The key you pin is in `main.yaml`. Nothing calls home while a request runs. When a licence expires, or you revoke it and the app hears about it, licensed features keep working through a grace period and then turn off. The rest of the app keeps serving. It runs in your app and is free. [Licensing](https://mainsdk.dev/docs/concepts/licensing) explains how app licences differ from TAPS licences, API keys and entitlements.

## Examples

### Gate a feature

`has` returns a boolean from memory. `require` throws. Both are true only while the licence in scope is `valid` or in `grace` and lists the feature.

```ts title="app/settings/page.tsx"
import { licence } from "mainsdk";

const showAudit = licence.has("audit-export");   // false when expired, revoked, tampered or missing
licence.require("sso");                          // MAIN-LICENCE-0001 with { feature, status, reason }
```

### Read the state and the limits

`state()` returns the status, the reason, the dates and whether revocation data is fresh. `limit(name)` returns the licence's number, `seats` for `"seats"`, or null. `get()` returns the verified claims, or null when the licence is missing or tampered.

```ts title="src/licence-banner.ts"
import { licence } from "mainsdk";

const { status, reason, expiresAt, graceEndsAt, revocation } = licence.state();
const seats = licence.limit("seats");   // number | null
const claims = licence.get();           // claims.features, claims.plan, claims.taps ... or null
if (status === "grace") console.warn(`licence ends ${graceEndsAt} (${reason})`);
```

### Watch for changes

`watch(scope, fn)` calls `fn` once per status change for the app (`"app"`) or one tenant id, and returns an unsubscribe.

```ts title="src/licence-watch.ts"
import { licence } from "mainsdk";

declare function notifyOwner(status: string, reason?: string): void;

const stop = licence.watch("app", (s) => {
  if (s.status !== "valid") notifyOwner(s.status, s.reason);
});
// stop() to unsubscribe
```

Status changes are worked out when the licence is read or installed. There is no timer, so `fn` runs on the next read, install or refresh after a status changes, not at the instant of expiry.

### Install a licence

`update(token, { scope? })` verifies a compact token, then stores it. A token that doesn't verify throws `MAIN-LICENCE-0004` (400) and changes nothing. The caller needs the `licence.manage` permission. An app licence is installed outside a tenant or inside `tenant.system()`; a tenant licence (`scope: "tenant"`) must name the current tenant. Anything else is `MAIN-LICENCE-0016`.

```ts title="src/admin/licence.ts"
import { licence, tenant } from "mainsdk";

export async function installTenantLicence(tenantId: string, token: string) {
  return tenant.run(tenantId, () => licence.update(token, { scope: "tenant" }));
}

export async function installAppLicence(token: string) {
  return tenant.system("install licence", () => licence.update(token, { scope: "app" }));
}
```

An installed licence takes precedence over `licence.file` and `licence.token` while its `seq` is the highest. Tenant licences load at start and refresh from the runtime store.

### Refresh and machine id

`refresh()` fetches new licences and revocation lists now. On the `http` driver it posts to the refresh endpoint; on `offline` it re-reads the files. It never throws for a network fault. `machine()` returns this machine's id, `m_` and 32 hex digits, which you give your vendor for a machine-bound licence.

```ts title="src/admin/refresh.ts"
import { licence } from "mainsdk";

const state = await licence.refresh();
const id = await licence.machine();   // MAIN-LICENCE-0015 when the OS id can't be read
```

## How a licence is checked

A licence file holds your licence, signed by an issuing CA, and the CA's certificate, signed by a root. The app pins the root, so the chain verifies with no network. Ed25519 and canonical JSON make any changed byte fail. A licence is accepted only if every step passes, in order: format, chain, signature, CA scope, test root, subject, environment and machine binding, then no rollback. The first failure decides the reason.

| Status | Licensed features | Reason | When |
| --- | --- | --- | --- |
| `valid` | On | | Verified, inside its dates, no revocation applies |
| `grace` | On, with a daily warning | `expiring`, `revoked`, `suspended`, `key-compromise` | Expired, or revoked or suspended by a signed list, within its grace |
| `expired` | Off | `expiring` | Past expiry plus grace |
| `revoked`, `suspended` | Off | `revoked`, `suspended`, `key-compromise` | Past the grace of a revocation entry |
| `tampered` | Off | `signature`, `chain`, `format`, `subject`, `binding`, `rollback`, `test-root` | The licence doesn't verify |
| `missing` | Off | `none`, `not-yet-valid` | No licence installed, or its start date is in the future |

`revocation` in the state is `fresh` (the newest list for the licence's CA is before its `nextUpdate`), `stale` (past it) or `none` (never seen). A suspension is lifted by a later list that leaves the entry out. A revoked entry stays.

## With entitlements and limits

A feature under `licence.features` is in a tenant's plan only when the plan includes it and the licence does. `entitlements.require` for such a feature throws `MAIN-LICENCE-0001`, not `MAIN-PLAN-0001`. A licence limit, or `seats`, that has the name of a plan limit or of a `limits.quotas` or `limits.concurrency` entry lowers it. It never raises it. A tenant licence with a `plan` is a plan source in [entitlements](https://mainsdk.dev/docs/interfaces/entitlements), after a manual plan and before `pay`.

`over: report` covers plan-limit checks and quotas. Concurrency has no report mode: a concurrency ceiling from a licence always enforces.

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-LICENCE-0001` | The feature needs a licence the scope doesn't have, or the licence isn't valid or in grace (403) | `mainsdk licence show`; install a licence that lists it |
| `MAIN-LICENCE-0002` | A licensed limit is reached (403) | Ask for a licence with a higher limit, or set `over: report` |
| `MAIN-LICENCE-0003` | A licensed limit was passed with `over: report` (logged) | Renew with a higher limit |
| `MAIN-LICENCE-0004` | A licence, certificate or list doesn't verify; `update` throws it (400) | Ask the issuer for a new file; check the roots, product and project |
| `MAIN-LICENCE-0005` | The licence is in grace (warned daily) | Renew it |
| `MAIN-LICENCE-0006` | Expired, revoked or suspended past grace (logged once) | Install a renewed licence |
| `MAIN-LICENCE-0007` | No licence, or not valid yet (logged) | Set `licence.file` or `licence.token` |
| `MAIN-LICENCE-0008` | Refresh failed or the newest list is stale; the state is kept (logged once per outage) | Check `licence.url` and the network |
| `MAIN-LICENCE-0009` | An older licence or list was refused; the newer one stays | Install the newest licence |
| `MAIN-LICENCE-0010` | The clock is behind the high-water time by more than `skew` | Fix the machine's clock |
| `MAIN-LICENCE-0011` | The `licence` section is invalid; the app doesn't start (check, runtime) | Fix the key the finding names |
| `MAIN-LICENCE-0012` | A name passed to `has` or `require` isn't under `licence.features` (check) | Add it, or fix the typo |
| `MAIN-LICENCE-0013` | The same at runtime: `has` returns false, `require` throws (500) | Add it, or fix the typo |
| `MAIN-LICENCE-0014` | The test root is pinned in production (check) | Pin your own root |
| `MAIN-LICENCE-0015` | The machine id can't be read (500) | Set `machine: env` and `MAINSDK_MACHINE_ID` |
| `MAIN-LICENCE-0016` | `update` refused: no `licence.manage`, wrong scope or wrong tenant (403) | Grant it, or install in the right tenant |

See [check codes](https://mainsdk.dev/docs/reference/check-codes) and [errors](https://mainsdk.dev/docs/reference/errors) for all of them.

## Swapping drivers

| Driver | Use it for |
| --- | --- |
| `offline` (default) | Files and tokens only, with a trust bundle from `licence.trust`. No network call at all |
| `http` | Offline verification plus background refresh of licences, CA certificates and revocation lists |
| `local` | `mainsdk dev` and tests. It trusts the test root and, with no licence installed, serves a 30-day dev licence with every name under `licence.features` and `test: true`. `mainsdk check` refuses it in production |

The `http` driver refreshes only after the server listens: first within 60 seconds, then every `licence.refresh`, with exponential backoff from 1 minute on failure and at least `Retry-After` after a 429. The wire contract is `POST {licence.url}/v1/licences/refresh`. The answer is untrusted: every licence, certificate and list in it is verified like a file, and what fails is dropped and logged.

## Testing

`createTestLicenceCA()` from `@mainsdk/testing` makes a CA under the well-known test root. It issues licences, rotates, revokes, writes signed lists and tampers with tokens. `app.licence.install(token, { scope, tenant })` installs one under `createTestApp()` as the system would. `createRefreshServer()` is a fake refresh endpoint for the `http` driver, and `buildLicenceFixtures()` rebuilds the golden files in `packages/testing/fixtures/licence/` byte for byte.

```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]
`;
const ca = await createTestLicenceCA({ product: "acme-desktop", project: "prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY" });

it("turns sso off when the licence has no sso", async () => {
  // test licences start on 2026-11-01, so set the clock after that
  const app = createTestApp({ yaml, clock: createClock(Date.UTC(2026, 10, 1, 12)) });
  await app.licence.install(await ca.issue({ features: ["audit-export"] }), { scope: "app" });
  assert.equal(app.licence.has("sso"), false);
  assert.equal(app.licence.has("audit-export"), true);
  assert.throws(() => app.licence.require("sso"), { code: "MAIN-LICENCE-0001" });
});

it("refuses a licence with one changed byte", async () => {
  const app = createTestApp({ yaml, clock: createClock(Date.UTC(2026, 10, 1, 12)) });
  const bad = ca.tamper(await ca.issue(), "payload");
  await assert.rejects(app.licence.install(bad, { scope: "app" }), { code: "MAIN-LICENCE-0004" });
});
```

From the shell, `mainsdk licence verify` and `mainsdk licence show` check a file. See [the CLI](https://mainsdk.dev/docs/reference/cli) and [the testing kit](https://mainsdk.dev/docs/interfaces/testing).

## Security and privacy

- `has`, `require`, `state`, `limit` and `get` read memory only: no store, file or network call, so a check adds no request-path latency.
- Verification uses the platform's WebCrypto and makes no network call. A licence signed directly by a root is `tampered`. The test root is refused in production.
- The app remembers the highest licence `seq`, the highest list `number` and the latest time it has seen. An older licence, an older list or a clock set backwards is refused and raises a security event.
- Logs, channels, problems and audit records carry the licence id, key ids and machine id, never the token, a signature or the raw machine source.
- Channels: `mainsdk:licence.verify`, `.state`, `.check`, `.refresh` and `.over`. Audit records: `licence.installed`, `licence.renewed`, `licence.state_changed`.

### What it promises, and what it doesn't

A licence can't be forged or edited without a CA's private key. A CA can't exist without a pinned root's private key. An app that has seen a newer licence, a newer list or a later time can't be rolled back through any input the SDK reads. A network or clock fault never stops the app.

It doesn't stop someone who controls the machine. MainSDK is open source and runs on the customer's machine, so that person can patch the SDK, replace the pinned root, wipe the runtime store to forget the rollback marks, or keep the machine offline so a revocation never arrives. The seal reports changed files, but can be rewritten too. Machine binding stops a casual copy between machines; with `machine: env` the id is set by whoever runs the app. Licensing keeps honest customers honest and makes your terms auditable. Serve features worth stealing from a server you run.

## FAQ

**Does a licence check make a network call?** No. Verification is offline. Only the `http` driver talks to your refresh endpoint, in the background after the server listens, and a failure never blocks a valid licence.

**What happens when the licence expires?** The state moves to `grace`, then `expired`. Only features under `licence.features` and licence limits turn off. The app starts and serves either way.

**Who issues licences?** Today, your own CA, or the testing kit for development. Issuing from MainSDK Cloud is planned for 0.6 (spec `licence-cloud`).

## Next

- [Licensing](https://mainsdk.dev/docs/concepts/licensing): app licences compared with TAPS licences, API keys and entitlements
- [License your app](https://mainsdk.dev/docs/guides/license-your-app): the steps, start to finish
- [Plans and entitlements](https://mainsdk.dev/docs/interfaces/entitlements)
- [Rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits)

## Helpers for wiring and tests

| Call | What it does |
| --- | --- |
| `new AppLicence(opts)` | The licence of one app instance, for tests and custom wiring; apps use `licence.has` and `licence.limit`. |
| `storeKv(store)` | Wraps a `Store` as the key-value port an `AppLicence` keeps its cached licence in. |
| `parseLicenceConfig(raw)` | Read the `licence` section of `main.yaml` into settings, or null when it is absent. |
| `licenceIssues(raw)` | List what is wrong in the `licence` section, without throwing. |
