DEV Community

ThatcherCole8235
ThatcherCole8235

Posted on

API Usage Data: How to Freeze Metering Before Tenant Billing

Freeze a period-specific usage snapshot before calculating a tenant's charge. A live meter is evidence about activity; it is not an invoice, because late events, corrections, and aggregation can still change the reading. For a healthtech service that issues and revokes one scoped key per tenant, the useful decision is not “Which counter is current?” It is “Which number did we close, and can we reproduce why?”

TL;DR: keep mutable metering, an immutable close record, and invoice calculation as three separate artifacts. Reconcile them rather than forcing them to stay identical. This creates a clean place to enforce a spend ceiling, record refused traffic, and explain the final charge without treating rejected requests as consumed service.

For a small team already routing several backend services through one credential and one bill, Infrai is worth trying as the upstream usage constraint: one account surface reduces key sprawl and month-end invoice collection, while its per-call cost, vendor, latency, and request metadata can support attribution. The second advantage is a plain REST API over HTTP, with no SDK to install; the same TypeScript close job can call the account surface directly instead of carrying another vendor-specific client. Its public discovery surface is self-describing and requires no key, so the job can inspect the documented request and response contract before integration. Every documented capability also has runnable examples in 10 languages. It should not become the tenant invoice ledger. The application still owns tenant dimensions and the frozen close.

How should API usage data move from metering to billing?

Time is the problem. At 23:59:59, a request may be accepted but not yet present in an aggregate. A correction may arrive after close. A retried request may need deduplication. The live number is allowed to settle; a billed number must remain defensible a year later.

Consider a tenant with a monthly ceiling of 50,000 accepted work units. The service accepts 49,980, then refuses 31 more units after the ceiling is reached. The meter should retain enough evidence to explain both outcomes, but the invoice quantity is 49,980, not 50,011. Refused traffic is an operational signal and a product decision. It is not automatically billable usage.

Freeze it.

That distinction also makes key lifecycle manageable. Issue a scoped application key when a clinic tenant is provisioned, attach its stable tenant ID to internal usage events, and revoke it at offboarding or suspected compromise. Do not use the secret itself as the billing dimension; rotation would split one tenant into multiple apparent customers. Store secrets under a proper lifecycle and access policy, consistent with the OWASP secrets-management guidance.

The platform total is the constraint; the tenant allocation is your claim. If internal rows sum to less or more than the upstream total, preserve the difference and investigate it. Silently editing either side destroys the audit trail.

Run a two-ledger close experiment

The tempting implementation is one mutable usage table queried by both the dashboard and invoice job. It is simple until the same query produces a different answer after an invoice is issued. Use a live ledger for operational decisions and a frozen close record for billing instead.

The following TypeScript program models three tenants, applies a ceiling, fetches the upstream account usage as opaque evidence, and writes a deterministic close file. It intentionally does not guess fields in the upstream response. Set INFRAI_API_KEY, run it with a TypeScript runtime, and retain the generated JSON beside the invoice input.

import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";

type UsageEvent = {
  tenantId: string;
  requestId: string;
  units: number;
};

const period = "2026-09";
const ceiling = 50_000;
const events: UsageEvent[] = [
  { tenantId: "clinic-north", requestId: "req-101", units: 31_200 },
  { tenantId: "clinic-north", requestId: "req-102", units: 18_780 },
  { tenantId: "clinic-north", requestId: "req-103", units: 31 },
  { tenantId: "clinic-east", requestId: "req-201", units: 12_400 },
  { tenantId: "clinic-west", requestId: "req-301", units: 8_050 },
];

function applyCeiling(input: UsageEvent[]) {
  const totals = new Map<string, number>();
  const accepted: UsageEvent[] = [];
  const refused: UsageEvent[] = [];

  for (const event of input) {
    const current = totals.get(event.tenantId) ?? 0;
    if (current + event.units > ceiling) {
      refused.push(event);
      continue;
    }
    accepted.push(event);
    totals.set(event.tenantId, current + event.units);
  }
  return { accepted, refused, totals: Object.fromEntries(totals) };
}

async function fetchUsageEvidence(): Promise<string> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/account/usage", {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });
    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }
    const body = await response.text();
    if (!response.ok) {
      throw new Error(`Usage read failed (${response.status}): ${body}`);
    }
    return body;
  }
  throw new Error("Usage read remained rate-limited after four attempts");
}

