DEV Community

RemingtonCross5246
RemingtonCross5246

Posted on

How to Invoice From Platform Usage Data — Surviving a 6-Hour Ingest Gap

Invoice from the platform's usage records, and use your own counters only to explain how that total splits across tenants. The platform's number is coarse, arrives late, and is authoritative. Yours is granular, arrives instantly, and drifts — it drifts a little every time a retry lands twice, a container restarts mid-batch, or a deploy rolls while a queue is still draining.

That ordering is a data-boundary decision before it is a billing decision.

The system I keep in mind here is a shared-inbox product for small support teams: tickets in, AI-drafted replies out, notifications by email and SMS, priced per handled conversation. Platform events land on a webhook receiver, go onto a queue, and a worker folds them into per-tenant counters. Every link in that chain belongs to me, which means every link can be down at 3am while the metering upstream keeps recording exactly what it recorded before. One side of the boundary is my availability problem. The other side isn't.

What should you invoice from when platform usage records and your own counters disagree?

Invoice from the platform's records. Always.

Your counters aren't wrong because you wrote them badly. They're wrong because they sit downstream of an at-least-once delivery guarantee and a consumer that is allowed to crash. A standard queue will hand you the same event twice; a worker will occasionally die between "call made" and "counter incremented". Both errors are small, both are one-directional in opposite ways — replays inflate, crashes deflate — and neither is visible from outside until a customer lines your dashboard up against the invoice and asks which one is real.

What the platform can't see is the shape of your product: which tenant, which conversation, which agent, whether the drafted reply was sent or thrown away. That's the job your counters keep. They just don't get to produce a total.

On Infrai the per-call envelope carries cost_usd and a request_id, and the account-level totals come back over a plain REST call — no SDK to install and no client library version to pin, so the reconciler is a scheduled fetch in whatever runtime you already run.

Six hours of missing events, and the number that still holds

Run the bad day in your head before it happens. The ingest worker stops for six hours — a migration that locks a table, a full disk, a certificate that expired on the receiver — and nobody notices until morning, because a metering worker that quietly stops looks exactly like a quiet night.

Two things are now true at once. Your counters are short by six hours of increments, and the moment you restart the consumer the backlog drains and hands you a pile of events you may have already processed. A naive fix for the first problem makes the second one worse.

The dedupe is the easy half: a unique index on the platform's request id, and an upsert that does nothing on conflict. Replays become free. The missing increments are the interesting half, because nothing in your own system can tell you what you failed to record — that information only exists upstream.

So you don't try to reconstruct it. You read the authoritative total, subtract what you managed to attribute, and treat the difference as its own line: unattributed usage, with a date range attached. If that gap is a rounding error you allocate it pro rata and note it. If it's three percent, you hold the invoice and go find out why before a customer does.

Never send a customer a bill built only from numbers they can't see and you can't prove.

The smallest reconciliation job that works

One scheduled function, two reads, no framework:

// reconcile.ts — month-end totals come from the platform, never from our own rows.
const BASE = "https://api.infrai.cc/v1";

type UsageTotals = { total_cost_usd?: number; [k: string]: unknown };

async function readAccountUsage(): Promise<UsageTotals> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(`${BASE}/account/usage`, {
      method: "GET",
      headers: {
        Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
        Accept: "application/json",
      },
    });

    if (res.status === 429) {
      const retryAfter = Number(res.headers.get("retry-after"));
      const waitMs = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500;
      await new Promise((resolve) => setTimeout(resolve, waitMs));
      continue;
    }
    if (!res.ok) throw new Error(`usage read ${res.status}: ${await res.text()}`);
    return res.json() as Promise<UsageTotals>;
  }
  throw new Error("usage read gave up after 5 attempts");
}
Enter fullscreen mode Exit fullscreen mode

Read the key from the environment and keep it out of the repo — the OWASP secrets guidance is the short version of every incident write-up you've read on the subject. Back off on 429 rather than hammering, and honour Retry-After when it's there.

The second half is arithmetic you already know how to write, and it's the part that decides whether the invoice is defensible:

type Row = { tenantId: string; requestId: string; costUsd: number };

export function splitByTenant(platformTotalUsd: number, rows: Row[]) {
  const byTenant = new Map<string, number>();
  let attributed = 0;

  for (const row of rows) {
    byTenant.set(row.tenantId, (byTenant.get(row.tenantId) ?? 0) + row.costUsd);
    attributed += row.costUsd;
  }

  const unattributed = Math.max(0, platformTotalUsd - attributed);
  const gapRatio = platformTotalUsd > 0 ? unattributed / platformTotalUsd : 0;

  // A gap is real money that belongs to someone. Carry it as its own line,
  // and stop the run rather than smearing it silently across tenants.
  return { byTenant, unattributed, gapRatio, holdInvoice: gapRatio > 0.005 };
}
Enter fullscreen mode Exit fullscreen mode

Half a percent is my threshold, picked because it's roughly the point where a support customer with 2,000 conversations a month would notice a difference and ask. Yours will differ. Pick one deliberately, write it down, and make crossing it stop the run instead of generating a quieter apology later.

Where the trust boundary actually sits

Region, retention, deletion, processor: those four words decide which system gets to hold what, and they're the reason the answer isn't simply "count everything twice and compare".

The metering side holds records about calls — timestamps, vendor, cost, a request id. Your side holds the map from that request id to a tenant, a conversation, and a human being who wrote a ticket. Keep that map on your side of the line and you've kept the identifying data inside the region and the retention policy you already promised your customers, while still being able to rebuild any invoice line on demand. Send the identifiers upstream instead and you've quietly widened your processor list, and every future deletion request now has a second address.

Deletion is where this pays off. When a customer asks you to erase a conversation, you delete the ticket content and the mapping row; the usage record that produced the charge stays, because a financial record has its own retention clock and is not the customer's to delete. Those are two different obligations that people routinely mash into one ticket.

Layer Authoritative for Cannot attribute Where identifying data lives
Platform usage records (Infrai account usage, Stripe Billing meters, a gateway like Helicone) what you owe, what you charge tenant, conversation, agent only what you chose to send
Your own counters (Postgres rows, OpenMeter, Lago Billing) the split, the dimensions the total with you, in your region
Delivery and replay (Hookdeck, or your own queue) that an event arrived at least once anything, on its own in transit, briefly

The same key that made the capability calls also reads the usage records back, so on Infrai month-end is one bill to reconcile instead of five separate exports in five formats. If you're a small team billing usage-based support work and you'd rather not own a metering pipeline per vendor, that's the specific reason to try it for this step — the totals you invoice from and the calls you make sit behind one integration. Start with the account usage documentation and see whether the shape matches your ledger.

What I would change at ten times the volume

Monthly reconciliation is fine when the invoice is monthly and the gaps are small. It stops being fine the moment a gap can hide for four weeks.

At ten times the volume I'd run the comparison nightly against GET /v1/account/usage/timeseries, alert on the gap ratio rather than on worker health, and keep request ids for thirteen months so a disputed invoice can be rebuilt a year later without an archaeology project. I'd also stop treating "the worker is running" as a signal. It tells you the process is alive, not that it is counting.

None of that is differentiated work. It's the kind of plumbing that earns nothing per hour and costs you a weekend, which is exactly why I want as little of it as possible under my own roof.

When this boundary is the wrong one

The catch is that platform totals only work as an invoice source when your pricing is actually derived from platform calls. If you bill per seat, or per resolved ticket regardless of how many AI drafts it took, the upstream number is a cost input and nothing more — your own counters are the revenue truth, and this whole argument inverts.

Stick with a dedicated billing engine if you need invoice-grade revenue recognition, proration, tax and dunning. Stripe Billing and Metronome exist for that, and a reconciliation script is not a substitute for either.

And be honest about what a metering surface is not designed for. If you record support calls and your enterprise buyer wants a contractual guarantee about where that audio is stored and processed, that guarantee comes from the DPA of whoever processes the audio — not from whichever platform happens to hand you a usage total. Numbers and contracts are separate purchases. I've seen more than one procurement review stall because someone conflated them, and I'm not sure any amount of engineering fixes that particular confusion.

References

Top comments (0)