---
title: "Gate features and limits by plan"
description: "Declare plans in main.yaml, refuse paid features and over-limit actions with an upgrade hint, and change a tenant's plan from a verified payment event."
section: "guides"
status: "Building for 0.3"
spec: "entitlements"
canonical: "https://mainsdk.dev/docs/guides/gate-features-by-plan"
updated: "2026-10-28"
---

# Gate features and limits by plan

> Declare plans in main.yaml, refuse paid features and over-limit actions with an upgrade hint, and change a tenant's plan from a verified payment event.

Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/gate-features-by-plan · Updated: 2026-10-28

You will declare a free and a pro plan, block one feature and one limit on free, and test both. It takes about 15 minutes.

## You need

- A MainSDK app with `tenancy` and `pay` (see [take a payment](https://mainsdk.dev/docs/guides/take-a-payment)).

## 1. Declare the plans

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

## 2. Refuse a paid feature

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

declare function buildExport(): Promise<unknown>;   // your own code

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

To show an upgrade prompt instead of an error, branch on `entitlements.has("export")`.

## 3. Enforce a limit

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

declare function buildExport(): Promise<unknown>;   // your own code

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

With a function, `within` runs it under a lock so two requests cannot both pass the limit.

## 4. Change a plan

A verified `subscription.updated` event from `pay` sets the plan when its price is listed under a plan's `prices` and `metadata.tenant` names the tenant. For a trial or a sales deal:

```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" });
});
```

## 5. Test it

```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" });
  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" });
  });
});
```

## Check it worked

Run `npx mainsdk check`: it reports a feature or limit no plan declares as `MAIN-PLAN-0010`. The test above passes, and `entitlements.has("export")` is true inside `app.asTenant({ id, plan: "pro" })`.

## Troubleshooting

- `MAIN-PLAN-0005` in the log: a subscription event named a price under no plan, or carried no tenant. Add the price under `plans.<name>.prices` and set `metadata.tenant`.
- `MAIN-PLAN-0007`: a plan change for another tenant outside `tenant.system()`.
- `MAIN-PLAN-0008`: `limit` or `within` named something no plan declares.

See the [entitlements interface](https://mainsdk.dev/docs/interfaces/entitlements).
