MainSDK
Search⌘K
How-to

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.

Building for 0.3

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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

1. Declare the plans

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

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

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:

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

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.

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet