Short answer: meter the platform's usage records, take an immutable snapshot for each customer and billing period, and generate invoices from that snapshot. A live read at invoice time is not an audit trail. It is a moving target.
That matters in a fintech service that rotates a production API key without downtime. The key rotation should be boring: overlap the old and new credentials, verify traffic, then revoke the old one. Billing should be just as boring. A scheduled snapshot must run even when nobody opens the billing page, and the resulting invoice must be reproducible months later.
The decision matrix for customer usage
| Option | Where the meter lives | Invoice source | Good fit | Main trade-off |
|---|---|---|---|---|
| Stripe Billing meters | Stripe event or meter stream | Stripe's invoice pipeline | Teams already deep in Stripe payments | Your product data and Stripe's event view need reconciliation |
| Orb | Orb usage events | Orb's ledger and invoice run | Usage-native pricing with a dedicated billing layer | Another system owns the canonical billing state |
| Lago | Lago events and billable metrics | Lago open-source or cloud ledger | Teams wanting an extensible billing engine | Operating the ledger is still your responsibility |
| Unkey or Kong Gateway | Gateway request counters | Your billing system | Teams metering API calls at the edge | Gateway telemetry still needs a customer-period ledger |
| Application snapshot | Your platform's usage record | Your immutable monthly snapshot | Per-customer rules and strict audit control | You own retries, storage, and reconciliation |
My default for a per-customer fintech bill is the last row: keep the platform record as the meter, copy the period total into an append-only snapshot, and bill that copy. Stripe, Orb, or Lago can be the better choice when payment collection, tax, or a hosted usage ledger is the problem you actually need to outsource. The choice is about custody of the evidence, not which dashboard looks nicer.
One sentence version: never calculate a past invoice from a live read.
What should a Node.js metered billing architecture store?
Store two related records, not one mutable counter. The platform usage record answers “what happened?” for a time range. Your snapshot answers “what did we agree to bill?” for a named customer and period. Give the snapshot a deterministic key such as customer_id + period_start + period_end, and enforce uniqueness in the database.
The snapshot should include the source query window, the platform record identifier, the measured quantity, the unit, the rate version, currency, and a hash of the normalized input. Keep the raw response as an immutable blob where access is private or signed-only. Do not put a public object URL in an invoice table. Billing data tends to contain account identifiers, and a public bucket turns a bookkeeping shortcut into a disclosure.
Here is the small TypeScript worker I would put behind a scheduler. It reads a bounded window and writes one idempotent snapshot per customer. The database call is represented by an interface so the example stays runnable without pretending a particular ORM is required.
type UsageWindow = {
customerId: string;
periodStart: string;
periodEnd: string;
};
type UsageSnapshot = UsageWindow & {
quantity: number;
unit: string;
source: "platform";
sourceRequestId: string;
};
type SnapshotStore = {
insertIfAbsent(snapshot: UsageSnapshot): Promise<void>;
};
async function readUsage(window: UsageWindow) {
const params = new URLSearchParams({
customer_id: window.customerId,
start: window.periodStart,
end: window.periodEnd,
});
const url = new URL("/v1/account/usage", process.env.ACCOUNT_API_BASE_URL);
url.search = params;
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
Accept: "application/json",
},
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? "2");
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
return readUsage(window);
}
if (!response.ok) {
throw new Error(`usage read failed: ${response.status} ${await response.text()}`);
}
return response.json() as Promise<{
quantity: number;
unit: string;
request_id: string;
}>;
}
export async function snapshotUsage(
window: UsageWindow,
store: SnapshotStore,
): Promise<void> {
const usage = await readUsage(window);
await store.insertIfAbsent({
...window,
quantity: usage.quantity,
unit: usage.unit,
source: "platform",
sourceRequestId: usage.request_id,
});
}
The retry is deliberately small and bounded by the job runner's deadline in production. Add exponential backoff there, honor Retry-After, and make insertIfAbsent a database uniqueness check. A retry must not create a second billable row.
Where does the schedule and reconciliation live?
Run the snapshot on a schedule, not as a side effect of invoice generation. A daily job can close the prior period after a short lateness window; a monthly invoice then reads only closed snapshots. The scheduler itself needs an idempotency key derived from the customer and period, so a deploy or a manual replay has the same result.
For reconciliation, keep a second read path that compares the closed snapshot with a later platform read. The comparison is a control, not a silent correction. If the numbers disagree, the platform record is the evidence your bill must explain: record the delta, the reason, and who approved any adjustment. Editing the original snapshot destroys the dispute trail. In practice, that means a January invoice can point to a January snapshot even after a February backfill changes the live total. The backfill gets its own reconciliation row; it does not rewrite January. Store the query window and request identifier beside the quantity, retain the raw response in a private or signed-only object, and make the invoice renderer accept a snapshot identifier rather than a “latest” flag. A support engineer should be able to open the invoice, follow that identifier, compare the recorded request with the later read, and see the approval attached to any adjustment. That chain is slower to design than a single counter, but it is what lets finance answer a dispute without asking an engineer to guess what the system meant last month.
I initially wanted one “current usage” column because it was easy to query. That failed the first time a customer asked for the number behind an old invoice. The query had changed, the live total had moved, and there was no honest answer. The extra table is cheaper than that conversation.
How can key rotation and invoice attribution coexist?
Treat credentials and billing identity as separate dimensions. During a production API-key rotation, accept both keys for the overlap window, tag usage with the stable customer or account identifier, then revoke the old key after traffic has drained. Do not use the key string as the customer identity or as the invoice key; keys are credentials, and credentials change.
This is where a plain REST surface can reduce glue. Infrai exposes account usage over HTTP, so a Node.js worker can call it with fetch and one bearer key rather than installing a client SDK, while its one key and one bill cover a broad capability surface under the same account boundary. That unified interface means a billing worker does not accumulate a separate credential and invoice for every backend service. It is useful when the team values a single attribution surface; it is not a reason to ignore the reconciliation work.
The catch is scope. If you need a hosted tax engine, payment retries, or a mature revenue-recognition workflow, use the platform that already owns those controls. An account-usage API does not replace them. Stick with Stripe when payment operations are the center of gravity, Orb when you want a usage ledger managed as a product, Lago when owning an extensible ledger is part of your architecture, or Unkey/Kong Gateway when the meter is specifically an edge request counter. Those tools can be the right answer; they just do not remove the need for an immutable customer-period snapshot.
Top comments (0)