---
title: "Usage metering"
description: "Record what each tenant uses, read it back by hour, day or month, and send closed hours to your billing provider through pay."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "meter"
canonical: "https://mainsdk.dev/docs/interfaces/meter"
updated: "2026-10-28"
---

# Usage metering

> Record what each tenant uses, read it back by hour, day or month, and send closed hours to your billing provider through pay.

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/meter · Updated: 2026-10-28

`meter` records what each tenant uses, idempotently and without slowing a request, so you can show a usage page, enforce a plan limit or bill by usage.

## At a glance

- Status: Building for 0.3 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/meter.md
- Category: Payments and plans

Calls:

- `meter.record(metric, value?, { feature?, apikey?, user?, at?, idempotencyKey? })`
- `meter.usage({ metric, window, tenant?, groupBy? })`
- `meter.flush()`
- `localMeter(o)`
- `meterIssues(raw, opts?)`
- `parseMeter(raw)`
- `meter.close()`
- `meter.erase(user)`
- `meter.exportDue(tenants)`
- `meter.exportUser(user)`
- `meter.pseudonym(user)`
- `meter.startExport(list)`

In main.yaml:

```yaml
meter:
  driver: db
  metrics:
    api_calls: { unit: call, aggregate: sum }
    active_users: { unit: person, aggregate: unique, of: user }
  export:
    to: pay
    every: 1h
    metrics: { api_calls: { meter: api_calls } }
```

Guarantees, whatever the driver:

- A retried step, job or webhook records once
- Recording never waits on the store and never throws because it is down
- Records stay in the tenant's region; only hourly totals go to the billing provider
- A person's id in usage data is pseudonymised when they are erased


## Drivers

Pick one in main.yaml with `meter.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Your database (the tenant's region store) | `db` | yes |  |
| Redis (built-in RESP client) | `redis` |  |  |
| Local (tests and mainsdk dev) | `local` |  |  |
| Stripe Billing meters (export through pay) | `stripe-meters` |  |  |
| Lago | `lago` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-METER-0001` | check | A metric not declared under `meter.metrics` |
| `MAIN-METER-0006` | check | `meter` is invalid: unknown aggregate, `unique` without `of`, an exported metric with no provider meter, `keep` under 35 days, or `local` in production |
| `MAIN-METER-0002` | runtime | A value that isn't finite, is negative for `sum` or `count`, or an `at` outside its window (400) |
| `MAIN-METER-0003` | runtime | The buffer was full while the store was down, so the oldest records were dropped (logged with the count) |
| `MAIN-METER-0004` | runtime | Usage was served from the last known value because the store can't be reached (`stale: true`) |
| `MAIN-METER-0005` | runtime | An export to `pay` failed for a tenant and hour; retried with the same identifier |
| `MAIN-METER-0007` | runtime | No meter store for the tenant's region; the record is refused, never written elsewhere |
| `MAIN-METER-0008` | runtime | A usage window is invalid: `from` after `to`, not on whole hours, or over 13 months (400) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| meter@1 contract and declared metrics | 1 | 0.3 | `meter#contract` |
| Idempotent records per tenant | 1 | 0.3 | `meter#record` |
| Usage by hour, day, month and range | 1 | 0.3 | `meter#usage` |
| Local, db and redis drivers by region | 1 | 0.3 | `meter#drivers` |
| Buffered records and stale usage when the store is down | 1 | 0.3 | `meter#fail-static` |
| Export to pay for usage billing | 1 | 0.3 | `meter#export` |
| Pseudonymised user ids on erasure | 1 | 0.3 | `meter#privacy` |
| Usage source for plan limits | 1 | 0.3 | `meter#entitlements-source` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
meter:
  driver: local
  metrics:
    api_calls: { unit: call, aggregate: sum }
```

```ts title="lib/usage.ts"
import { meter } from "mainsdk";

// call this once the request has succeeded; it returns { id, queued: true } at once
meter.record("api_calls", 1, { feature: "export" });

