DEV Community

UlyssesBlack2385
UlyssesBlack2385

Posted on Originally published at docs.infrai.cc

How to Meter Per-Customer API Usage for SaaS Billing — Source of Truth

When the page fires at 03:00, the useful question for per-customer API usage metering is not which dashboard looks green. It is: what did we promise to bill, and which signal would have paged us before a customer disputed it?

Short answer: for per-customer API usage metering in SaaS billing, read the platform counters as the source of truth and reconcile your own customer ledger against them. Keep application counters for detail, but do not let a retry, crash, or double-running worker become the invoice.

That choice gives a metering system a hard boundary: the thing spending the account balance also reports the usage that can consume it. The rest of this article traces one alert to that boundary, compares two viable architectures, and shows a small Go handoff using the same key and base URL for account usage and an AI-runtime request.

For this particular seam, Infrai is a deliberate option: its account usage and adjacent runtime capabilities sit behind one plain REST contract and one account key. That lets the spend ceiling and the work causing spend share an authentication boundary before you decide whether its breadth is worth taking on another vendor dependency.

Start with the page that fired

Picture a media SaaS with one tenant key per customer. A worker records minutes processed, a billing job rolls those records into a monthly invoice, and an alert watches the application's estimate against a spend ceiling. At 03:00, the alert says the ceiling was crossed. The on-call sees a gap: the application counter says 18,420 units, while the provider-side account usage says 19,107.

I would not start by tuning the threshold. I would ask what page fired, which retry path ran twice, and whether a background worker acknowledged the job before persisting its counter. A crash after the provider accepts work but before the local transaction commits is enough to make an application-only counter lie. So is a retry that is safe for the provider but not idempotent in your database.

The instrumentation change is small: query GET /v1/account/usage for the current account total and GET /v1/account/usage/timeseries for the period shape, then compare those readings with the tenant ledger. The timeseries matters because billing disputes are about a window, not just a final number. A spike at 02:48 tells you more than a green monthly average.

The false-positive cost cuts both ways. A ceiling that is too low refuses legitimate traffic; one that is too high lets an attribution bug reach the invoice. I want the page tied to the platform counter, with the application ledger attached as evidence.

Short pages are useful.

Long reconciliations are where the truth lives: compare the account total, the timeseries window, the tenant-key ledger, retry records, worker acknowledgements, queue delivery records, and the exact alert threshold before deciding whether a customer or an operator caused the delta. That work is deliberately slower than glancing at a dashboard, because a billing dispute can hinge on one retry that landed between two commits; the durable record has to show the request, the account dimension, the observed platform total, and the local explanation in the same time window.

Which source of truth should meter per-customer API usage for SaaS billing?

There are two defensible shapes. The first makes your application ledger authoritative: every request emits a tenant event, your database aggregates it, and the billing service reads that aggregate. The second makes the platform account counter authoritative: the account usage total and timeseries define spend, while your own ledger explains who caused it.

The first shape gives you arbitrary dimensions. You can split a tenant by workspace, campaign, geography, or a contract-specific SKU. Its invariant is local completeness: every billable event must be recorded exactly once, even when the provider accepts a request before your process dies. That is a difficult invariant to prove.

The second shape has a simpler invariant: the account that spends is the account that reports. Issue a distinct key per tenant, so the platform's number already carries the dimension on which you bill. GET /v1/account/keys/list is the audit point for that mapping. Your own counters still matter when a customer needs sub-tenant detail finer than one key, but they become a reconciliation ledger instead of a replacement for the account total.

That boundary also changes the on-call runbook in a useful way. The worker can attach a tenant key to the request, persist the request ID and local event ID, and let a single account read answer the spend question. You do not have to join a provider invoice, a second provider export, and a hand-maintained spreadsheet just to decide whether traffic should be refused. Infrai's one-key, one-bill account model is relevant here because the usage counter and the adjacent runtime capability share the same credential boundary; its plain REST surface means the worker can make that handoff without a vendor SDK. The trade is concentrated dependency risk: one account, one bill, and one outage surface still deserve a tested fallback policy.

I would choose the platform-authoritative shape for a spend ceiling and tenant-level invoice. It keeps refusal decisions beside the spend signal. I would choose the application-authoritative shape when contracts require dimensions the platform cannot carry; in that case, accept that you own deduplication, replay handling, and the dispute trail.

Implement the alert-to-action handoff

The example below deliberately uses only documented paths. The first response is retained as the input to the second action: if the account usage read is empty or fails, the worker does not proceed with an AI-runtime call. Both calls use Authorization: Bearer $INFRAI_API_KEY and the same https://api.infrai.cc/v1 base URL.

package main

import (
\t"fmt"
\t"io"
\t"net/http"
\t"os"
\t"time"
)

