MainSDK
Search⌘K
Interfaces

Payments and plans

Usage metering

meter

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

Building for 0.3ships as a beta APIPhase 1 · Core, public betaSpec meterFree 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

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

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

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)

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

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

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

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

Codes

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

Features

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

FeaturePhaseMilestoneSpec ticket
meter@1 contract and declared metricsPhase 10.3meter#contract
Idempotent records per tenantPhase 10.3meter#record
Usage by hour, day, month and rangePhase 10.3meter#usage
Local, db and redis drivers by regionPhase 10.3meter#drivers
Buffered records and stale usage when the store is downPhase 10.3meter#fail-static
Export to pay for usage billingPhase 10.3meter#export
Pseudonymised user ids on erasurePhase 10.3meter#privacy
Usage source for plan limitsPhase 10.3meter#entitlements-source

Quick start

main.yaml
meter:
  driver: local
  metrics:
    api_calls: { unit: call, aggregate: sum }
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

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
KeyWhat it does
driverlocal 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.
keepHow long raw records are kept. Hourly totals last as long as the tenant.
exportSends 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

CallWhat 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

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

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.

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.

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

CodeWhenFix
MAIN-METER-0001A metric is not declared under meter.metricsDeclare it
MAIN-METER-0002A 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-0003The buffer was full, so the oldest records were dropped (logged)Raise buffer, or fix the store
MAIN-METER-0004Usage was served from the last known value because the store is unreachable (stale: true)Restore the store
MAIN-METER-0005An export to pay failed for a tenant and hour; it is retried (logged)Check the pay driver
MAIN-METER-0006meter is invalid, or local is used in productionFix the key the error names; use db or redis
MAIN-METER-0007No store for the tenant's region; the record is refusedAdd meter.redis.<region>
MAIN-METER-0008A 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.

Swapping drivers

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.

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.

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.

FAQ

Does meter charge the customer? No. It records usage; your billing provider prices and charges it, through 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 reads meter.usage.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet