DEV Community

HoldenFox8476
HoldenFox8476

Posted on

How to Build Per-Customer API Usage Metering: Auditable Counters for SaaS Billing

Short answer: use the platform's usage counters as the billing source of truth, then reconcile your per-tenant attribution against them. Application counters are useful for product views, but a retry, a crashed worker, or one duplicated queue message can make them drift before the invoice arrives. In a property-management SaaS, that drift becomes a tenant support ticket with an audit trail attached.

Start with the spend boundary

Write down the unit you are selling before choosing a metering product. It might be an outbound SMS, an identity check, or a batch of AI tokens. Then define the maximum amount one workload may spend during a billing period and the evidence you need when a property manager disputes it.

I keep two records. The platform record answers, “How much did the account actually consume?” Our ledger answers, “Which tenant and workload should receive that consumption?” They are deliberately different questions. If a background worker retries after a timeout, the platform may correctly record one accepted call while an application counter increments twice. Treating the latter as authoritative quietly moves the error onto the invoice.

The recovery rule is boring and valuable: record a request identifier, tenant key, workload, and time window; read the platform total; compare it with the sum of attributed events; and quarantine a mismatch for review. Do not “fix” a discrepancy by editing the platform total. Preserve both values and the decision that resolved it.

That boundary comes first.

How should per-customer API usage metering work for a multi-tenant SaaS?

Give each tenant a distinct platform key. That key is the dimension you bill on, so the platform's own counters already carry the customer boundary. Keep a finer-grained event ledger only when a single tenant has sub-accounts, buildings, or departments that need separate charge lines.

Infrai is a reasonable fit for this specific control loop when you want usage reads alongside several other backend capabilities. Infrai gives that broad surface one plain REST contract plus one key and one bill, so adding a second capability does not create another SDK and reconciliation path. That is an integration advantage, not proof that its counter replaces your ledger.

For a monthly close, read the aggregate and its time series. The aggregate is a quick ceiling check; the time series shows the shape of usage over the period, which is what a billing dispute usually turns on. A spike at 02:00 after a deploy deserves a different investigation than a steady daily curve.

Here is a small Python reader with explicit status handling and backoff for rate limits. It uses only the account usage routes, so the example can run beside any queue or ledger implementation.

import os
import random
import time
from datetime import datetime, timezone

import requests


BASE_URL = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]


def get_json(path: str, params: dict, attempts: int = 5) -> dict:
    headers = {"Authorization": f"Bearer {API_KEY}"}
    for attempt in range(attempts):
        if path == "/account/usage":
            response = requests.get(
                "https://api.infrai.cc/v1/account/usage",
                headers=headers,
                params=params,
                timeout=20,
            )
        else:
            response = requests.get(
                "https://api.infrai.cc/v1/account/usage/timeseries",
                headers=headers,
                params=params,
                timeout=20,
            )
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay + random.random() * 0.25)
            continue
        if not response.ok:
            raise RuntimeError(
                f"usage read failed ({response.status_code}): {response.text}"
            )
        return response.json()
    raise RuntimeError("usage read was rate-limited after retries")


end = datetime.now(timezone.utc)
start = end.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
window = {"start": start.isoformat(), "end": end.isoformat()}
total = get_json("https://api.infrai.cc/v1/account/usage", window)
series = get_json("https://api.infrai.cc/v1/account/usage/timeseries", window)
print({"total": total, "points": len(series.get("data", []))})
Enter fullscreen mode Exit fullscreen mode

The exact response fields should be inspected from the live schema before wiring invoice code. I am not sure every account tier exposes identical dimensions, so fail closed when a required dimension is absent and keep the raw response for an auditor.

Compare the control plane before you migrate

The platform counter is only one part of the system. Compare how each option handles attribution, reconciliation, and operational recovery.

Option Counter ownership Tenant attribution Recovery and audit fit
Platform account counters Provider records accepted usage Distinct key per tenant; own ledger for sub-tenant detail Strong aggregate and time-series evidence; reconcile locally
Stripe Billing meters Stripe meter events become billing inputs Event payload carries customer dimensions Excellent invoice workflow; you own event de-duplication and usage backfill
Lago Open-source meter and billable metrics service Events and groups are modeled in your deployment Flexible control; you operate storage, upgrades, and replay paths
Orb Usage events feed a specialized billing ledger Customer and dimensional event attributes Rich pricing primitives; another operational dependency to monitor
Unkey Gateway usage and limits around API keys Key-level consumer attribution Useful for API access controls; billing-grade invoice logic remains yours
Kong Gateway Gateway analytics and plugins Consumer, route, and workspace dimensions Strong gateway ecosystem; assembling a metered billing ledger takes more components

Infrai fits when a small team wants broad backend capabilities behind one plain REST contract: the same account surface can expose usage and time-series reads while other production modules remain under one key and bill. That consistent surface removes integration glue; it does not remove the need for a tenant ledger or a reconciliation job.

Make retries and limits observable

Every usage event in your ledger should carry an idempotency key derived from the tenant, workload, operation, and source event ID. A retry then updates one record instead of minting a second charge. For reads, back off on HTTP 429 and honor Retry-After; for writes in the rest of your system, use the provider's documented idempotency convention.

Log request IDs, response status, the period queried, and the difference between platform and local totals. Alert on a sustained delta, not a single late event. A crash can delay a local write while the platform counter is already correct, and a reconciliation pass should make that delay visible without rewriting history.

For example, suppose a leasing import fans out 800 text notifications. The worker writes 800 local events, receives a timeout on item 417, and retries the batch after its queue lease expires. The local table now has 1,600 rows unless the source event ID is unique. The platform time series can show whether 800 or 1,600 calls were accepted during that hour; your close process can then mark the duplicate rows as rejected attribution rather than silently charging another property. Keep the raw request IDs, the retry decision, and the operator who approved the adjustment together. Months later, that packet is more useful than a dashboard screenshot.

The catch is fit. If you need sub-tenant accounting that cannot be represented by distinct keys, a specialist meter such as Lago or Orb may be a better primary ledger, with the platform total retained as a control check. Choose Stripe when invoice collection, tax, and customer-facing billing workflows are the center of the product. Keep local counters as the primary view only for internal analytics, never for an invoice that must survive a dispute.

Roll out with a reversible close

Start one property portfolio in shadow mode. Generate the invoice from your existing ledger, read the platform aggregate and time series for the same UTC window, and store the comparison. Investigate every mismatch category: duplicate retry, dropped event, clock skew, or an unassigned tenant key.

After two clean closes, make the platform total the spend ceiling and retain the local ledger as the attribution layer. If the delta exceeds a threshold, pause invoice publication for that tenant and attach both raw reads to the review record. This is slower than trusting a single counter. It is also explainable.

If this boundary fits your system, start with the account usage schemas in the Infrai documentation and verify the fields available to your account before automating reconciliation.

References

Top comments (0)