Recipes

Meter usage events

Send usage events from your app, count them with meters, and bill each combination of values from a rate card. Retry-safe keys, batches of 500, late events and decimals.

Your app sends usage events as things happen: a request served, a file stored, tokens used. A meter says which events to count and how. A price on the meter bills what it counts, with a rate for each combination of values. You do not need to know subscription item ids: an event names the customer by a key your app already has.

This is the event path. The older path, one usage record for each subscription item, is in Usage-based billing and works as before.

Before you begin

You need a test API key and your base URL, set up as in the Quickstart.

setup.ts
import { Tmbilling } from "@two-minute-billing/sdk";

const tmb = new Tmbilling({
  apiKey: process.env.TMBILLING_API_KEY!,
  baseUrl: process.env.TMBILLING_URL,
});

Create a meter

A meter reads one event name. This one adds up the gb property of storage_used events and keeps one total for each region:

const biz = await tmb.businesses.create({ name: "Fernhill Storage", default_currency: "USD" });

const meter = await tmb.meters.create(biz.id, {
  id: "storage_gb",
  name: "Storage written",
  event_name: "storage_used",
  aggregation: "sum",
  value_property: "gb",
  group_by: ["region"],
});
aggregationThe period billsNeeds
sumThe total of the valuesvalue_property
countThe number of eventsnothing
min / maxThe lowest or highest valuevalue_property
avgThe total of the values ÷ the number of eventsvalue_property
lastThe value of the last eventvalue_property
uniqueThe number of different values of one propertyunique_property
weighted_sumA level × the hours (or days) it stayedvalue_property, time_unit
  • filters counts only some events: { "region": ["eu", "us"] } skips every other region.
  • group_by takes up to 5 property names. A price can set a rate for each combination.
  • Several meters can read the same event name. A new meter also counts the events that we already keep, so you can add one later.
  • After the first counted event or the first price, only name can change. To count in a different way, create a new meter.

Price the meter

A price on a meter takes its aggregation from the meter. unit_amount_decimal is the rate in minor units, up to 12 decimal places, per quantity_divisor units. This price bills 2 cents per GB, and 3 cents in ap-south:

const product = await tmb.products.create(biz.id, { name: "Storage" });
const storage = await tmb.prices.create(biz.id, {
  product: product.id,
  name: "Storage monthly",
  model: "usage",
  currency: "USD",
  interval: "month",
  meter: meter.id,
  unit_amount_decimal: "2",
  rates: [{ match: { region: "ap-south" }, unit_amount_decimal: "3" }],
});

A combination takes the rate row that names the most values, then the oldest row. A value that no row names takes the price's own rate, so a new region still bills. Each combination is one invoice line, rounded half-up to the cent once. Models tiered, volume and package also work on a meter. A customer can have one live subscription that bills a meter; a second one is refused with 409.

Name the customer with a usage key

An event names its customer with customer: the cus_… id, or any usage key you add to the customer. Use an id your app already has, such as a workspace or account id.

const customer = await tmb.customers.create(biz.id, {
  email: "[email protected]",
  currency: "USD",
});
await tmb.paymentMethods.create(biz.id, customer.id, { token: "4242424242424242" });
await tmb.usageKeys.add(biz.id, customer.id, "workspace-4821");
await tmb.subscriptions.create(biz.id, customer.id, { items: [{ price: storage.id }] });

An event whose key matches no customer is not lost: we keep it as unmatched. When you add the key to a customer, we match those events and bill them. A key belongs to one customer.

Send events

Each event has its own key. A repeat of a key is a duplicate: it changes nothing, even with a different timestamp or value. So you can retry a send whenever you are not sure it arrived. These routes do not use the Idempotency-Key header.

const one = await tmb.usageEvents.send(biz.id, {
  key: "upload-7f3a91",
  customer: "workspace-4821",
  event_name: "storage_used",
  properties: { region: "eu", gb: "12.5" },
});

const batch = await tmb.usageEvents.sendBatch(biz.id, [
  { key: "upload-7f3a92", customer: "workspace-4821", event_name: "storage_used", properties: { region: "eu", gb: 3 } },
  { key: "upload-7f3a93", customer: "workspace-4821", event_name: "storage_used", properties: { region: "ap-south", gb: 8 } },
]);
console.log(one.status, batch.created, batch.duplicates, batch.errors);
Response
{
  "status": "created",
  "event": {
    "object": "usage_event",
    "key": "upload-7f3a91",
    "customer": "cus_XXXXXXXXXXXX01",
    "customer_key": "workspace-4821",
    "event_name": "storage_used",
    "timestamp": "2026-11-01T09:30:12.000Z",
    "properties": { "region": "eu", "gb": "12.5" },
    "status": "matched",
    "meters": ["storage_gb"],
    "flags": []
  }
}

A batch takes 1 to 500 events and answers with one result for each, in order. One bad event does not stop the others.

Rules for events

  • timestamp is RFC 3339 with Z or an offset. It can be up to 35 days in the past and 5 minutes in the future. Outside that, the event is refused with timestamp_too_old or timestamp_in_future. With no timestamp, we use the time we receive it.
  • A meter's value property must be a number with at most 12 decimal places. More places, or a value that is not a number, is refused with invalid_value. We never round it or read it as 0. We read a JSON number from its exact text, so 0.123456789012 keeps every digit.
  • properties is flat: up to 50 keys of string, number or boolean.
  • An event name with no meter is kept, with the flag no_meter, so a meter that you add later counts it.

Rate limit

The limit is per organization, shared by all its API keys: 100 requests a second by default. A batch is one request, so batches of 500 carry 50,000 events a second. Every answer has X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. A 429 adds Retry-After.

When usage bills

A meter line bills the exact period of the subscription, to the microsecond. Usage closes about 6 minutes after the period ends, and then the renewal invoice bills it. If your app keeps events for some minutes before it sends them, set usage_close_delay_minutes (0 to 24) on the business. The total wait is never more than 30 minutes.

An event that arrives after its period closed is still billed: it goes on the next invoice as a line "Usage from 1–31 Oct 2026", dated to its own period.

Read and correct events

const unmatched = await tmb.usageEvents.list(biz.id, { meter: "storage_gb", status: "unmatched" });
await tmb.usageEvents.void(biz.id, { key: "upload-7f3a93" });

You can void an event until an invoice that bills its period is finalized. The event stays on record with voided_at. Send a replacement with a new key. After finalization, issue a credit note instead.

On the invoice

Every invoice line has quantity_decimal and unit_amount_decimal (strings). Read those: a meter line carries its true quantity and sub-cent rate there, and keeps quantity: 1 with the line amount in unit_amount_minor for older clients. The line's rating names the meter, the group values and the rate row that billed it.

Next

  • Bill AI tokens: a rate card per model and token type, and the SDK helper that turns an AI response into events.
Copyright © 2026