Meter usage events
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.
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"],
});
curl -X POST $TMBILLING_URL/v1/businesses \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "name": "Fernhill Storage", "default_currency": "USD" }'
export BIZ=biz_…
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/meters \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"id": "storage_gb",
"name": "Storage written",
"event_name": "storage_used",
"aggregation": "sum",
"value_property": "gb",
"group_by": ["region"]
}'
aggregation | The period bills | Needs |
|---|---|---|
sum | The total of the values | value_property |
count | The number of events | nothing |
min / max | The lowest or highest value | value_property |
avg | The total of the values ÷ the number of events | value_property |
last | The value of the last event | value_property |
unique | The number of different values of one property | unique_property |
weighted_sum | A level × the hours (or days) it stayed | value_property, time_unit |
filterscounts only some events:{ "region": ["eu", "us"] }skips every other region.group_bytakes 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
namecan 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" }],
});
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/products \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "name": "Storage" }'
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/prices \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"product": "storage",
"name": "Storage monthly",
"model": "usage",
"currency": "USD",
"interval": "month",
"meter": "storage_gb",
"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 }] });
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/customers \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]", "currency": "USD" }'
export CUS=cus_…
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/customers/$CUS/payment_methods \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "token": "4242424242424242" }'
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/customers/$CUS/subscriptions \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "items": [{ "price": "storage-monthly" }] }'
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/customers/$CUS/usage_keys \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "key": "workspace-4821" }'
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);
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/usage_events \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"key": "upload-7f3a91",
"customer": "workspace-4821",
"event_name": "storage_used",
"properties": { "region": "eu", "gb": "12.5" }
}'
{
"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
timestampis RFC 3339 withZor an offset. It can be up to 35 days in the past and 5 minutes in the future. Outside that, the event is refused withtimestamp_too_oldortimestamp_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, so0.123456789012keeps every digit. propertiesis 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" });
curl "$TMBILLING_URL/v1/businesses/$BIZ/usage_events?meter=storage_gb&status=unmatched" \
-H "Authorization: Bearer $TMBILLING_API_KEY"
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.
India mandates
Charge Indian subscriptions off-session through Razorpay with netbanking e-mandates and card mandates: mandate limits, pre-debit notices and revoked mandates.
Bill AI tokens
Charge for AI usage per model and token type: one meter, a rate card or the model price list with a markup, your cost and margin, and an SDK helper that reports each model response once.