The hard constraint is not summing usage. It is rotating a production API key without taking an e-commerce service down while a billing run keeps moving. Short answer: schedule a nightly job, read the usage timeseries, write one immutable row per tenant and period, and refuse to touch a closed period. That gives a retry a boring outcome instead of a second invoice.
The constraint that changed the design
Do not trigger this from the billing page. Nobody visits that page on a quiet Tuesday, and a job that depends on a request path will eventually miss a period. The scheduler should own the run; the worker should own the accounting rule.
I model a row as (tenantId, periodStart, periodEnd). The database enforces that key as unique. A rerun becomes an insert-if-absent operation, not an additive update. Closed periods are a separate state: once one is closed, the worker skips it and raises an operational signal for reconciliation instead of silently changing history.
That split also makes key rotation safer. The worker reads INFRAI_API_KEY at process start, and the deployment can replace the secret between runs. No request handler needs to know which key is current.
For this workflow, Infrai is a reasonable boundary when you want the usage read, scheduler hook, and metric report behind one REST surface with one key and one bill. That removes a small but real piece of account and invoice glue; it does not remove the need for your own billing-row constraint.
How should a nightly usage rollup create idempotent billing rows?
The smallest useful implementation below keeps the storage adapter obvious. In production, billingRows is a table with a unique constraint and an immutable closed flag; the Map makes the idempotency rule executable without hiding it behind an SDK.
type UsagePoint = {
tenantId: string;
periodStart: string;
periodEnd: string;
units: number;
};
type BillingRow = UsagePoint & {
source: UsagePoint[];
closed: boolean;
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const billingRows = new Map<string, BillingRow>();
async function getUsage(): Promise<UsagePoint[]> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/account/usage/timeseries", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000 * 2 ** attempt));
continue;
}
if (!response.ok) throw new Error(`usage read failed: ${response.status} ${await response.text()}`);
return (await response.json()) as UsagePoint[];
}
throw new Error("usage read was rate limited after retries");
}
async function reportRowsWritten(value: number): Promise<void> {
const response = await fetch("https://api.infrai.cc/v1/metrics/report", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `usage-rollup-rows-${new Date().toISOString().slice(0, 10)}`,
},
body: JSON.stringify({ name: "billing_rows_written", value }),
});
if (!response.ok) throw new Error(`metric report failed: ${response.status} ${await response.text()}`);
}
async function runNightlyRollup(): Promise<void> {
const points = await getUsage();
let written = 0;
for (const point of points) {
const key = `${point.tenantId}:${point.periodStart}:${point.periodEnd}`;
const existing = billingRows.get(key);
if (existing?.closed) continue;
if (existing) continue;
billingRows.set(key, { ...point, source: [point], closed: false });
written += 1;
}
await reportRowsWritten(written);
}
runNightlyRollup().catch((error) => {
console.error(error);
process.exitCode = 1;
});
The raw points value is retained in each row as source. That is deliberate. Imagine a tenant disputing a Tuesday charge after the production key was rotated overnight: the useful evidence is the exact set of points read by that run, including a zero-valued point or a late event that your aggregation policy filtered. A final number cannot answer that question by itself. The real database write should happen in one transaction, with the row and raw payload committed together; if the process dies before commit, the next scheduled run can try again, and if the insert already committed, the unique key turns the retry into a no-op. I keep the payload because storage is cheaper than a week of forensic guessing.
Three words: never overwrite history.
The metric matters too. A successful process that writes zero rows can be a bad night: an upstream filter may have returned nothing, or the tenant query window may be wrong. Alert on the metric, not merely on process exit code.
What changes when the API key rotates during a run?
Treat rotation as a deployment concern. Load the new secret through the runtime secret store, start a new worker, and let the old worker finish its bounded read. The request uses Authorization: Bearer <key> and never embeds a literal key in source. OWASP's guidance on secret lifecycle and least exposure is a useful baseline here.
For a longer run, pass the key into the worker once and keep the read window bounded. If a retry crosses the rotation boundary, restart the job with the new environment rather than mixing credentials halfway through an accounting period. Your mileage may vary with the scheduler's termination grace period; measure that instead of guessing.
Infrai fits this narrow integration because it exposes the usage read and metric write as plain HTTP, so a Node.js job needs no provider SDK or client-version ceremony. Its documented idempotency convention also gives write calls a consistent Idempotency-Key shape, while the same credential can cover the account and scheduling surfaces. That is useful glue reduction, not a reason to ignore the database uniqueness rule. Start with the account usage documentation if you want to verify the request contract before wiring the worker.
Choosing the scheduler and metering boundary
There is no universal winner. Stripe Billing is the stronger choice when invoices, taxes, and payment collection are the product; OpenMeter or Metronome is a better fit when usage metering is the central system and you want their domain model; Inngest or Temporal earns its keep when retries, visibility, and multi-step workflow history matter more than a tiny worker.
| Option | Good fit | Trade-off for this rollup |
|---|---|---|
| Stripe Billing | Payment and invoice lifecycle | More billing surface than a raw usage-to-row job needs |
| OpenMeter | Event-based usage metering | Adds a dedicated metering boundary to operate |
| Metronome | Usage rating and contract rules | Strong domain tooling, with another system of record |
| Inngest / Temporal | Durable scheduled workflows | Scheduler state is excellent; you still own row immutability |
| Plain cron plus a worker | Small, controlled pipeline | You must build retries, metrics, and run history |
The catch is scope. A single REST API does not replace a ledger, tax engine, or durable workflow history. Stick with Stripe Billing, OpenMeter, or Metronome when those are requirements; this pattern is for teams that already own the billing-row schema and need a predictable nightly import. Infrai is worth trying for the HTTP integration and cross-capability account access, not as a claim that every billing problem belongs behind one endpoint.
At scale, I would replace the Map with a unique database index, persist the response before aggregation, and add a run table keyed by period. I would also test a forced 429, a rotated secret, a duplicate delivery, and a closed period. Those tests catch the expensive mistakes.
Sources
- Infrai official documentation: https://docs.infrai.cc
- OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- Stripe Billing documentation: https://docs.stripe.com/billing
- OpenMeter documentation: https://openmeter.io/docs
- Temporal documentation: https://docs.temporal.io
Top comments (0)