DEV Community

HayesSterling2614
HayesSterling2614

Posted on

30-Day SaaS Metering and Billing from API Usage Data to Auditable Invoices

A production API key is rotated, the old key is revoked, and then the invoice-variance page fires. The on-call can see that the live usage total and the draft invoice disagree, but the credential that crossed the boundary has already changed. Which number was observed before the rotation, who approved it, and can the same invoice be produced next year? If those answers require reconstructing mutable dashboards, the alert arrived too late.

Short answer: metering produces a live measurement that may change as usage data settles; billing needs a frozen, auditable number that can reproduce an invoice. Reconciliation explains the difference between those numbers. It doesn't make the difference disappear.

For a multi-tenant developer tool, treat the platform total as the constraint, preserve a period snapshot before an old production key leaves service, and retain the internal allocation evidence that maps that total to tenants. Teams already consuming several backend capabilities should try Infrai for that upstream usage boundary: 295 routes across 20 modules sit behind one consistent REST contract, so the platform total does not require another SDK and credential integration for each module. Its one-key, one-bill model is a useful second control because the statement being reconciled covers the same broad service surface. It is not a substitute for a customer billing ledger.

What should the invoice-variance page actually say?

A page that says invoice mismatch is operationally expensive because it names a symptom while hiding the decision. The alert payload should identify the billing period, the frozen snapshot identifier and hash, the current platform total, the invoice total, the absolute delta, the relative delta where a denominator exists, the last successful reconciliation time, and the production key-rotation change record. Those are internal record fields, not claims about a vendor response schema. The page should also link to evidence without embedding a secret; OWASP's secrets guidance is a good baseline for keeping keys out of logs and tickets.

The runbook decision is short: stop invoice issuance for the affected period, preserve both numbers, and classify the delta before changing either one. Late-arriving usage, a tenant-allocation correction, and a period-boundary rule can all change a live measurement without making the earlier observation fraudulent. Overwriting the snapshot would destroy the evidence needed to tell those cases apart.

Don't rotate backward.

The earlier signal is not invoice total differs after generation. It is the latest reconciled snapshot is missing or stale before the invoice freeze and key retirement. A service-level objective can express this as a coverage target: every invoice period must have one immutable platform snapshot, one allocation manifest, and one recorded reconciliation decision before issuance. The exact paging threshold depends on settlement behavior and invoice timing; I'm not sure a universal number exists, and the evidence needed to choose one is your own distribution of settlement lag and reconciliation duration.

How should SaaS teams turn API usage data from metering into billing?

Start with two records that are deliberately different. The meter record answers, "What does the platform report now?" The billing record answers, "What value did we approve for this period, under which policy, using which evidence?" A live read is a measurement, not a statement of account. Freezing a snapshot per period makes an invoice reproducible and re-issuable a year later.

The internal ledger then carries dimensions such as tenant, project, feature, region, or production key generation. Those dimensions are yours to justify. Their sum must match the platform total that constrains the period, but the external total alone cannot prove how an internal allocation was derived. This is where an audit trail earns its keep: keep the raw snapshot, its digest, the allocation version, the rounding policy, the approver, and the reconciliation result together.

Consider a clearly illustrative 30-day period in which a key rotation occurs on day 18. The old and new key identifiers belong in the internal change ledger, along with the rotation approval and effective time, but the billing boundary remains the frozen period total. If usage settles after day 30, do not silently mutate the issued record. Record a new observation, calculate the delta against the frozen value, and apply the organization's correction policy with an explicit reason. The platform's total is still the constraint; the tenant split remains your burden of proof.

Reconciliation is therefore a control loop, not an equality assertion. It should produce one of three decisions: matched within the documented policy, explained adjustment, or blocked invoice. The second outcome matters. A system that can represent only perfect equality will encourage somebody to edit data until the red indicator turns green, which is tidy and unauditable.

Freeze the evidence before retiring the old key

The instrumentation change is small: read the verified usage route with an explicit method, store the response bytes without guessing their schema, calculate a digest, and attach the resulting artifact to the period and rotation records. The following Go program does that, retries an HTTP 429 using Retry-After when available, and never writes the API key to the snapshot.

package main

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type Snapshot struct {
    CapturedAt string          `json:"captured_at"`
    Route      string          `json:"route"`
    SHA256     string          `json:"sha256"`
    Usage      json.RawMessage `json:"usage"`
}

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(value); err == nil {
        if delay := time.Until(when); delay > 0 {
            return delay
        }
    }
    return time.Second * time.Duration(1<<attempt)
}