const usage = await meter.usage({ metric: "api_calls", window: "day" });
console.log(usage.value, usage.unit);   // { metric, value, unit, from, to, stale }
```

## Configuration

```yaml title="main.yaml"
meter:
  driver: db                       # local | db | redis; default: the runtime store's driver
  redis:                           # redis driver: one store per region
    eu: { url: secret:REDIS_EU_URL }
  metrics:
    api_calls:     { unit: call, aggregate: sum }
    storage_gb:    { unit: GB, aggregate: max }
    active_users:  { unit: person, aggregate: unique, of: user }
  keep: 400d                       # raw records; at least 35d
  flush: 1s                        # how often buffered records are written
  buffer: 10000                    # records held in memory while the store is down
  export:
    to: pay
    every: 1h
    late: 1h
    metrics:
      api_calls: { meter: api_calls }    # the provider's meter name
```

| Key | What it does |
| --- | --- |
| `driver` | `local` keeps records in process, for tests and one-instance dev. `db` stores them in the tenant's region. `redis` uses one store per region. |
| `metrics.<name>` | Declares a metric. `aggregate` is `sum`, `count`, `max`, `last` or `unique`; `unique` needs `of: user`, `feature` or `apikey`. |
| `keep` | How long raw records are kept. Hourly totals last as long as the tenant. |
| `export` | Sends closed hours to the `pay` driver's usage API. See below. |

A metric you have not declared throws `MAIN-METER-0001`. Every call runs inside a tenant when tenancy is on.

## What you get

- `meter.record()` returns at once and never throws because the store is down. A retried step, job or webhook records once.
- Usage by hour, day, month or range, split by feature, API key or user, in the tenant's region.
- Closed hours go to your billing provider through `pay`, with a total per tenant, metric and hour. A person's id in usage is pseudonymised when they are erased.
- Reach for it when your pricing has an included allowance and a price per unit beyond it, such as API calls, audio minutes or storage. It runs in your app on your own store and is free.

### Reference

| Call | What it does |
| --- | --- |
| `meter.pseudonym(user)` | The keyed hash (`u_` plus 24 hex characters) that stands for a user in totals and groups. Use it to look a person up in grouped usage. It needs the same secret on every instance |
| `meter.exportUser(user)` | The raw records that name a person, for the person's data export |
| `meter.erase(user)` | Replaces a person's id in raw records with its keyed hash, so totals and unique counts stay right. Returns `{ records }`, the number rewritten |
| `meter.exportDue(tenants)` | Sends every closed, unexported hour of every exported metric to the billing provider. Takes `{ id, region }[]` and returns `{ sent, skipped, failed }`. A tenant with no billing customer is skipped |
| `meter.startExport(list)` | Runs `exportDue` every `meter.export.every` for the tenants `list` returns. It stops on `close` |
| `meter.close()` | Stops the timers and tries to write what is buffered. Call it on shutdown |
| `localMeter({ now? })` | A meter in this process for tests: any metric counts as a sum and a record is visible to `usage` at once |
| `parseMeter(raw)` | Reads and checks the `meter` section; `MAIN-METER-0006` names every invalid key |
| `meterIssues(raw, { env? })` | The problems in a `meter` section as a list. With `env: "prod"`, the `local` driver is one too, as `mainsdk check` reports it |

## Examples

### Meter AI requests per tenant

```ts title="lib/summarise.ts"
import { ai, meter } from "mainsdk";

export async function summarise(text: string, user: { id: string }) {
  const summary = await ai.generate({
    model: "anthropic/claude-sonnet",
    prompt: `Summarise: ${text}`,
    feature: "summary",
    user,
  });
  meter.record("ai_summaries", 1, { feature: "summary", user: user.id });
  return summary;
}
```

Declare `ai_summaries` under `meter.metrics` first.

### Read usage split by feature

```ts title="app/usage/page.tsx"
import { meter } from "mainsdk";

const usage = await meter.usage({ metric: "api_calls", window: "month", groupBy: ["feature"] });
for (const g of usage.groups ?? []) console.log(g.key.feature, g.value);
```

`window` is `"hour"`, `"day"`, `"month"` (UTC calendar windows) or `{ from, to }` on whole hours, at most 13 months. Another tenant's usage needs `tenant.system()`.

### Count once when a step retries

Pass your own `idempotencyKey` when you have a natural one. Inside a workflow step, job or inbound webhook, the key defaults from that context. Outside any of them, with no key, nothing is deduplicated.

```ts title="lib/transcribe.ts"
import { meter } from "mainsdk";