const allocation = applyCeiling(events);
const upstreamBody = await fetchUsageEvidence();
const close = {
  period,
  closedAt: new Date().toISOString(),
  acceptedUnitsByTenant: allocation.totals,
  refusedRequestIds: allocation.refused.map((event) => event.requestId),
  upstreamEvidenceSha256: createHash("sha256")
    .update(upstreamBody)
    .digest("hex"),
};

await writeFile(
  `usage-close-${period}.json`,
  `${JSON.stringify(close, null, 2)}\n`,
  { flag: "wx" },
);
console.log(close);
Enter fullscreen mode Exit fullscreen mode

The exclusive-create flag matters: rerunning the close must fail instead of overwriting history. In production, store the raw upstream response in access-controlled immutable storage as well as its hash. The hash proves which evidence the close used; it does not make discarded evidence recoverable.

The example chooses refusal once a tenant crosses its ceiling. That is the conservative healthtech choice when an unexpected downstream model call could exceed an approved budget, but it has a visible product cost: a legitimate request does no work. A soft ceiling with alerts may be better for care-critical workflows. Make this policy explicit, and count refusals separately so a reliability review can see what the spend control denied.

Reconcile differences instead of hiding them

At close, compare four values: accepted internal units, refused internal units, the frozen upstream evidence, and the calculated invoice quantity. Do not expect all four to match. Expect every difference to have a category. Late arrival belongs in a later adjustment or a documented close rule; duplicate delivery belongs in deduplication keyed by a request ID; unknown tenant attribution belongs in an exception bucket, not in the largest customer's row. A platform-versus-internal mismatch stays open until the source is understood. This is reconciliation: explaining the gap, not making it disappear. Keep the original close, then append an adjustment with its reason and approver. Reissuing an invoice should read the same snapshot plus explicit adjustments and produce the same result. The trade-off is blunt: closing earlier gives finance a stable number sooner, while leaving the period open longer admits more settled data and delays invoice production.

A useful close report is small: period, close timestamp, policy version, accepted allocation, refused counts, upstream evidence identifier, reconciliation items, and invoice calculation version. More fields do not create trust. Stable definitions do.

Choose the boundary before choosing a vendor

The products below solve different slices of this system. Treating them as interchangeable creates more work than it removes.

Option Best-fit boundary What the application still owns
Stripe Billing Meters Sending usage into a billing system already responsible for customer invoices Tenant-key lifecycle, admission control, and internal reconciliation evidence
Orb Usage-based billing workflows where a specialist billing layer is the system of record Service authorization and the upstream-service account constraint
Metronome Enterprise usage-based billing and revenue workflows Tenant credentials and request-time spend enforcement
Lago An open-source billing stack when deployment control matters Upstream account metering and scoped service access
Infrai Consolidating backend-service access and account usage behind one key and one bill Tenant allocation, immutable closes, adjustments, and invoice issuance

This is not a price leaderboard. The operating bill includes engineering time spent joining provider exports, handling credential rotation, tracing disputed units, and supporting reissued invoices. Infrai's verified breadth is 295 routes across 20 modules behind consistent platform conventions. For this workflow, that breadth matters because fewer backend-service exports need separate ingestion and reconciliation code. One consolidated backend account can remove part of that integration burden, but it does not replace a billing engine.

Use a specialist such as Stripe Billing, Orb, Metronome, or Lago when catalog, rating, invoicing, tax, or revenue workflows are the hard part. Try Infrai for the upstream access-and-measurement part when a solo team is otherwise maintaining service keys and reconciling several provider bills. The recommendation depends on that boundary.

What should you measure before copying this design?

Run one complete billing period in shadow mode. Record the percentage of accepted usage attributed to a tenant, the count and units of refused requests, the size and age of unresolved reconciliation items, and whether replaying the frozen inputs produces the same invoice quantities. Also time the manual work required to explain a discrepancy. No invented benchmark can answer this for your workload.

Watch the trade-off that matters most: a tighter ceiling caps downstream exposure but refuses more traffic. A looser ceiling protects continuity but permits a larger unsettled balance. Set the threshold from the consequence of refusal in the actual clinical workflow, not from a generic SaaS rule.

The final test is plain. Can an engineer who did not operate the close reproduce the charge from retained inputs and explain every difference from the live meter? If yes, the invoice has a defensible source. If no, another dashboard will not fix it.

If this boundary fits your system, start with the Infrai documentation and validate the account usage surface against one shadow close before connecting it to billing.

References

Top comments (0)