MainSDK
Search⌘K
Interfaces

Payments and plans

Plans and entitlements

entitlements

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

Building for 0.3ships as a beta APIPhase 1 · Core, public betaSpec entitlementsFree in your app, Apache-2.0

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

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

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" }

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? })

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

2 drivers. Select one to see its line in main.yaml.

main.yamlYour database (the tenant's region store) (default)
entitlements:
  driver: db

Change this one line to swap providers; your calls stay the same.Your database integration

Codes

CodeWhere it appearsMeaning
MAIN-PLAN-0006Checkmainsdk check, before deployplans 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-0010Checkmainsdk check, before deployA feature or limit name used in code that no plan declares
MAIN-PLAN-0001RuntimeError responses and logsThe tenant's plan doesn't include this feature (403, with an upgrade hint naming the plans that do)
MAIN-PLAN-0002RuntimeError responses and logsThe tenant has reached this limit of its plan (403, with an upgrade hint)
MAIN-PLAN-0003RuntimeError responses and logsAn override would remove a feature or lower a limit; overrides may only add (400)
MAIN-PLAN-0004RuntimeError responses and logsThe 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-0005RuntimeError responses and logsA subscription event names a price under no plan, or carries no tenant; it is ignored and the plan is unchanged
MAIN-PLAN-0007RuntimeError responses and logsA plan change for another tenant outside tenant.system(), or by a caller without the entitlements.manage permission (403)
MAIN-PLAN-0008RuntimeError responses and logsA feature or limit name that no plan declares. has returns false and logs once; limit and within throw (500)
MAIN-PLAN-0009RuntimeError responses and logsThe tenant has reached its plan's request rate (429, with Retry-After)
MAIN-PLAN-0011RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
plans and entitlements in main.yamlPhase 10.3entitlements#schema
has, require, limit and within for the current tenantPhase 10.3entitlements#checks
Plan state stored in the tenant's regionPhase 10.3entitlements#store
Plan set from pay subscription eventsPhase 10.3entitlements#pay-source
Audited per-tenant overrides that only addPhase 10.3entitlements#overrides
Last known plan served when the store is downPhase 10.3entitlements#fail-static
Usage from members, records, AI spend and meterPhase 10.3entitlements#usage
Plan request rates and plan in flag rulesPhase 10.3entitlements#limits-flags
Check for features and limits no plan declaresPhase 10.3entitlements#check
Plan helpers in the testing kitPhase 10.3entitlements#testing

Quick start

main.yaml
plans:
  free: { features: [basic], limits: { seats: 3 } }
  pro:  { features: [basic, export], limits: { seats: 50 }, prices: [price_pro_monthly] }
entitlements:
  default: free
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

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
KeyWhat it does
plans.<name>.featuresNames a plan includes. Check them with has and require
plans.<name>.limitsNumbers, money per window, or request rates. A limit not listed is unlimited
plans.<name>.pricesThe pay price ids that put a tenant on this plan
entitlements.defaultThe plan of a tenant with no other plan
entitlements.driverdb keeps plan state in the tenant's region store. local keeps it in process, for tests and local mode
entitlements.usageWhere 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

CallWhat 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.

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.

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().

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

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

See 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 }).

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.

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.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet