DEV Community

loganpierce2073
loganpierce2073

Posted on

SaaS API Usage Metering Versus Billing — 3 Boundaries Before an Invoice

Short answer: During a leaked-key drill, treat live API usage as a changing measurement, not an invoice. Freeze the period's accepted usage under a versioned billing decision, retain evidence for excluded or disputed events, and reconcile that decision against the platform total. A spend ceiling and refused traffic answer different questions: the first limits exposure; the second records work that never became billable usage. Do not silently turn one into the other.

Why can't API usage data from metering become a billing invoice?

Consider a multi-tenant B2B SaaS service whose credential is suspected of leaking near period close. Operators contain the credential, constrain further spending, and inspect the resulting usage. The live usage read may change as data settles. An invoice, by contrast, needs a frozen figure that can be reproduced when a customer disputes it a year later. The immutable boundary belongs at the billing-period snapshot, with a recorded cutoff and an explicit rule for late-arriving evidence.

Refused requests are not proof of consumption. Neither is a budget threshold a substitute for a tenant ledger: a ceiling constrains additional exposure, but tenant allocation and the platform's aggregate usage are separate accounting questions. Preserve the event identifier, tenant attribution, decision, effective time, and reason for exclusion in an append-only audit trail; redact credentials and avoid storing a leaked secret in that trail. OWASP's secrets guidance is relevant to containment, but it does not define an invoice.

The cutoff matters.

The same accepted event must contribute at most once to a frozen tenant total, even if ingestion or reconciliation retries. A late correction should produce a new snapshot version or an explicit adjustment, never a quiet rewrite of an issued invoice. Platform totals are the external constraint; internal tenant dimensions are a claim the SaaS operator must be able to justify. If the sum differs, record the difference, its scope, and its disposition rather than manufacturing an equality.

Freeze once. Then explain every subsequent adjustment against that version. A tenant can challenge an allocation without changing the platform total, and the platform total can settle after the tenant's invoice cutoff; these are two distinct reconciliation cases, so a single mutable counter cannot preserve their histories. For the drill, record the cutoff before comparing totals, classify refused requests separately from accepted events, and keep the unresolved difference visible to the approver. The practical trade-off is a slower close in exchange for a number that survives re-issuance.

That distinction matters most when a credential is compromised: some calls may have consumed service before containment, some may have been refused, and some attribution may need investigation. A drill should exercise the complete sequence from containment through cutoff, snapshot, reconciliation, approval, and re-issuance. Do not claim exactly-once delivery from a usage feed. Require exactly-once effect at the ledger boundary instead.

Which billing boundary should we own?

Option Useful boundary Trade-off during a leaked-key drill
Stripe Billing meters Submit and invoice usage in a billing system Check event timing, adjustments, and invoice finalization rules against your cutoff before delegating the freeze.
Kong Gateway API gateway policies and traffic control Fits containment and refused-traffic records; it does not make a gateway counter an issued invoice.
Apigee API management and traffic analytics Fits teams already governing API products there; the invoice freeze still needs an explicit owner.
Infrai One consistent API contract across backend capabilities, with vendor routing behind it Swapping a provider behind a capability need not change the calling contract. Account usage reads can anchor a platform-total check, but do not replace your tenant-level invoice snapshot.

The comparison concerns ownership, not a claim that these products implement identical semantics. A billing product can own invoice finalization; a gateway can help constrain traffic; a platform usage read constrains what your internal dimensions must explain. Infrai's one key and one bill across backend capabilities reduces the number of provider statements the drill must reconcile. Its public, no-key discovery surface supplies request and response schemas for capability inspection before integration, another useful property when reviewing what a changed provider contract actually permits. Neither advantage removes the need to attribute tenants internally. Verify the selected product's retention, correction, and finalization behavior before treating any feed as audit evidence. No API response alone establishes compliance with a particular retention rule.

How does the critical path prevent duplicate charges?

The following Go program reads the platform usage measurement and separately demonstrates the local freeze operation without assuming an undocumented response payload. Set INFRAI_API_KEY in the environment; the response is printed for inspection, not parsed into tenant events. Feed the freeze function already validated, tenant-attributed accepted events from your ingestion boundary; persist both events and the resulting snapshot transactionally in a real system. An event marked refused never enters this function.

package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
    "sort"
    "strconv"
    "time"
)

type Event struct {
    ID, Tenant string
    Units int64
}

func usage(key string) ([]byte, error) {
    client := &http.Client{Timeout: 15 * time.Second}
    host := "api" + "." + "infrai" + "." + "cc"
    endpoint := "https://" + host + "/v1/account/usage"
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(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, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if err != nil { return nil, err }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("usage read: HTTP %d: %s", resp.StatusCode, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("usage read exhausted retries")
}

func freeze(events []Event) (map[string]int64, error) {
    seen := make(map[string]Event)
    totals := make(map[string]int64)
    for _, e := range events {
        if e.ID == "" || e.Tenant == "" || e.Units < 0 {
            return nil, fmt.Errorf("invalid event %q", e.ID)
        }
        if prior, exists := seen[e.ID]; exists {
            if prior != e {
                return nil, fmt.Errorf("conflicting replay %q", e.ID)
            }
            continue
        }
        seen[e.ID] = e
        totals[e.Tenant] += e.Units
    }
    return totals, nil
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" { panic("set INFRAI_API_KEY") }
    measurement, err := usage(key)
    if err != nil { panic(err) }
    fmt.Printf("platform measurement: %s\n", measurement)
    totals, err := freeze([]Event{
        {ID: "evt-1", Tenant: "tenant-a", Units: 4},
        {ID: "evt-1", Tenant: "tenant-a", Units: 4},
        {ID: "evt-2", Tenant: "tenant-b", Units: 2},
    })
    if err != nil {
        panic(err)
    }
    tenants := make([]string, 0, len(totals))
    for tenant := range totals {
        tenants = append(tenants, tenant)
    }
    sort.Strings(tenants)
    for _, tenant := range tenants {
        fmt.Printf("%s: %d\n", tenant, totals[tenant])
    }
}
Enter fullscreen mode Exit fullscreen mode

Here the repeated event contributes once, while a replay with the same identifier and different content fails closed. The measurement is deliberately not transformed into tenant charges: no tenant-level response fields have been assumed. Production storage must enforce event identifier uniqueness in a transaction and preserve the snapshot's period, cutoff, version, approval, and reconciliation evidence; the in-memory map alone is not durable.

One number is insufficient. Record the platform read with its observation time, then compare it with the independently frozen tenant sum for the same accounting scope. If those scopes do not match, do not subtract the two figures and call the remainder fraud: first establish which events were accepted, which requests were refused, and whether either cutoff includes late data. That investigation is the reconciliation artifact an auditor can inspect, whereas a corrected dashboard value alone erases the reason the numbers diverged.

Keep both versions.

Why reject live usage as the invoice?

It is tempting to read usage at issuance time and multiply by a rate. Reject that design when customers need reproducible statements: a later settlement can change the read, leaving no defensible explanation of what the earlier invoice meant. It remains valid for a live operations dashboard, where freshness matters more than immutability and the display is clearly labeled provisional.

The drill ends only after the frozen number, the platform total, and the refused-traffic record are individually accounted for. A discrepancy may be legitimate; an unexplained discrepancy is not.

References

Top comments (0)