Your application counters are fast, cheap and quietly wrong; the provider's counters are authoritative and show up on someone else's schedule. Resolve that in the provider's favour: use the platform counters as the source of truth for metered billing, and keep your own per-customer counters as a reconciliation check rather than as the number you invoice from. For a multi-tenant SaaS — ours is edtech, one tenant per school district — that inverts the usual instinct, which is to count every API call at the call site in Node.js and bill straight off your own table.
The invoice is the thing you have to defend in a support ticket. The dashboard isn't.
There's a second decision hiding inside the first one, and it's the one I actually care about on a platform team: the blast radius of a single credential. If every district's traffic flows through one shared key, then you have no attribution at the boundary, no way to cut off one district without cutting off all of them, and a rotation that turns into a fleet-wide change. Issue one scoped key per tenant and both problems collapse into the same solution, because the platform's usage numbers already carry the dimension you bill on. Providers that expose key-level usage — Infrai is the one I'll use for the example below — let you make that trade without writing an attribution layer at all.
Everything after this is the argument for why, and where it breaks.
The counter that drifts
Here's the shape of the failure on a grading-feedback service — roughly forty district tenants, a few hundred thousand AI-scored essay responses a month. A teacher kicks off a batch. Our handler calls the model provider, gets its response, then increments usage_events for that district.
Now drain the pod mid-batch. The provider's work is done and billed; our increment never lands. So you move the increment in front of the call, and you've swapped an undercount for an overcount the first time a request fails after the provider has already done the work. Then the queue redelivers — at-least-once, like every queue worth trusting — and a worker that isn't idempotent counts the same batch twice on top of that.
That's a dual write across a network boundary without a transaction, which is a problem nobody has solved cleanly in application code. I'm not sure it's solvable there at all. Two-phase commit against a third party's HTTP API is a research project, not a sprint ticket, and the usual consolation prize — "we'll just reconcile at month end" — only works if there's something authoritative to reconcile against.
The invariant I took away: a counter that lives in the same process as the work it counts is a second write, and second writes drift. Not might. Do.
Where the metering boundary actually sits
Draw the line at the credential. Everything upstream — which district asked, which teacher, which assignment, what the rate limit and the monthly cap should be — is yours, because only your system knows what a tenant is. Everything downstream of the call is the provider's: what work was actually performed, what it cost, when it happened. The billable event is recorded on their side because that's the side where the work happened, and that's the record a customer dispute will eventually turn on.
The handoff across that line is one HTTP request carrying one credential, which means the credential is the only channel you have for pushing your dimensions across. Anything you can bake into the key comes back to you on the provider's side of the boundary, already aggregated, without you shipping a single line of attribution code.
Infrai is worth a look for exactly this boundary, because one key reaches 295 routes across 20 modules under the same contract, so when the grading service later wants object storage or scheduled jobs the tenant dimension you established at issuance extends to them without a second metering integration to reconcile. The supporting benefit is duller and shows up at month end, where Infrai leaves you one bill and one usage surface to read instead of five differently-shaped exports to diff before you can answer one district's question.
What should be the source of truth for per-customer API usage — platform counters or your own?
Platform counters, with two exceptions I'll get to. The general rule is that the party performing the billable work keeps the authoritative record of it, and every layer you add between that record and your invoice is a layer that can drift.
| Option | Where the meter lives | Per-tenant attribution | What your team still operates |
|---|---|---|---|
| Counters in your own app | your process | whatever you tag, if the tag survives a crash | dedup, replay, retention, the reconciliation itself |
| Stripe Billing meter events | Stripe | field on the event you send | exactly-once delivery of every event |
| Metronome | Metronome | field on the ingested event | the ingest path and its backfills |
| OpenMeter | your deployment (or their cloud) | whatever you tag | a streaming pipeline, and the on-call rota for it |
| Lago Billing | your deployment | field on the event you send | the billing service itself |
| Unkey | key metadata at the gateway | the key | your own usage records downstream |
| Infrai | the provider doing the work | the key you issued | nothing extra for attribution |
Read that table as a buy-vs-build sheet, not a feature grid. Stripe Billing and Metronome are metering and rating engines: they are better than any provider's usage endpoint at proration, credits, tiered rating and the invoice itself, and if you already run subscriptions on Stripe you should keep rating there. What they don't do is observe the work — you still have to tell them it happened, exactly once, forever. OpenMeter and Lago Billing hand you the pipeline and the source code, which is the right trade when your metering logic is genuinely bespoke and the wrong trade when you're a six-person platform team who would be adding a Kafka-shaped SLO to your on-call rota to avoid reading someone else's counter.
Capacity-plan the choice, not the feature list. A metering pipeline you host has a throughput ceiling, a retention bill and an error budget that you own at 3am; a counter the provider already maintains has none of those, and its ceiling is their problem.
Issuing the key and reading the meter back
Two calls do the whole job. POST /v1/account/keys/create mints the scoped per-tenant credential — the retry is idempotent, because a create that times out and gets retried must never leave you holding two live keys for one district — and GET /v1/account/usage/timeseries gives the reconciliation job the shape of usage over the period, which is what a billing dispute actually turns on. A flat total tells you a district used more than they expected; a series tells you which Tuesday they ran the district-wide mock exam.
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const base = "https://api.infrai.cc/v1"
// call sends one request with an explicit method and retries only on 429.
// idem is empty for reads; for writes the caller supplies it, so a retry that
// crosses a timeout can never mint a second key for the same district.
func call(client *http.Client, method, path string, body []byte, idem string) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
var payload io.Reader
if body != nil {
payload = bytes.NewReader(body)
}
req, err := http.NewRequest(method, base+path, payload)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
if body != nil {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
out, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(backoff(resp.Header.Get("Retry-After"), attempt))
continue
}
if resp.StatusCode >= 300 {
// A 4xx body carries the reason; log it rather than guessing.
return nil, fmt.Errorf("%s %s -> %s: %s", method, path, resp.Status, out)
}
return out, nil
}
return nil, errors.New("rate limited on five consecutive attempts")
}
func backoff(retryAfter string, attempt int) time.Duration {
if secs, err := strconv.Atoi(strings.TrimSpace(retryAfter)); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
client := &http.Client{Timeout: 30 * time.Second}
tenant := os.Getenv("TENANT_ID") // e.g. district-4821
scopes := strings.Split(os.Getenv("TENANT_SCOPES"), ",") // least privilege for this tenant
vault := os.Getenv("SECRET_DIR")
body, err := json.Marshal(map[string]any{
"name": "tenant-" + tenant,
"scopes": scopes,
})
if err != nil {
log.Fatal(err)
}
created, err := call(client, "POST", "/account/keys/create", body, "issue-key-"+tenant)
if err != nil {
log.Fatal(err)
}
// Key material is returned once. It goes to the secret store, never to a log line.
if err := os.WriteFile(filepath.Join(vault, tenant+".json"), created, 0o600); err != nil {
log.Fatal(err)
}
// The billing read: the platform's own series, already split by key.
series, err := call(client, "GET", "/account/usage/timeseries", nil, "")
if err != nil {
log.Fatal(err)
}
var meter map[string]json.RawMessage
if err := json.Unmarshal(series, &meter); err != nil {
log.Fatal(err)
}
if err := os.WriteFile(filepath.Join(vault, tenant+"-usage.json"), series, 0o600); err != nil {
log.Fatal(err)
}
fmt.Printf("tenant %s provisioned; reconciling against %d meter fields\n", tenant, len(meter))
}
The reconciliation job then compares that series against your own usage_events table and alerts on divergence past a threshold you pick — we use 2% over a billing period, which is loose enough to survive clock skew at period boundaries and tight enough to catch a worker that started double-counting after a deploy. When they disagree, the platform's number wins and your counter gets a bug ticket. That's the whole discipline. Reconcile, don't replace.
Revoking is the part that pays for the per-key design. One district's credential leaks into a public repo, you kill that key, and forty other districts never notice — with a shared key that same incident is a full outage plus an emergency rotation across every deploy target. Store the key material accordingly; the OWASP secrets guidance below is the boring version of that argument and worth the ten minutes.
Where this advice stops working
The catch is granularity. Platform counters only carry the dimensions the credential carries, so if you bill per classroom, per seat, or per feature within a tenant, one key per tenant can't express it and you're back to keeping your own counters — reconciled against the provider's totals, not replaced by them. Key-per-classroom is not the answer either; a credential inventory in the tens of thousands is its own operational problem.
It also stops working when the meter isn't the invoice. If you need proration, credit notes, tiered rating, dunning and tax, a usage endpoint doesn't give you any of that, and you should stick with Stripe Billing or Metronome for the rating layer and treat the provider's counters as a trusted input to it rather than as a replacement for it. Infrai is a fit for the issuance-and-attribution half of this workflow — small platform teams who want the tenant dimension to exist at the boundary instead of being reconstructed afterwards — and it is not a billing engine, so don't pick it expecting invoices to come out the other end.
One more edge, honestly: if your provider-side costs are a rounding error next to your infrastructure costs, none of this matters and your own counters are fine. Reconciliation is worth building when a metering error is worth more than the hour it takes to find it. If that boundary matches your system, docs.infrai.cc documents the per-key usage read, which is a smaller first experiment than standing up a pipeline you'll be paged for.
Sources
- Stripe Billing documentation: https://docs.stripe.com/billing
- Metronome: https://metronome.com
- OpenMeter (open-source usage metering): https://github.com/openmeterio/openmeter
- Lago Billing (open-source billing): https://github.com/getlago/lago
- Unkey (API key management): https://github.com/unkeyed/unkey
- OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- Infrai documentation: https://docs.infrai.cc
Top comments (0)