MainSDK
Search⌘K
How-to

Limits, metering and entitlements

Rate-limit an endpoint, cap usage with a quota, meter what each tenant uses and gate features and limits by plan.

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 protect an expensive endpoint with a rate limit and a daily quota, record what each tenant uses, and decide what each plan includes. The three interfaces work together: limits refuses abuse, meter counts usage, and entitlements turns a tenant's plan into yes or no. It takes about 20 minutes and needs no account.

You need

1. Rate-limit an endpoint

Declare a named policy, then enforce it with the person's id as the key.

main.yaml
limits:
  driver: local
  single-instance: true       # use redis once you run more than one instance
  policies:
    export: { max: 5, per: 1m }
  quotas:
    exports-daily: { max: 50, per: day }
app/api/export/route.ts
import { auth, limits } from "mainsdk";

const buildExport = async () => ({ rows: 0 });

export async function POST(req: Request) {
  const session = await auth.require(req);
  await limits.enforce("export", { key: session.user });   // MAIN-LIMIT-0001 (429) past 5 a minute
  return Response.json(await buildExport());
}

A refusal is a 429 problem with Retry-After and the RateLimit headers. You should see the sixth request in a minute fail.

2. Cap usage with a quota

A quota counts per UTC window and gives back what you release.

app/exports/actions.ts
import { limits } from "mainsdk";

const runExport = async () => {};

export async function startExport(userId: string) {
  await limits.quota("exports-daily", { key: userId });   // MAIN-LIMIT-0002 when the day's 50 are used
  try {
    await runExport();
  } catch (err) {
    await limits.release("exports-daily", { key: userId, add: 1 });
    throw err;
  }
}

3. Meter what each tenant uses

Declare each metric, then record where the usage happens. meter.record() returns at once and never fails the request.

main.yaml
meter:
  driver: local               # use db or redis in production
  metrics:
    exports: { unit: export, aggregate: sum }
lib/usage.ts
import { meter } from "mainsdk";

meter.record("exports", 1, { feature: "export" });
const month = await meter.usage({ metric: "exports", window: "month" });   // { value, unit, from, to, stale }

Pass an idempotencyKey where a step can retry, so it counts once.

4. Say what each plan includes

Declare plans. A limit that reads a meter counter needs a window.

main.yaml
plans:
  free: { features: [basic], limits: { exports: 20/month } }
  pro:
    features: [basic, export]
    limits: { exports: 1000/month }
    prices: [price_pro_monthly]
entitlements:
  default: free
  upgrade: /settings/billing
  usage:
    exports: meter:exports
app/api/export/route.ts
import { entitlements } from "mainsdk";

const buildExport = async () => ({ rows: 0 });

entitlements.require("export");                                     // MAIN-PLAN-0001 (403) on free
await entitlements.within("exports", 1, () => buildExport());       // MAIN-PLAN-0002 (403) at the limit
const { limit, used, remaining } = await entitlements.limit("exports");

Every refusal carries an upgrade hint with the plans that include it and your upgrade link.

5. Let a subscription set the plan

When pay verifies a subscription.updated event whose price is listed under a plan's prices and whose metadata.tenant names the tenant, the tenant moves to that plan. To set a plan by hand:

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

await tenant.system("sales deal", () =>
  entitlements.set("tnt_acme", "pro", { reason: "annual deal", trial: "14d" }),
);

Verify

bash
pnpm mainsdk check
pnpm test

check fails on an undeclared policy, quota or metric in code, on a feature or limit no plan declares, and on driver: local in production without single-instance: true for limits. A test with createTestApp can prove the behaviour:

ts
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { entitlements, limits } from "mainsdk";

const app = createTestApp({ main: "./main.yaml" });
await app.asTenant({ id: "tnt_free" }, async () => {
  assert.ok(await limits.enforce("export", { key: "u1" }));
  assert.equal(entitlements.has("export"), false);
});

If it fails

CodeCauseFix
MAIN-LIMIT-0006A policy or quota name is not declaredDeclare it under limits
MAIN-LIMIT-0007A policy has a bad max, per, algorithm or burstFix the key the error names
MAIN-CONFIG-0004A policy declares key:, which the loader reads as a secret fieldRemove it and pass { key } on the call
MAIN-LIMIT-0008limits uses local in production without single-instanceUse redis, or set single-instance: true
MAIN-METER-0001A metric is not declared under meter.metricsDeclare it
MAIN-PLAN-0006plans is invalid, such as a meter-sourced limit with no windowWrite 20/month, and declare the metric
MAIN-PLAN-0008A feature or limit name that no plan declaresDeclare it under plans

For agents

text
Goal: protect an endpoint with limits, count usage with meter, and gate features by plan with entitlements.
Touch: main.yaml (limits, meter, plans, entitlements sections), the route handlers that enforce and record, tests.
Rules: declare every policy, quota, metric, feature and limit in main.yaml before using it. Pass { key } to limits calls. Key by user or tenant id, never an email. A meter-sourced limit needs a window such as 20/month. Never read the plan from the browser.
Verify: pnpm mainsdk check && pnpm test
Never weaken or skip a check; fix the finding it names.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet