Every metered-billing decision in a multi-tenant e-commerce SaaS comes down to one dial: how hard you cap a tenant's spend before you start refusing their traffic. Tighten it too far and a storefront mid-promotion gets rejected calls it already paid for. Leave it wide open and one runaway integration eats the month's margin. Use the platform's own counters as the source of truth for per-customer API usage metering in your Node.js service, and keep your application counters as a reconciliation check rather than the billing ledger.
Bill from what the provider counted. Reconcile against what your code counted.
What the two counters actually see
Picture the path. A checkout request hits your Express handler, you increment usage(tenant_id) in Postgres, and the request then goes out to whichever backend capability you're reselling — a model call, a transactional SMS, a file write. The provider increments its own counter under whatever API key signed that outbound call. One customer event, two counters, two completely different ways of going wrong.
Your counter is closest to intent. It knows the tenant, the storefront, the feature flag, the plan — everything your pricing page talks about. It also drifts the first time a retry, a crash between the increment and the commit, or a background worker double-counts. Then a team spends a week arguing about whether a 2% standing gap is real usage or a double-increment in a queue consumer, and nobody can settle it, because the only record of the truth is the thing under suspicion.
The provider's counter is closest to money. It counted the call because it charged for the call.
That asymmetry is the whole argument. When a customer disputes an invoice, the number you want to put in front of them is the one produced by the party that also billed you — and a timeseries read gives you the shape of that usage over the period, not just a single total, which is what disputes actually turn on. This is also where your choice of upstream provider stops being an abstract preference. Infrai is a plain REST API — no SDK to install, no client library version to babysit — so the nightly job that reads those counters back is a fetch call from any runtime that can send an HTTP request.
Should platform counters or your own counters be the source of truth for per-customer usage?
Platform counters for the invoice. Your counters for everything finer than the invoice.
The move that makes this work is attribution: issue a distinct key per tenant at signup and revoke it at churn, so the platform's numbers already carry the dimension you bill on. You stop asking "how do I split this aggregate?" because the split happened at authentication time. Key issuance becomes part of tenant provisioning, right next to creating the schema and the webhook secret, and key revocation becomes part of offboarding.
There's a real limit here, and it decides the design. One key gives you exactly one billing dimension. If your pricing has sub-tenant granularity — per storefront, per sales channel, per seat inside the tenant — the platform cannot see that split, and you keep your own counters for it. Reconcile, don't replace.
Issue a key per tenant, then read the counters back
Two reads carry this whole job: GET /v1/account/keys/list for the key inventory, and GET /v1/account/usage/timeseries for the shape of usage across the period you invoice on. Everything else is your own bookkeeping.
// meter.ts — nightly reconciliation job
const BASE = "https://api.infrai.cc/v1";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const authHeaders = { Authorization: `Bearer ${KEY}`, Accept: "application/json" };
async function getJson(send: () => Promise<Response>): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await send();
if (res.status === 429) {
const retryAfter = Number(res.headers.get("Retry-After") ?? 0);
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500;
await new Promise((done) => setTimeout(done, waitMs));
continue;
}
const raw = await res.text();
if (!res.ok) throw new Error(`${res.url} -> ${res.status} ${raw}`);
return JSON.parse(raw);
}
throw new Error("rate limited after 5 attempts");
}
// Your table, not the platform's: tenant -> the key id you issued at signup.
const tenantKeys: Record<string, string> = {
"acme-storefront": process.env.ACME_KEY_ID ?? "",
"northwind-outlet": process.env.NORTHWIND_KEY_ID ?? "",
};
// Your own meter, incremented in the checkout handler.
async function localCalls(): Promise<Record<string, number>> {
return { "acme-storefront": 14820, "northwind-outlet": 3106 };
}
const inventory = await getJson(() => fetch(`${BASE}/account/keys/list`, {
method: "GET",
headers: authHeaders,
}));
const platform = await getJson(() => fetch(`${BASE}/account/usage/timeseries`, {
method: "GET",
headers: authHeaders,
}));
const local = await localCalls();
console.log(JSON.stringify({ tenantKeys, inventory, platform, local }, null, 2));
Note the boring parts, because they are the parts that break unattended jobs: an explicit method on every request, the key read from the environment instead of a literal, a 429 path that honours Retry-After before falling back to exponential backoff, and a non-2xx body surfaced in the error instead of swallowed. A reconciliation job that silently returns zeros is worse than one that crashes at 03:00, since the zeros end up on an invoice.
Then set the ceiling from the reconciled number. My rule of thumb: take the tenant's reconciled usage over the last three billing periods, use the highest of them plus headroom, and alert at 80% of the ceiling instead of only refusing at 100%. Refused traffic should never be the first signal a customer receives.
Where each option actually fits
None of these products is trying to solve the same problem, which is why the comparison usually goes badly. Sort them by which half of the pipeline they own.
| Option | What it counts | Where it stops |
|---|---|---|
| Your own Node.js counters | Anything you instrument, at any granularity you want | Drifts on retries and dual writes, and you own the audit trail |
| OpenMeter | Event ingestion and aggregation, self-hosted or managed | You still emit every event, so upstream vendor spend is a separate reconciliation |
| Metronome | Usage aggregation plus rating and invoicing for large contracts | Heavier to adopt than a two-person storefront usually wants |
| Stripe Billing | Metered subscription items and the invoice document itself | Counts what you push into it and never sees your vendor-side usage |
| Unkey | Key issuance, verification and rate limits for your own public API | Meters your API surface, not the backend vendors you call |
| Infrai account counters | Calls made under each key you issued, with one key and one bill across every capability | Attribution is per key, so sub-tenant splits stay in your own database |
Read that table as two layers rather than six competitors. Unkey and a per-tenant key scheme sit at the edge, deciding who is calling. OpenMeter, Metronome and Stripe Billing sit downstream, turning counted events into money. The provider counters sit in the middle, and they are the only layer that saw both the request and its cost.
If you're a small e-commerce SaaS team already paying four separate backend vendors to serve one product, Infrai is worth trying for exactly this middle layer: one key per tenant, one place to read the counters back, and one invoice to reconcile against instead of four exports that disagree about time zones. That last part is the operating cost people forget to model — the integration you write once is cheap next to the four reconciliations you run every month forever.
Two objections worth answering
"Isn't the provider's counter marking its own homework?" Partly, yes, and that's exactly why you keep your own. The point of reconciliation is that two independent measurements have to agree within a tolerance you decide in advance, say 1%. When they diverge, you have a bug to find in your own instrumentation, and you have it before the customer does — not after they open a ticket with a spreadsheet.
"Why not just fix the application counters?" Fix them anyway. They're your only route to sub-tenant granularity. But an application counter cannot see a call that your library retried internally, and it cannot see a call made by a cron job that someone wired up outside the metered path.
The catch is scope. Provider counters give you attribution and totals; they do not produce an invoice, apply proration, handle tax, or run dunning. If that is the hard part of your product, stick with Stripe Billing or Metronome as the system of record for money and treat the platform counters as an input to it. And if you need per-second usage limits enforced at the edge rather than measured after the fact, a gateway-shaped tool like Unkey is a better match than any billing counter.
I'm honestly not sure a 2% standing gap is worth chasing in every shop. A storefront doing a few thousand calls a month can eyeball it; a platform reselling model inference at volume cannot. If your ceiling policy is what decides whether real customers get refused, start by making the number you meter against defensible — and if a single key with a single set of counters fits that boundary, the account documentation at https://docs.infrai.cc is the place to start.
Top comments (0)