export function countMinutes(jobId: string, minutes: number) {
  meter.record("audio_minutes", minutes, { idempotencyKey: `transcribe:${jobId}` });
}
```

Duplicates are dropped when the batch is written (every `flush`), so a total read in the same second can briefly count a repeat.

### Send usage to your billing provider

With `meter.export`, a scheduled job sends each tenant's total for every closed hour to the `pay` driver, with the identifier `<tenant>:<metric>:<hour>` so a retry counts once. Only totals leave the region: no user ids, API key ids or features.

```ts title="lib/billing.ts"
import { pay } from "mainsdk";

// the same call the export job makes; the identifier makes a retry count once
await pay.reportUsage({
  customer: "cus_123",           // the provider's customer id
  meter: "api_calls",            // the provider's meter name
  value: 1200,
  at: Date.UTC(2026, 9, 27, 10),
  identifier: "tnt_acme:api_calls:2026-10-27T10",
});
```

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-METER-0001` | A metric is not declared under `meter.metrics` | Declare it |
| `MAIN-METER-0002` | A value is not a finite number, is negative for `sum` or `count`, or `at` is out of range (400) | Pass a valid value |
| `MAIN-METER-0003` | The buffer was full, so the oldest records were dropped (logged) | Raise `buffer`, or fix the store |
| `MAIN-METER-0004` | Usage was served from the last known value because the store is unreachable (`stale: true`) | Restore the store |
| `MAIN-METER-0005` | An export to `pay` failed for a tenant and hour; it is retried (logged) | Check the `pay` driver |
| `MAIN-METER-0006` | `meter` is invalid, or `local` is used in production | Fix the key the error names; use `db` or `redis` |
| `MAIN-METER-0007` | No store for the tenant's region; the record is refused | Add `meter.redis.<region>` |
| `MAIN-METER-0008` | A usage window is invalid (400) | Use whole hours, `from` before `to`, at most 13 months |

`mainsdk check` reports `MAIN-METER-0001` for a literal metric name in code that is not declared, and `MAIN-METER-0006` for `local` with `--env prod`. See [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

```yaml title="main.yaml"
meter:
  driver: redis   # was db
  redis:
    eu: { url: secret:REDIS_EU_URL }
```

Your `meter.record()` calls stay the same. Past usage lives in the old store, so moving drivers is a data migration, not a one-line switch. Billing customers and past invoices live in your provider. See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver).

## Testing

`createTestApp` puts `meter` on its in-memory driver with a fake clock. Every call runs inside a tenant, so wrap it in `app.asTenant()`. `meter.flush()` writes the buffer, so a total read after it is exact.

```ts title="lib/usage.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { meter } from "mainsdk";

describe("usage", () => {
  it("counts a retried record once", async () => {
    const app = createTestApp({ main: "./main.yaml" });   // metrics: api_calls { aggregate: sum }
    await app.asTenant({ id: "tnt_a" }, async () => {
      meter.record("api_calls", 1, { idempotencyKey: "req-1" });
      meter.record("api_calls", 1, { idempotencyKey: "req-1" });
      await meter.flush();
      assert.equal((await meter.usage({ metric: "api_calls", window: "day" })).value, 1);
    });
  });
});
```

## Security and privacy

- The `user` dimension is personal data. When a person is erased, it is replaced in raw records by a keyed hash, so totals and `unique` counts stay right.
- Records stay in the tenant's region, and a record is never written to another region.
- `meter` is for your app's billing. It is separate from how MainSDK Cloud counts its own usage; see [Free in the app, paid in the Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud).

## FAQ

**Does `meter` charge the customer?** No. It records usage; your billing provider prices and charges it, through [`pay`](https://mainsdk.dev/docs/interfaces/pay).

**How is this different from analytics?** `meter` holds exact, idempotent billing facts. Analytics events are product signals and are approximate.

**Can a plan limit read the meter?** Yes. A limit whose source is `meter:<metric>` in [`entitlements`](https://mainsdk.dev/docs/interfaces/entitlements) reads `meter.usage`.

## Next

- [Limits, metering and entitlements](https://mainsdk.dev/docs/guides/limits-and-metering)
- [Payments](https://mainsdk.dev/docs/interfaces/pay)
- [Rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits)
