---
title: "Plans and entitlements"
description: "Say what each plan includes, check it in one line, and set a tenant's plan from its subscription."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "entitlements"
canonical: "https://mainsdk.dev/docs/interfaces/entitlements"
updated: "2026-10-28"
---

# Plans and entitlements

> Say what each plan includes, check it in one line, and set a tenant's plan from its subscription.

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/entitlements · Updated: 2026-10-28

Declare plans in `main.yaml`, then check a feature or limit in one line with `entitlements`, which refuses with an upgrade hint and takes the plan from verified subscription events.

## At a glance

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

Calls:

- `entitlements.has(feature)`
- `entitlements.require(feature)`
- `entitlements.limit(name)`
- `entitlements.within(name, add?, fn?)`
- `entitlements.plan()`
- `entitlements.set(tenantId, plan, { reason, trial?, until? })`
- `entitlements.grant(tenantId, { features?, limits? }, { reason, until? })`
- `entitlements.revoke(tenantId, grantId, { reason })`
- `entitlements.ungrant(tenantId, grantId, { reason })`
- `new Entitlements(o)`
- `PLAN_DDL(const)`
- `dbPlanStore(o)`
- `localPlanStore()`
- `parsePlans(plans, ent, { meter? })`
- `planIssues(plans, ent, { meter? })`

In main.yaml:

```yaml
plans:
  free: { features: [basic], limits: { seats: 3, projects: 5, ai_spend: $5/month } }
  pro:
    features: [basic, export, sso]
    limits: { seats: 50, requests: 100/s }
    prices: [price_pro_monthly, price_pro_yearly]
entitlements:
  driver: db
  default: free
  grace: 7d
  upgrade: /settings/billing
  usage: { seats: members, ai_spend: ai, projects: "model:projects" }
```

Guarantees, whatever the driver:

- has reads memory only; no network call per check
- Every refusal carries an upgrade hint that names the plans that include the feature
- The plan comes from verified subscription events, never from the browser
- Per-tenant overrides can only add features or raise limits, and are audited
- The last known plan is served when the store is down