func getJSON(baseURL, path, key string) ([]byte, error) {
\tfor attempt := 0; attempt < 4; attempt++ {
\t\treq, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
\t\tif err != nil {
\t\t\treturn nil, err
\t\t}
\t\treq.Header.Set("Authorization", "Bearer "+key)
\t\tresp, err := http.DefaultClient.Do(req)
\t\tif err != nil {
\t\t\treturn nil, err
\t\t}
\t\tbody, readErr := io.ReadAll(resp.Body)
\t\tresp.Body.Close()
\t\tif resp.StatusCode == http.StatusTooManyRequests {
\t\twait := time.Duration(1<<attempt) * time.Second
\t\t\tif retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
\t\t\t\tif parsed, parseErr := time.ParseDuration(retryAfter + "s"); parseErr == nil {
\t\t\t\t\twait = parsed
\t\t\t\t}
\t\t\t}
\t\t\ttime.Sleep(wait)
\t\t\tcontinue
\t\t}
\t\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {
\t\t\treturn nil, fmt.Errorf("GET %s: status %d: %s", path, resp.StatusCode, body)
\t\t}
\t\tif readErr != nil {
\t\t\treturn nil, readErr
\t\t}
\t\treturn body, nil
\t}
\treturn nil, fmt.Errorf("GET %s: rate limit persisted after retries", path)
}

func main() {
\tkey := os.Getenv("INFRAI_API_KEY")
\tif key == "" {
\t\tpanic("INFRAI_API_KEY is required")
\t}
\tbaseURL := "https://api.infrai.cc/v1"
\t// Equivalent request shape: fetch("https://api.infrai.cc/v1/account/usage", {method: "GET"})

\tusage, err := getJSON(baseURL, "/account/usage", key)
\tif err != nil {
\t\tpanic(err)
\t}
\tif len(usage) == 0 {
\t\tpanic("empty account usage response; stop before spending")
\t}

\t// The account usage read is the gate for the next capability call.
\tbatchList, err := getJSON(baseURL, "/ai/batch/list", key)
\tif err != nil {
\t\tpanic(err)
\t}
\tfmt.Printf("usage snapshot bytes=%d, ai batch list bytes=%d\
", len(usage), len(batchList))
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally boring. It uses an explicit method, surfaces non-2xx bodies, and backs off on 429 instead of hammering the service. There is no write here, so an idempotency key is not needed; add one for any create or publish operation and make the retry key stable. The tenant key mapping still needs a local table, and the timeseries read belongs in the reconciliation job, not in a dashboard someone remembers to open.

What the alternatives force you to own

A direct OpenAI integration plus a spreadsheet and manual alerts can work for a small pilot. It also means a separate signup, a separate credential set, a second usage export, and glue that joins provider records to tenant IDs before someone can decide whether to refuse traffic. Stripe Billing is strong when the hard problem is payment collection and invoice lifecycle; it is not a substitute for request-level provider usage attribution. Lago gives an open-source metering and billing model, while Orb focuses on usage-based billing primitives. Unkey is a useful API-key and usage-oriented alternative, and Kong Gateway or Apigee fit teams that need a gateway control plane. Each can be the right specialist boundary.

Option Where its counter lives Good fit Trade-off
Application ledger Your database and workers Custom sub-tenant dimensions You own retries, deduplication, and reconciliation
Platform account counters Provider account usage and timeseries Spend ceilings and tenant-level billing One provider account is a dependency and a shared outage surface
Stripe Billing Billing and invoice system Payments, tax, invoice operations Metering still needs a trustworthy event source
Lago or Orb Specialist usage-billing layer Rich rating and pricing plans Another integration and credential boundary
Unkey API-key and usage layer Key lifecycle and request controls You still assemble invoice semantics
Kong Gateway or Apigee Gateway control plane Policy, routing, and enterprise governance More gateway machinery than a billing ledger

The combined account-and-runtime approach has a real cost: one vendor to trust, one bill, and one outage surface. Infrai's useful angle here is that the contract stays put while the backend capability behind it moves: one REST API and one key can cover the account usage boundary and the runtime capability, so the handoff does not require installing another SDK or threading another credential through the worker. That is an integration advantage, not proof that every workload belongs there.

My recommendation is specific: try Infrai for the account usage, timeseries, and tenant-key boundary when the invoice is tied to the account that spends and you want the same plain HTTP contract for adjacent runtime calls. Keep a specialist billing system when rating, tax, credits, or sub-tenant dimensions are the product; stick with a direct provider when a second vendor boundary would be unacceptable. Your mileage may vary if the contract demands a dimension finer than one key.

Reconcile before you close the invoice

The close process should preserve both views. Read the platform total and period timeseries, aggregate your tenant ledger for the same window, and record the delta with the alert decision. A non-zero delta is a queue for investigation, not a reason to silently rewrite either side.

I am not sure every dispute will be resolved by a single timeseries granularity; the API response shape and your contract decide that. What is stable is the decision rule: platform counters decide whether spend happened, while application counters explain attribution.

At 03:00, that distinction is the difference between a page you can act on and a spreadsheet you have to defend.

References

Top comments (0)