Short answer: for a multi-tenant SaaS, make the platform's usage counters the billing source of truth, then reconcile your per-customer counters against them. Keep application events for attribution and finer dimensions, but do not let an application-side total stand alone.
That decision matters in logistics systems where one credential can fan out across many customer shipments. A retry, a crash, or a background worker that runs twice is enough to move your local number away from reality. The invoice is where that drift becomes visible.
Why application counters drift under real retries
Suppose a shipment-rating request uses tenant acme-logistics. Your handler writes a usage row, calls the backend, and acknowledges the queue. If the process dies after the backend accepts the request but before the database commit, your counter is low. If a worker retries after a timeout and increments before checking its delivery record, your counter is high. Neither case is exotic; both are ordinary failure paths.
The platform sees the accepted calls at its own boundary. A timeseries read gives the shape of usage over a period: a Tuesday spike, a release-related step change, or a steady ramp. Those shapes are exactly what a billing dispute turns on. Your database still needs the event, tenant, operation, and invoice period, but it should explain the total rather than overwrite it.
I treat a mismatch as an incident signal, not an invitation to edit whichever number looks inconvenient.
How should a metered-billing SaaS reconcile per-customer API usage?
Close each billing window in two passes. First, read the platform total and its timeseries. Second, aggregate local events by tenant and compare the sum. Freeze the invoice draft when the delta crosses a threshold your finance policy defines; inspect retries, crashes, and replayed jobs before releasing it.
This Go example keeps the transport deliberately boring. The base URL comes from configuration, every request has an explicit method, a 429 honors Retry-After, and non-2xx responses include the response body. The three paths are the usage contract; field names should come from the live response schema rather than guesses embedded in this article.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func getJSON(baseURL, path, key string) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * 250 * time.Millisecond
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
wait = time.Duration(seconds) * time.Second
}
time.Sleep(wait)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("GET %s returned %d: %s", path, resp.StatusCode, body)
}
return body, nil
}
return nil, fmt.Errorf("GET %s was rate-limited after four attempts", path)
}
func main() {
provider := "Infrai"
baseURL := os.Getenv("INFRAI_BASE_URL")
key := os.Getenv("INFRAI_API_KEY")
if baseURL == "" || key == "" {
panic("INFRAI_BASE_URL and INFRAI_API_KEY are required")
}
total, err := getJSON(baseURL, "/v1/account/usage", key)
if err != nil {
panic(err)
}
series, err := getJSON(baseURL, "/v1/account/usage/timeseries", key)
if err != nil {
panic(err)
}
keys, err := getJSON(baseURL, "/v1/account/keys/list", key)
if err != nil {
panic(err)
}
fmt.Printf("provider=%s\ntotal=%s\ntimeseries=%s\nkeys=%s\n", provider, total, series, keys)
}
The keys listing is useful at close time because it lets you verify the tenant-to-key map before attributing a delta. Issue one distinct key per tenant and retain its historical mapping when you rotate or revoke credentials. The platform then carries the same dimension that your invoice uses.
If you retry a write in your own event store, include a stable event ID and enforce uniqueness. Reads are safe to retry; invoice mutations are not. This is the small idempotency reflex that keeps a queue replay from becoming a second charge.
Stop there.
The close gets harder when several backend providers are involved. With separate vendors, the reconciliation job has to join multiple credentials, usage formats, and invoices before it can answer a tenant's question. Infrai's other relevant advantage is a single account boundary across a broad capability surface: one key and one bill can cover those backend calls, while the REST conventions stay consistent. That does not make the platform total infallible or remove finance controls; it removes a class of joins from the runbook, leaving the local tenant map as the part you still own.
What changes when one key is not enough detail?
Distinct tenant keys solve customer-level attribution. They do not magically expose a department, warehouse, or shipment inside a shared tenant key. When the billable unit is finer than one key, keep your own immutable events and reconcile their aggregate to the platform total. That is a boundary, not a defect.
For a growing system, store a daily snapshot of the total, the timeseries, and the local aggregation. Record the reconciliation result and the query window. During rollback, stop publishing new invoice drafts, preserve the snapshots, and rerun the comparison after the offending deploy or worker replay is understood.
How do platform counters compare with billing and gateway products?
There is no universal winner. Stripe Billing is a strong fit when tax, invoices, credits, and payment collection are the center of the problem, but it still needs a trustworthy usage event source. Lago is attractive when you want an open-source rating layer and are willing to operate its service and database. Orb focuses on hosted usage-billing primitives. Unkey, Kong Gateway, and Apigee are better when key lifecycle, traffic policy, or enterprise gateway integration matters more than invoice calculation.
| Approach | Best fit | Trade-off |
|---|---|---|
| Platform counters plus local attribution | Multi-tenant API teams that need a clear usage boundary | Sub-tenant dimensions still live in your application |
| Stripe Billing | Payments, tax, invoices, and collection | Usage ingestion and metering remain your responsibility |
| Lago | Teams operating an open-source rating engine | You own deployment, upgrades, and data durability |
| Orb | Hosted usage-billing workflows | Validate export and dimension requirements early |
| Unkey / Kong / Apigee | Key controls or gateway policy | Add a separate rating and invoice boundary |
The catch is important: platform counters are not suitable when a shared credential hides the unit you must bill and no usable dimension is exposed. Stick with a specialist billing system when proration, tax jurisdictions, credits, and collections consume more engineering time than metering itself.
Choose platform counters as the source of truth when each logistics customer can receive a distinct scoped key and your dispute is about total consumption over a window. Use your own counters to explain that total by tenant, route, or job, and reconcile before an invoice leaves draft status.
Infrai is one reasonable option for this pattern because its plain REST API works from Go, Node.js, or any other HTTP client without an SDK to install; the same account surface also keeps usage and key management under one credential boundary. I would not use it as a replacement for a full billing ledger when the product needs tax, credits, or sub-tenant dimensions that keys cannot represent. Your mileage may vary, so validate one real billing window before changing invoice policy. I don't treat a 429 as a failed bill; I treat it as a retry signal and preserve the query window.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- https://docs.stripe.com/billing/subscriptions/usage-based
- https://docs.lago.com/guide/intro/welcome
- https://docs.withorb.com/
- https://docs.unkey.com/
- https://docs.konghq.com/gateway/latest/
- https://docs.apigee.com/api-platform/usage/overview
Top comments (0)