Skip to content

Metering high-frequency usage with the SDK

Use the TypeScript SDK's LeaseManager to charge thousands of small usages a minute against a customer's prepaid wallet, with local metering, batched settles, renewals and a final settle on shutdown.

When your service charges a customer's prepaid credits for every model call, API request or batch of tokens, a call to Ownrecord per charge is too slow. Leases solve this: you reserve a block of the customer's wallet, meter against it locally, and settle in batches. The TypeScript SDK's LeaseManager does the bookkeeping for you. It acquires and renews leases, never grants more than a lease holds, settles on a schedule and settles everything when your service stops.

Set it up

Create one manager per customer wallet in each process, with a secret API key that has wallets:write:

TypeScript
import { LeaseManager } from "@ownrecord/sdk";

const leases = new LeaseManager({
  apiKey: process.env.OWNRECORD_API_KEY,
  walletId: "wal_01m4ty3c2b5qx7mhd9k8w6s0b1",
  currency: "EUR",
  amount: 5_000_000n, // lease 5.00 at a time
  ttlSeconds: 300,
});

Every amount is integer micros of the wallet's currency as a bigint: 5_000_000n is 5.00, 2_000n is 0.002. Never use floating-point numbers for money.

The manager uses web standard APIs only (fetch, crypto, timers), so it runs in Node.js 18+, Deno, Bun and Cloudflare Workers.

Charge usage

Call consume before you serve a request, with what it costs:

TypeScript
const result = await leases.consume({
  meter: "api.calls",
  quantity: 1n,
  amount: 2_000n,
  attributes: { model: "large" },
});

if (!result.ok) {
  // Don't serve the request: result.reason says why.
}

The manager grants the usage only when the current lease can still hold it, and records it before it answers. The first call acquires a lease; call leases.start() at boot to do that earlier. When a consumption doesn't fit, the manager renews the lease with room for it and the call waits for the new lease, up to consumeTimeoutMs (10 seconds by default). Pass { wait: false } to get an answer at once instead.

reasonWhat it means
insufficient_leaseThe lease can't hold it right now and no bigger lease arrived in time, or you passed wait: false.
insufficient_fundsThe wallet can't fund a lease that holds it. Offer a top-up.
wallet_unavailableThe wallet is frozen or closed (code says which).
closedThe manager is shutting down.
errorOwnrecord or your store failed, or the meter was refused. code and error have the details.

After a lease couldn't be funded, the manager answers insufficient_funds for 5 seconds before it asks Ownrecord again, so an empty wallet doesn't cost you a request per call.

Meters are names you choose, such as api.calls: lowercase words separated by dots. Don't use a meter that a metered price bills in arrears. Ownrecord refuses it (usage_meter_billed_in_arrears), and the manager then drops that meter's usage and refuses it from then on. Up to 10 attributes describe a usage, and Ownrecord keeps one usage summary per meter and set of attributes. Malformed usage, such as an invalid meter name or a negative amount, throws a TypeError or RangeError.

Settling

The manager settles what was consumed since the last settle, with one usage summary line per meter and set of attributes. Each line becomes a usage event on the customer, with its amount. It settles:

  • every settleIntervalMs (5 seconds by default) while there is unsettled usage;
  • as soon as settleAmount is unsettled, if you set it;
  • when it renews or closes a lease;
  • when you call await leases.flush().

Only one request is in flight at a time. Settles are numbered (settleSeq), and a request that fails is resent unchanged with its Idempotency-Key until Ownrecord answers, so a settle is never charged twice. If another client settles the same lease, the manager numbers its settle after theirs and sends it again.

Renewals and sizing

A renewal is a settle that closes the lease and reserves the next one in the same request. The manager renews:

  • renewBeforeExpiryMs before the lease expires: a quarter of the TTL by default, at most 30 seconds;
  • when less than renewAtRemaining is left on a lease that is in use: a tenth of the lease by default;
  • when a consumption doesn't fit.

While a renewal is in flight, new consumption waits for the new lease, usually for one round trip. If renewals keep failing, the manager stops granting usage on the old lease when half of renewBeforeExpiryMs is left, so it still has time to settle.

Renewals and later leases ask for Ownrecord's suggestedNextAmount, what your current rate of consumption needs, kept between minAmount (default amount) and maxAmount (default 10 × amount). A consumption larger than that still gets a lease that holds it. Set sizing: "fixed" to always lease amount. When the wallet has less than a renewal asks for, the new lease holds what there is.

A lease that nobody used is closed when it would be renewed, so its funds go back to the wallet. The next consume acquires a new one. Set renewWhenIdle: true to keep renewing instead.

Shutting down

Settle everything before your process exits:

TypeScript
process.on("SIGTERM", async () => {
  const { flushed } = await leases.shutdown();
  process.exit(flushed ? 0 : 1);
});

shutdown stops granting usage, settles what is left with final: true and closes the lease, so its remaining funds go back to the wallet. It tries for shutdownTimeoutMs (15 seconds by default). If it can't reach Ownrecord in that time, flushed is false and unsettled says how much wasn't settled.

Surviving a crash

By default the manager keeps its consumption log in memory. If the process crashes, that log is lost, and the lease's expiry policy decides what happens to the usage that wasn't settled: release doesn't charge it, capture charges the whole lease.

To charge exactly what was used, keep the log in a durable store and lease with hold_for_late_settle:

TypeScript
const leases = new LeaseManager({
  apiKey: process.env.OWNRECORD_API_KEY,
  walletId,
  currency: "EUR",
  amount: 5_000_000n,
  expiryPolicy: "hold_for_late_settle",
  lateSettleWindowSeconds: 3_600,
  store: myRedisLeaseStore,
  name: `${walletId}:${workerId}`,
});

When a manager starts with the same store and name, it first resends the request the crashed process had in flight, with the same Idempotency-Key, then settles what the crashed process consumed. If the lease has expired meanwhile, that is a late settle inside the lease's window. Then it continues on a new lease.

A store implements five methods. Each may return a value or a promise:

TypeScript
interface LeaseStore {
  get(name: string): LeaseStoreState | undefined; // the manager's saved state (plain JSON)
  set(name: string, state: LeaseStoreState): void;
  incr(name: string, leaseId: string, line: string, quantity: bigint, amount: bigint): void; // atomic
  totals(name: string, leaseId: string): LeaseUsageTotal[]; // replays the lease's log
  clear(name: string, leaseId: string): void;
}

incr must add both numbers or neither, for example two HINCRBY in a Redis MULTI, or one SQL upsert. Give each running manager its own name: two live managers sharing a name would settle each other's usage.

Watching it

Pass onEvent to log or measure what the manager does:

EventWhen
lease_acquiredA new lease, at start or after one was closed.
settledA settle was applied (mode: open, renew or final), with what it charged.
lease_renewedMetering moved to the next lease.
lease_closedA lease closed without a successor (reason: final, idle, late, not_renewed, expired or refused).
usage_unsettledUsage that couldn't be charged on its lease, for example after lease_expired. chargedByExpiry says whether the expiry policy charged it anyway.
recoveredThe manager found a previous process's state in its store.
errorA request or the store failed. retrying is true when the manager will try again.

leases.lease shows the current lease: its amount, what was consumed and settled, and what is left.

Without the manager

createLeaseApi({ apiKey }) gives you the three lease calls, leases.create, leases.settle and leases.get, with amounts as bigint micros, retries on network errors, rate limits and server errors, and the same Idempotency-Key on every retry. Pass your own implementation of these calls to the manager as api to route them through another client.