---
title: "Limits, metering and entitlements"
description: "Rate-limit an endpoint, cap usage with a quota, meter what each tenant uses and gate features and limits by plan."
section: "guides"
status: "Building for 0.3"
spec: "limits"
canonical: "https://mainsdk.dev/docs/guides/limits-and-metering"
updated: "2026-10-28"
---

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

Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/limits-and-metering · 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

- A project with `main.yaml` and tenancy on (see [Build a multi-tenant app](https://mainsdk.dev/docs/guides/build-a-multi-tenant-app)).
- For plans set by payments: a [`pay`](https://mainsdk.dev/docs/guides/take-a-payment) driver. Optional here.

## 1. Rate-limit an endpoint

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

```yaml title="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 }
```

```ts title="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.

```ts title="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.

```yaml title="main.yaml"
meter:
  driver: local               # use db or redis in production
  metrics:
    exports: { unit: export, aggregate: sum }
```

```ts title="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.

```yaml title="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
```

```ts title="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:

```ts title="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

| Code | Cause | Fix |
| --- | --- | --- |
| `MAIN-LIMIT-0006` | A policy or quota name is not declared | Declare it under `limits` |
| `MAIN-LIMIT-0007` | A policy has a bad `max`, `per`, `algorithm` or `burst` | Fix the key the error names |
| `MAIN-CONFIG-0004` | A policy declares `key:`, which the loader reads as a secret field | Remove it and pass `{ key }` on the call |
| `MAIN-LIMIT-0008` | `limits` uses `local` in production without `single-instance` | Use `redis`, or set `single-instance: true` |
| `MAIN-METER-0001` | A metric is not declared under `meter.metrics` | Declare it |
| `MAIN-PLAN-0006` | `plans` is invalid, such as a meter-sourced limit with no window | Write `20/month`, and declare the metric |
| `MAIN-PLAN-0008` | A feature or limit name that no plan declares | Declare 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

- [Rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits)
- [Usage metering](https://mainsdk.dev/docs/interfaces/meter)
- [Plans and entitlements](https://mainsdk.dev/docs/interfaces/entitlements)