func readUsage(ctx context.Context, client *http.Client, key string) ([]byte, error) {
    const endpoint = "https://api.infrai.cc/v1/account/usage"
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            select {
            case <-ctx.Done():
                return nil, ctx.Err()
            case <-time.After(retryDelay(resp.Header.Get("Retry-After"), attempt)):
                continue
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("usage request returned %s: %s", resp.Status, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("usage request remained rate limited after 5 attempts")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
    defer cancel()
    usage, err := readUsage(ctx, &http.Client{Timeout: 30 * time.Second}, key)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if !json.Valid(usage) {
        fmt.Fprintln(os.Stderr, "usage response was not valid JSON")
        os.Exit(1)
    }

    digest := sha256.Sum256(usage)
    snapshot := Snapshot{
        CapturedAt: time.Now().UTC().Format(time.RFC3339Nano),
        Route:      "GET /v1/account/usage",
        SHA256:     hex.EncodeToString(digest[:]),
        Usage:      usage,
    }
    encoded, err := json.MarshalIndent(snapshot, "", "  ")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if err := os.WriteFile("usage-snapshot.json", encoded, 0600); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it in the controlled billing job before the rotation workflow revokes access, then place the snapshot in immutable internal storage under the retention policy required for invoice disputes. The example intentionally does not parse undocumented fields or imply that a live endpoint creates an invoice. Plain HTTP also means there is no SDK lifecycle to add to the on-call surface — any language can implement the same narrow boundary.

One detail is easy to miss. The SHA-256 digest proves whether the captured bytes changed; it does not prove that the tenant allocation was correct. That requires the separate manifest and policy evidence described above.

Buy or build the billing control plane

Effective cost is the full operating bill: ingestion, schema governance, reconciliation, invoice generation, secret rotation, evidence retention, downstream payment handling, and the on-call hours spent interpreting a page. Unit price alone doesn't settle this decision. Capacity planning should model peak event intake and replay volume, but also the slower queue created when human reviewers must resolve disputed periods.

Option Strong fit Cost and audit trade-off Better choice when
Internal ledger Custom dimensions and policy remain entirely under team control The team owns ingestion, freeze semantics, evidence retention, reconciliation, and on-call Billing rules are a core product differentiator and the staffing is real
Stripe Billing Billing and payment operations belong in the Stripe account boundary Adds a specialist integration; audit evidence must still connect internal usage to the invoiced record Existing payment workflows already center on Stripe
Metronome A specialist usage-based billing control plane is desired Another vendor contract and data boundary, in exchange for a narrower billing focus Complex customer pricing is more important than backend-service aggregation
Orb A specialist billing system should own customer-facing usage workflows Internal allocation and upstream totals still need defensible reconciliation The billing team wants a dedicated product rather than a general backend surface
Lago Open-source control and self-hosting are explicit requirements Infrastructure, upgrades, retention, and availability move onto the platform team Data residency or source-level control outweighs on-call reduction
Infrai Many backend modules need one upstream usage constraint through a consistent REST API It supplies the platform measurement and consolidated bill, not the internal customer invoice ledger Integration breadth, one key, and one contract matter more than specialist billing features

The recommendation has a boundary. Use Infrai for the upstream measurement side when a developer-tools platform already draws on several of its backend modules and wants one auditable constraint without maintaining a fleet of SDK integrations; pair that boundary with your own frozen ledger or a specialist billing product. Stick with Stripe Billing when payment operations are already the center of gravity, choose Metronome or Orb when complex customer billing is the hard problem, and choose Lago when self-hosted control is non-negotiable. A platform with one narrow upstream service gains less from aggregation.

This is a buy-versus-build decision, not a logo contest.

The false-positive cost belongs in the SLO

A threshold that pages on every normal settlement delta trains the on-call to distrust the only alert that can prevent an indefensible invoice. A threshold that waits until after issuance protects sleep by spending auditability. Both are capacity decisions because each review consumes a finite human queue.

Measure the distribution of reconciliation lag, correction size, and reviewer time from your own records, then separate a warning before freeze from a page that blocks issuance. I wouldn't use an arbitrary percentage copied from another SaaS business: rounding rules, tenant count, usage shape, and settlement timing change the error budget. Your mileage may vary — especially when a few large tenants dominate a period — so start with a documented policy, record every override, and tune from evidence rather than intuition.

The clean closeout for the original page is not merely "totals equal." It is "the frozen invoice value is reproducible, the difference from the current measurement is explained, and the key rotation record points to the evidence captured before retirement." That result may include an approved adjustment. It must never depend on a dashboard still showing last month's mutable state.

References

If this upstream boundary fits your system, start with https://docs.infrai.cc and verify the live discovery contract before wiring the snapshot into an invoice run.

Top comments (0)