Getting started
The code on this page comes from docs/src/snippets/getting-started.test.ts and runs with bun run test, so the page cannot show code that does not work.
Install
Section titled “Install”bun add pricemeter zod # or npm i / pnpm addzod is optional: dimension and context schemas can use any Standard Schema library (zod, valibot, arktype), or no schema at all. pricemeter has no runtime dependencies; it ships ESM + CJS and runs on Node ≥ 20, Bun and Workers.
1. Catalog, tariff, adapter
Section titled “1. Catalog, tariff, adapter”import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";import { z } from "zod";
// $0.02 per SMS; from the 10,000th message on, $0.015. Amounts are integer micro-units.const sms: Rate = { id: "sms-2026", model: "graduated", tiers: [ { from: 0, unitPriceMicroUsd: 20_000 }, { from: 10_000, unitPriceMicroUsd: 15_000 }, ],};
// getRate + commit kept in memory: prepaid, "acme" starts with $10.const mem = memoryAdapters({ rates: { "otp/sms": sms }, prepaid: true, balances: { acme: 10_000_000 } });
const metering = buildMetering() .context(z.object({ accountId: z.string() })) .refs(["otp_send"]) .meter("otp/sms", { country: z.string().length(2) }) .bind(mem); // same as .getRate(mem.getRate).commit(mem.commit)
const ctx = { accountId: "acme" };-
A tariff (
Rate) carries every tier.rate()decides which tier an observation lands in; amounts are integer micro-units (20_000= $0.02). -
memoryAdapters(pricemeter/testing) is an in-memorygetRate+commitfor tests and examples: it countsusedSoFarfrom the rows it wrote and, withprepaid: true, throwsInsufficientCreditwhen the balance is short. -
buildMetering()is one chain; there is nobuild()step..bind(mem)binds the catalog to these adapters; in production you pass your owngetRateandcommitinstead.
2. First observe
Section titled “2. First observe”const r = await metering.observe("otp/sms", { country: "TR" }, 2, { type: "otp_send", id: "msg_1" }, ctx);// → { ok: true, charged: 40000, lines: [{ meter: "otp/sms", quantity: 2, amount: 40000, tierIndex: 0, … }] }
mem.store.available("acme"); // 9_960_000mem.store.usage.length; // 1 usage row (refId "otp_send:msg_1:otp/sms")mem.store.ledger.length; // 1 ledger row (a "charge" of 40000)observe validates the context and dimensions, asks getRate for the tariff and usedSoFar, computes the amount with price(), hands the plan to commit and returns the result. The plan is two lists: ledger rows that move money and usage rows that record consumption.
3. Retries are safe
Section titled “3. Retries are safe”// A retry with the same ref computes the same plan; commit sees (refType, refId) again and writes nothing.await metering.observe("otp/sms", { country: "TR" }, 2, { type: "otp_send", id: "msg_1" }, ctx);mem.store.available("acme"); // still 9_960_000Every row’s refId is derived from the ref (otp_send:msg_1:otp/sms). When commit sees the same (refType, refId) again it writes nothing. The library does not know it is a retry — the result still says charged: 40000, “the price of this call”, not “the amount written” (A99) — but the balance does not move.
4. Failures are values
Section titled “4. Failures are values”await metering.observe("otp/sms", { country: "TUR" }, 1, { type: "otp_send", id: "msg_2" }, ctx);// → { ok: false, reason: "invalid_dims", meter: "otp/sms", cause: [...] } (zod: length 2)
await metering.observe("otp/sms", { country: "TR" }, 1_000, { type: "otp_send", id: "msg_3" }, ctx);// → { ok: false, reason: "insufficient_credit", … } ($20 > $9.96; nothing written)
mem.store.setRate("otp/sms", undefined); // the price table no longer has this meterawait metering.observe("otp/sms", { country: "TR" }, 1, { type: "otp_send", id: "msg_4" }, ctx);// → { ok: false, reason: "no_price", meter: "otp/sms" }When the result is { ok: false, reason }, nothing was written. The balance check is not in the library but inside commit: only the ledger can check “balance ≥ amount” in the same atomic step as the write. All reasons: observe and hold → Result.
5. See the plan without writing it
Section titled “5. See the plan without writing it”const { result, plan } = await metering.plan.observe("otp/sms", { country: "TR" }, 3, { type: "otp_send", id: "msg_5" }, ctx);// result.charged → 60000// plan.ledger → [{ op: "charge", account: "acme", amount: 60000, refType: "otp_send", refId: "otp_send:msg_5:otp/sms", at }]// plan.usage → [{ account: "acme", meter: "otp/sms", dims: { country: "TR" }, quantity: 3, amount: 60000, rateId: "sms-2026", … }]metering.plan.* takes the same arguments, never calls commit, and returns { result, plan, lines }. Use it in tests, or when the plan is applied somewhere else (e.g. inside a Durable Object).
- The “how much / when” boundary: what lives in the library and what stays in your app.
- Catalog and Tariffs and models.
- Writing adapters: a
commitfor a real database.