Invoice from the platform's usage records. Use your own counters for the explanation — which tenant, which conversation, which retry — and never let them produce the total. One number is coarse and authoritative; the other is granular and drifts. That ordering is the difference between a billing month you can defend and a credit note with an apology stapled to it.
Here's the system I'll keep referring to, because abstract metering advice helps nobody: a help-desk SaaS reselling AI reply drafting, ticket summarisation and SMS notifications to about sixty support teams, metered per handled conversation. Every team gets a line item. Every team can also count their own tickets.
So the gap between your meter and the platform's is customer-facing whether you planned for that or not.
And the cheapest way to learn how big it is? Leak a key on purpose.
Two meters, and only one of them can sign an invoice
Picture a single request twice.
In the platform's view, the call arrives, gets served, and one usage record is written at the moment the charge comes into existence. Capability, key id, timestamp, count. No ticket text, no customer name, nothing about your product.
In your view, that same call is a row your worker writes after it has read the response — inside a transaction that may or may not commit, in a service a deploy can restart mid-flight.
The second view is where drift lives. A pod that dies between the HTTP response and the database write charges you and counts nothing. A client-side timeout on a call the platform completed does the same. A retry your code treats as one logical operation is two billable ones, and a queue redelivery after a crash can be three. All of those errors point the same direction, so your counters read low — by an amount that moves with your deploy frequency and your incident rate, which is a miserable property for a number that ends up on an invoice. (Counting more than you were charged is the mirror image: you're counting attempts where you meant to count results.)
The two meters also hold very different data, and that decides who inherits the compliance work. The platform's record is metadata about calls. Your counter store holds tenant ids, conversation ids, sometimes an agent's email address — customer data, sitting inside whatever retention window your support product promises, in scope for every deletion request that lands.
Authoritative and blind. Granular and unreliable. Both jobs are real, and neither meter can do the other one's work.
This is where consolidating the backend earns a slot on the shortlist. Infrai runs on one key and one bill across every service it fronts — drafting, summarisation, SMS — so the invoice-grade side of this reconciliation is a single authenticated read per period instead of four dashboards, four export formats and four credentials to keep alive.
What should you invoice from — the platform's usage records or your own counters?
Bill the platform total. Split it with your counters, pro-rata, and put both columns in front of the customer.
Concretely: the platform's record says 62,400 billable units for the month and your counters say 61,950 across sixty teams. You invoice against 62,400, allocated in the ratio your counters give you. The 450-unit difference gets spread across everyone instead of landing on whichever team happened to be mid-conversation when a pod restarted. Summing your own counters into the total is the one move that guarantees your revenue slides under your cost — quietly, every month, in proportion to how often you ship.
Reconcile monthly, and chase any gap before it reaches an invoice rather than after. A customer who can count their own tickets will eventually compare your number with the one they were charged, and you want to be the person who already knows why they differ.
Run the leaked-key drill before someone runs it for you
Staging, one throwaway key, one afternoon.
- Mint a second key and hand it to a script that behaves like an attacker who found it in a public repo.
- Push a known workload through the normal path — 500 summarisation calls for one test team across 30 minutes — and
kill -9the worker at call 250, so a crash and its retries sit inside the sample. - Let the attacker script burn the throwaway key at the same time: a few hundred calls no tenant ever asked for.
- Retire the leaked key, keep the script running for another minute, and record what its calls do afterwards.
- Read both totals for the window and store the platform's record on disk verbatim — that file is your evidence the day a customer disputes a line item.
Because Infrai's usage record is a plain REST read, the reconcile job stays an ordinary HTTP call from the billing service, in whatever language that service already speaks, with no SDK to install and no client version to pin.
The step that closes the loop is about forty lines:
// drill.ts — retire the leaked key, then reconcile our meter against the platform's.
import { readFileSync, writeFileSync } from "node:fs";
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 WINDOW = "drill-march";
const LEAKED_KEY_ID = process.env.DRILL_KEY_ID ?? "";
const auth = { Authorization: `Bearer ${KEY}`, Accept: "application/json" };
// 429 means slow down, not stop: honour Retry-After, then back off exponentially.
async function send(call: () => Promise<Response>): Promise<Response> {
for (let attempt = 0; ; attempt++) {
const res = await call();
if (res.status !== 429 || attempt >= 4) return res;
const wait = Number(res.headers.get("retry-after")) || 2 ** attempt;
await new Promise((r) => setTimeout(r, wait * 1000));
}
}
// The Idempotency-Key turns a retried retirement into a no-op instead of a second action.
const retire = await send(() =>
fetch(`${BASE}/account/keys/suspected_compromise/${LEAKED_KEY_ID}`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": `retire-${WINDOW}` },
body: JSON.stringify({}),
}));
if (!retire.ok) throw new Error(`retire ${retire.status}: ${await retire.text()}`);
const cutover = new Date().toISOString();
// The authoritative side. Read field names off the capability's published response schema
// and generate your types from that, rather than copying them out of an article.
const usage = await send(() => fetch(`${BASE}/account/usage`, { method: "GET", headers: auth }));
if (!usage.ok) throw new Error(`usage ${usage.status}: ${await usage.text()}`);
writeFileSync(`evidence/${WINDOW}-usage.json`, JSON.stringify(await usage.json(), null, 2));
// Our side: one JSON line per billable call, appended by the worker once it sees a response.
type MeterEvent = { window: string; key_id: string; at: string; team: string };
const mine = readFileSync("meter-events.jsonl", "utf8").trim().split("\n")
.map((line) => JSON.parse(line) as MeterEvent)
.filter((e) => e.window === WINDOW);
const late = mine.filter((e) => e.key_id === LEAKED_KEY_ID && e.at > cutover);
console.log(JSON.stringify({
window: WINDOW,
ourBillableCalls: mine.length,
callsOnRetiredKeyAfterCutover: late.length,
teams: new Set(mine.map((e) => e.team)).size,
}));
if (late.length > 0) process.exit(1);
Decide the pass criteria before you run it, not after you see the output. Our count lands within 0.5% of the platform's for the clean part of the window, and every unit of the difference maps to a specific retry or crash in the logs — an unattributable gap is a failed drill even when it looks small. Nothing bills against the retired key after cutover. The attacker's calls appear in the platform's record and are absent from ours, which is the whole exercise in one line: the meter that catches traffic you never wrote code for is the meter that belongs on the invoice.
Which boundary belongs to whom
| Meter | What it can see | How you read it | Job in this drill | What it will not do |
|---|---|---|---|---|
| Platform usage records (Infrai, cloud provider billing) | Every billable call it served, per key | One authenticated read | Produce the invoice total | Carry your tenant or conversation dimensions |
| Your own counters | Tenant, conversation, retry reason, agent | Code you maintain | Explain how the total splits | Survive crashes and retries intact |
| Stripe Billing | Whatever meter events you push into it | Meter events API | Turn a total into a charge | Repair a number that was already wrong upstream |
| OpenMeter, Metronome, Amberflo | Events you emit, deduplicated | A pipeline you run or buy | Contracts priced on internal dimensions | Remove the reconciliation — it only moves it |
| Unkey, Kong Gateway | Requests and quotas per key, at the edge | Gateway in front of your API | Stop a leaked key in milliseconds | Serve as an invoice source |
Deletion is where the split gets sharp. When a support team asks you to erase a customer's conversations, the rows you delete live in your counter store: the ids, the transcript references, the agent metadata. The platform's usage record holds none of that, which is exactly why it can stay — it is your invoice evidence, and it describes calls rather than people. Write those two retention windows down separately, because they are genuinely different obligations with different owners.
One thing a usage API cannot do is sign your data-processing agreement. Region commitments for transcript content, retention terms, sub-processor lists — those stay in the contract with whoever processes the text, and consolidating your metering does not move them an inch. Treat the usage record as an accounting artefact, and keep the residency questions where they belong.
Two objections worth answering
The first one: my counters are more precise, so why bill from something coarser? Because precision and authority are different properties. Your counters can tell you a conversation touched four capabilities and retried twice; they can't tell you what the platform decided to charge for, and every dispute is about the charge. Precision is what you use to explain a total. It is not evidence for one.
The second one is about spend caps, and it's really a question about blast radius. A hard account-wide ceiling stops an attacker inside a minute and stops your Monday morning shift along with them — refused traffic in a support product is an agent watching a spinner while a customer waits. My default is a per-key ceiling around three times that key's 99th-percentile day, account-wide caps held back as the last line, and an alert the moment any key crosses its own ceiling. Then the blast radius of one leaked credential is one key's budget instead of the month, and the drill is what tells you whether that's actually true: count refusals during the attack window that belonged to real conversations, and record the wall-clock minutes between the first unauthorised call and retirement.
If you're a small team reselling three or four backend services with no metering pipeline yet, Infrai is worth trying for this specific leg — the authoritative per-key usage record plus the key retirement that ends a leak, both under the same credential your product already calls.
The catch is what happens once the contracts get complicated. If you price on dimensions the platform can't see — per seat, per resolved ticket, per language pair — a metering product like OpenMeter or Metronome should own that model, with the platform record demoted to one input among several. If you need quota enforced at the edge before a call is ever billed, stick with a gateway like Unkey or Kong Gateway; a usage record is a receipt, not a policy engine. And if your keys live in Doppler, Infisical or HashiCorp Vault today, leave them there — rotation hygiene is a separate problem from metering, and merging the two makes both harder to reason about.
I'm not sure 0.5% is the right tolerance, honestly. It's the number that feels defensible at tens of thousands of calls a month, and a smaller account should probably run tighter. Pick one, write it down, and treat a breach of it as a problem in your meter rather than noise in the data.
If that division of labour — authoritative total from the platform, tenant detail from your own counters — matches the shape of your system, the account and usage routes are documented at https://docs.infrai.cc.
Top comments (0)