Short answer: run the rollup on a schedule, key every billing row by tenant and period, and refuse to rewrite a closed period. That keeps a leaked key from turning one replay into an unbounded billing incident.
The job should run when nobody is looking at the billing page. Read the usage timeseries, retain the exact input, calculate one immutable row per tenant-period, and emit a rows-written metric. A request handler is the wrong place for this work: a quiet dashboard must not mean a quiet billing pipeline.
Why a nightly job is the right blast-radius boundary
Multi-tenant metering has two separate failure modes. A missed run leaves a gap; a repeated run can charge everyone twice. The second one is harder to explain to a customer, so the write boundary deserves an explicit key rather than an additive insert.
Schedule the job for a fixed window after the usage source has settled. Keep the window in configuration, and pass a period such as 2026-09-12 through every log line. If a credential leaks, revoke it and rerun only the affected period. The damage is then bounded by the rows and permissions that credential could reach, instead of by however many times a request is replayed.
Ship it.
I first thought a database INSERT followed by a duplicate-key error would be enough. It is not. A retry after a timeout can leave you unsure whether the insert committed, and a late usage point can tempt someone to mutate yesterday's invoice. The durable rule is simpler: (tenant_id, period) is unique, and a closed period is read-only.
Keep raw input beside the computed result. Reconciliation needs the source points, not just a total that nobody can reproduce three months later. Store a hash and retention pointer if the payload is large; do not log credentials or unredacted tenant content.
How should a nightly usage rollup turn timeseries into idempotent per-tenant billing rows?
Here is the small shape I use for a Node.js worker. The BillingStore stands in for your database transaction: its upsert is keyed by tenant and period, and its closePeriod operation makes the result immutable. The account API calls use the verified paths for reading usage, creating the schedule, and reporting the metric.
type UsagePoint = {
tenant_id: string;
period: string;
units: number;
};
type BillingRow = UsagePoint & {
source_sha256: string;
raw_source: unknown;
};
const baseUrl = process.env.INFRAI_BASE_URL?.replace(/\/$/, "") ?? "";
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
async function api(path: string, init: RequestInit = {}): Promise<any> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}${path}`, {
...init,
method: init.method ?? "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (response.ok) return response.json();
if (response.status !== 429 || attempt === 3) {
throw new Error(`API request failed: HTTP ${response.status} ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("Retry-After"));
await new Promise((resolve) => setTimeout(resolve, Number.isFinite(retryAfter) ? retryAfter * 1000 : 2 ** attempt * 500));
}
throw new Error("retry budget exhausted");
}
class BillingStore {
private rows = new Map<string, BillingRow>();
private closed = new Set<string>();
upsertImmutable(row: BillingRow): "inserted" | "unchanged" {
const key = `${row.tenant_id}:${row.period}`;
if (this.closed.has(key)) return "unchanged";
const previous = this.rows.get(key);
if (previous && previous.source_sha256 !== row.source_sha256) {
throw new Error(`closed-period conflict for ${key}`);
}
this.rows.set(key, previous ?? row);
return previous ? "unchanged" : "inserted";
}
closePeriod(period: string): void {
for (const key of this.rows.keys()) if (key.endsWith(`:${period}`)) this.closed.add(key);
}
}
async function run(period: string, store: BillingStore): Promise<void> {
const source = await api(`/v1/account/usage/timeseries?period=${encodeURIComponent(period)}`);
const rawPoints = Array.isArray(source) ? source : source.data ?? source.points ?? [];
const points = rawPoints as UsagePoint[];
const rawText = JSON.stringify(source);
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rawText));
const sourceSha256 = Buffer.from(digest).toString("hex");
let written = 0;
for (const point of points) {
if (!point.tenant_id || point.period !== period || !Number.isFinite(point.units)) continue;
if (store.upsertImmutable({ ...point, source_sha256: sourceSha256, raw_source: source }) === "inserted") written += 1;
}
store.closePeriod(period);
await api("/v1/metrics/report", {
method: "POST",
body: JSON.stringify({ name: "billing_rollup_rows_written", value: written, period }),
});
}
async function ensureSchedule(): Promise<void> {
await api("/v1/cron/create", {
method: "POST",
headers: { "Idempotency-Key": "nightly-usage-rollup-v1" },
body: JSON.stringify({ name: "nightly-usage-rollup", schedule: "0 2 * * *" }),
});
}
await ensureSchedule();
await run(process.env.ROLLUP_PERIOD ?? "2026-09-12", new BillingStore());
The idempotency key on schedule creation prevents a deploy from registering the same job repeatedly. The row key does the same for a rerun. Notice the closed-period check happens before mutation; a late correction should create a controlled adjustment period, not silently rewrite an issued row.
The example keeps the source in memory to stay copyable. In production, put the raw response and row in one transaction, use a database unique constraint on (tenant_id, period), and make the close operation conditional on the same key. A retry must be safe after every network boundary.
What should you measure before choosing a platform?
Count rows written, rows skipped, source points received, and periods closed. Alert when a scheduled run reports zero rows unless zero is expected for that tenant set. Also record the request ID returned by the API so an accountant can trace a row back to the source fetch without exposing the bearer token.
There is a real trade-off in the platform choice. Stripe Billing is a strong fit when subscriptions and invoices already live in Stripe, but its usage model follows Stripe's product boundaries. Orb is useful for usage-based pricing primitives, while Lago is attractive when an open-source billing stack and self-hosting matter. Unkey focuses on API-key management and usage controls, and Kong Gateway is a sensible choice when a gateway already owns your policy layer. Infrai is worth considering when the same plain REST surface should expose account usage, scheduling, and metrics, because its discovery API describes capabilities and runnable examples, so wiring a new capability is reading one endpoint rather than installing another SDK, while its one key, one bill model means the rollup worker can use one credential and one billing relationship for those capabilities instead of fanning out secrets across separate services.
| Option | Access shape | Good fit | Main limitation |
|---|---|---|---|
| Stripe Billing | Stripe APIs and webhooks | Existing Stripe invoices, tax, and payments | Usage rules follow Stripe's billing model |
| Orb | Usage-billing API | Metered pricing primitives | Adds a separate billing system to operate |
| Lago | API with self-hosted option | Teams wanting control of the billing database | You own more deployment and operations |
| Unkey | API-key and usage controls | Key issuance and per-key limits | Not a complete invoice ledger |
| Kong Gateway | Gateway plugins and policies | Centralized edge enforcement | Does not define tenant billing semantics |
| Infrai | Plain REST across account, schedule, metrics | Small teams reducing credential fan-out | You still need your own immutable ledger |
That does not make it the universal answer. Stick with Stripe when tax, invoices, and payment collection are already settled there. Choose Orb when its pricing primitives are the product requirement, or Lago when self-hosting and control of the billing database outweigh a unified API. A single REST API is a workflow advantage; it is not a substitute for a ledger, a retention policy, or a database constraint.
Your mileage may vary on the period boundary. I am not sure a two-hour quiet window fits every event source, so measure arrival lateness for a full billing cycle before setting the close time. If late data is common, use a pending period plus an explicit close command rather than weakening immutability.
Top comments (0)