## Drivers

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

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Your database (the tenant's region store) | `db` | yes |  |
| Local (tests and local mode) | `local` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-PLAN-0006` | check | `plans` or `entitlements` is invalid: the default plan isn't defined, a name is both a feature and a limit, a limit has a bad value, a windowed usage source has a limit with no window, or a price is under two plans. The app doesn't start |
| `MAIN-PLAN-0010` | check | A feature or limit name used in code that no plan declares |
| `MAIN-PLAN-0001` | runtime | The tenant's plan doesn't include this feature (403, with an `upgrade` hint naming the plans that do) |
| `MAIN-PLAN-0002` | runtime | The tenant has reached this limit of its plan (403, with an `upgrade` hint) |
| `MAIN-PLAN-0003` | runtime | An override would remove a feature or lower a limit; overrides may only add (400) |
| `MAIN-PLAN-0004` | runtime | The plan store can't be reached; the last known plan is served, or the default plan for a tenant with none. Logged once per outage |
| `MAIN-PLAN-0005` | runtime | A subscription event names a price under no plan, or carries no tenant; it is ignored and the plan is unchanged |
| `MAIN-PLAN-0007` | runtime | A plan change for another tenant outside `tenant.system()`, or by a caller without the `entitlements.manage` permission (403) |
| `MAIN-PLAN-0008` | runtime | A feature or limit name that no plan declares. `has` returns false and logs once; `limit` and `within` throw (500) |
| `MAIN-PLAN-0009` | runtime | The tenant has reached its plan's request rate (429, with `Retry-After`) |
| `MAIN-PLAN-0011` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| plans and entitlements in main.yaml | 1 | 0.3 | `entitlements#schema` |
| has, require, limit and within for the current tenant | 1 | 0.3 | `entitlements#checks` |
| Plan state stored in the tenant's region | 1 | 0.3 | `entitlements#store` |
| Plan set from pay subscription events | 1 | 0.3 | `entitlements#pay-source` |
| Audited per-tenant overrides that only add | 1 | 0.3 | `entitlements#overrides` |
| Last known plan served when the store is down | 1 | 0.3 | `entitlements#fail-static` |
| Usage from members, records, AI spend and meter | 1 | 0.3 | `entitlements#usage` |
| Plan request rates and plan in flag rules | 1 | 0.3 | `entitlements#limits-flags` |
| Check for features and limits no plan declares | 1 | 0.3 | `entitlements#check` |
| Plan helpers in the testing kit | 1 | 0.3 | `entitlements#testing` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
plans:
  free: { features: [basic], limits: { seats: 3 } }
  pro:  { features: [basic, export], limits: { seats: 50 }, prices: [price_pro_monthly] }
entitlements:
  default: free
```

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

async function buildExport() {
  return { rows: [] };
}

export async function POST() {
  entitlements.require("export");   // throws MAIN-PLAN-0001 (403) with an upgrade hint on free
  return Response.json(await buildExport());
}
```

## Configuration

```yaml title="main.yaml"
plans:
  free: { features: [basic], limits: { seats: 3, projects: 5, ai_spend: $5/month, exports: 100/month } }
  pro:
    features: [basic, export, sso]
    limits: { seats: 50, requests: 100/s }   # a limit not listed is unlimited
    prices: [price_pro_monthly, price_pro_yearly]
entitlements:
  driver: db                       # db (default) | local
  default: free
  grace: 7d                        # how long a past-due subscription keeps its plan
  upgrade: /settings/billing       # the link in every upgrade hint
  usage:
    seats: members
    ai_spend: ai
    projects: model:projects
    exports: meter:exports         # any other name reads the meter counter of that name
```

| Key | What it does |
| --- | --- |
| `plans.<name>.features` | Names a plan includes. Check them with `has` and `require` |
| `plans.<name>.limits` | Numbers, money per window, or request rates. A limit not listed is unlimited |
| `plans.<name>.prices` | The `pay` price ids that put a tenant on this plan |
| `entitlements.default` | The plan of a tenant with no other plan |
| `entitlements.driver` | `db` keeps plan state in the tenant's region store. `local` keeps it in process, for tests and local mode |
| `entitlements.usage` | Where usage comes from: `members`, `ai`, `model:<name>` or `meter:<metric>` |

A limit that reads a `meter` counter needs a window (`100/month`, `20/day`), because the meter counts per window. The metric must be declared under `meter.metrics`; AI spend reads the metric `ai.spend`. `plans` with a missing default plan, a bad limit value or a price under two plans stops the app with `MAIN-PLAN-0006`.

## What you get

| Call | What it does |
| --- | --- |
| `entitlements.has(feature)` | True when the current tenant's plan includes the feature. It reads memory only, never a store or a provider |
| `entitlements.require(feature)` | Throw `MAIN-PLAN-0001` (403) with an upgrade hint unless the plan includes the feature |
| `entitlements.revoke(tenantId, grantId, { reason })` | End a grant of one tenant now, with the reason in the audit log. An unknown grant id fails |
| `entitlements.ungrant(tenantId, grantId, { reason })` | The old name of `revoke`. It is deprecated and removed in 1.0, so use `revoke` |
| `new Entitlements(o)` | The module behind `entitlements.*`. `mainsdk` builds it from the `plans` and `entitlements` sections of `main.yaml`; `o` takes the parsed config, a plan store, and the tenancy, access, log, limits and meter it works with |
| `parsePlans(plans, ent, { meter? })` | Read and check the `plans` and `entitlements` sections. A problem is `MAIN-PLAN-0006` and the app does not start. It returns `null` when neither section exists |
| `planIssues(plans, ent, { meter? })` | Every problem in the two sections as a list of `{ key, message }`, such as an unknown driver, a `grace` that isn't a duration or a `usage` source that isn't `members`, `ai`, `model:<name>` or `meter:<metric>` |
| `dbPlanStore(o)` | The `db` driver: one row per tenant in the SQL store of that tenant's region. `migrate: true` also creates the table on Postgres, and `sql()` returns the statements |
| `PLAN_DDL (constant)` | The statement that creates the `mainsdk_plans` table, for a migration file |
| `localPlanStore()` | The `local` driver: plan state in this process, one map per region, for tests and local mode |

## Examples

### Check a feature

`entitlements.has(feature)` reads memory and returns a boolean. `entitlements.require(feature)` throws. Branch on `has` to show an upgrade prompt.

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

const canExport = entitlements.has("export");
const plan = entitlements.plan();   // { plan, status, source, trialEndsAt?, ... }
```

### Enforce a limit

`entitlements.limit(name)` reports the limit, what is used and what remains. `entitlements.within(name, add, fn)` refuses when the add would pass the limit, and with `fn` runs it under a lock so two requests cannot both squeeze in.

```ts title="app/projects/actions.ts"
import { entitlements } from "mainsdk";

async function buildExport() {
  return { rows: [] };
}

const { limit, used, remaining } = await entitlements.limit("exports");   // limit and remaining are null when unlimited

await entitlements.within("exports", 1, () => buildExport());   // throws MAIN-PLAN-0002 at the limit
```

### Change a plan

A verified `subscription.updated` event from `pay` sets the plan on its own: the event must carry a price listed under a plan's `prices` and the tenant id in `metadata.tenant`, or it is ignored with `MAIN-PLAN-0005`. To set one by hand, such as a trial or a sales deal, call `entitlements.set()`. For a tenant other than the current one, do it inside `tenant.system()`.

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

const tenantId = "tnt_acme";

await tenant.system("sales deal", async () => {
  await entitlements.set(tenantId, "pro", { reason: "annual deal", trial: "14d" });
  const grant = await entitlements.grant(tenantId, { features: ["sso"] }, { reason: "pilot", until: "2026-12-31" });
  await entitlements.revoke(tenantId, grant.id, { reason: "pilot over" });
});
```

A grant can only add features or raise limits. One that would remove or lower fails with `MAIN-PLAN-0003`.

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-PLAN-0001` | The plan lacks the feature (403, with an `upgrade` hint) | Upgrade, or grant it |
| `MAIN-PLAN-0002` | The tenant is at a limit (403) | Upgrade, or raise the limit |
| `MAIN-PLAN-0003` | A grant would remove a feature or lower a limit (400) | Grant only additions |
| `MAIN-PLAN-0004` | The plan store is unreachable; the last known plan is served (logged) | Restore the store |
| `MAIN-PLAN-0005` | A subscription event names a price under no plan, or no tenant; it is ignored (logged) | Add the price under `plans.<name>.prices`, and set `metadata.tenant` |
| `MAIN-PLAN-0006` | `plans` or `entitlements` is invalid; the app does not start | Fix the key the error names |
| `MAIN-PLAN-0007` | A plan change for another tenant outside `tenant.system()`, or without `entitlements.manage` (403) | Run it in `tenant.system()`, or grant the permission |
| `MAIN-PLAN-0008` | A feature or limit name that no plan declares: `limit` and `within` throw | Declare it under `plans` |
| `MAIN-PLAN-0009` | The plan's request rate is reached (429) | Wait for `Retry-After` |
| `MAIN-PLAN-0010` | `mainsdk check` found a feature or limit no plan declares | Declare it under `plans` |

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

## Swapping drivers

`driver: local` keeps plan state in process, for tests and local mode. Use `db` in production; the local driver loses plans on restart.

## Testing

`createTestApp` gives `app.entitlements` two helpers: `setPlan(tenant, plan, { trial? })` and `setUsage(tenant, limit, used)`. Run code as a tenant on a plan with `app.asTenant({ id, plan })`.

```ts title="lib/limits.test.ts"
import { it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { entitlements } from "mainsdk";

it("refuses a fourth seat on free", async () => {
  const app = createTestApp({ main: "./main.yaml" });   // free: seats 3
  await app.asTenant({ id: "tnt_free" }, async () => {
    await app.entitlements.setUsage("tnt_free", "seats", 3);
    await assert.rejects(entitlements.within("seats", 1), { code: "MAIN-PLAN-0002" });
  });
  await app.asTenant({ id: "tnt_pro", plan: "pro" }, async () => {
    assert.equal(entitlements.has("export"), true);
  });
});
```

See [the testing kit](https://mainsdk.dev/docs/interfaces/testing).

## Security and privacy

- `has` and `require` read memory, so a check adds no network call.
- A plan changes only from a verified subscription event, a person with `entitlements.manage`, or `tenant.system()`.
- When the store is down, the last known plan is served, and an unknown tenant gets the default plan.

## FAQ

**Where is the plan stored?** In the tenant's region, with an audit record for every change.

**Can a flag target a plan?** Yes. Flag rules can match the tenant's plan. See [feature flags](https://mainsdk.dev/docs/interfaces/flags).

## Next

- [Tenants](https://mainsdk.dev/docs/interfaces/tenant)
- [Payments](https://mainsdk.dev/docs/interfaces/pay): the subscriptions that set the plan.
- [Rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits): plan request rates.
- [Limits, metering and entitlements](https://mainsdk.dev/docs/guides/limits-and-metering)
