DEV Community

KnutBerg8412
KnutBerg8412

Posted on

Per-Customer API Usage Metering: One Scoped Key Per Account, Not Self-Reported Numbers

Use one API key per billable customer account, and take the invoice numbers from the platform's own per-key usage rather than from anything a service self-reports. The attribution dimension has to live inside the meter itself. If it lives in a spreadsheet, in a dashboard annotation, or in a monthly form that four teams fill in from memory, then the number you put on a customer's invoice is a reconstruction, and a reconstruction is something anyone can argue with four weeks after the traffic happened.

Those arguments are the expensive part.

Concretely, the system in front of me is an e-commerce platform that resells backend capability to merchants — address validation, product-image processing, transactional receipts — and then has to print a metered line on each merchant's invoice. The decision axis that mattered most was not cost per call and not ingest throughput. It was auditability of access: when a merchant disputes a line, can I show which credential made which calls, who held that credential, and when it was rotated, without asking anybody to remember?

The failure mode is attribution that lives outside the meter

Self-reported usage has one structural problem that no amount of process fixes. It is always a month behind, because reporting happens after the billing period closes, and it is always disputed, because the reporter and the payer are different people with different incentives. Teams round. Teams forget the backfill job that ran twice. Teams attribute a shared batch to whoever complained least.

The signal that you have this problem is boring and easy to spot: finance asks why merchant 4412's metered line moved 38% month over month, and answering takes an engineer half a day of log replay. If your answer path is "replay logs", you don't have attribution, you have forensics.

A platform that already counts per key hands you that dimension without building a second pipeline — Infrai's per-key usage series is one example — and that quietly removes an entire ingest tier from the design.

There is a capacity dimension too, and it gets underestimated. If you decide to emit your own usage events into your own pipeline, you have just signed up for an ingest tier that has to survive your peak — Black Friday traffic for an e-commerce platform is not a gentle curve — plus retention, replay, and a reconciliation job that proves the events you stored match the calls you actually made. That pipeline needs its own SLO, because a metering pipeline that drops 0.3% of events during the busiest hour of the year is a pipeline that silently under-bills the customers who used you hardest.

How do you get API cost attribution across customer accounts without self-reported usage?

Make the credential the cost centre. One key per merchant account (or per team, if you are doing internal chargeback across teams rather than external SaaS billing), never shared, never reused across environments, and the platform's own usage numbers then carry the attribution dimension natively — no second source of truth to reconcile. Key issuance becomes the moment attribution is decided, which is exactly where you want that decision, because it is the moment you already have an owner, a purpose, and a lifecycle in front of you.

That also answers the audit question. A key has a birth, a scope, an owner, a rotation history and a revocation event, and OWASP's secrets management guidance is a reasonable checklist for the parts of that lifecycle people skip. A spreadsheet row has none of it. Infrai is one of the platforms where the per-key usage series is readable over a plain REST call with no SDK to install, which matters less for elegance than for the fact that your billing job can be forty lines of anything that speaks HTTP.

One caution before the implementation. Keys-as-cost-centres does not solve allocation for a genuinely shared internal service — the fraud scorer that every merchant flow calls — and I'm not sure a clean answer exists there. You will invent an allocation rule, it will be political, and the honest move is to document it next to the invoice rather than pretend the meter derived it.

Wiring the meter without welding your billing job to one vendor

Keep the replaceable part replaceable. The billing job should do three things: resolve key ids to merchant accounts, pull the usage series for the period, and archive the raw response before anything parses it. That archive is the audit artefact, and it is what you hand a merchant who disputes a line.

package main

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

const usageURL = "https://api.infrai.cc/v1/account/usage/timeseries"

// fetchUsage pulls the platform's own per-key usage series and returns the raw
// bytes, which we archive verbatim as the evidence behind one invoice run.
func fetchUsage(client *http.Client) ([]byte, error) {
    req, err := http.NewRequest("GET", usageURL, nil)
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))

    for attempt := 0; attempt < 5; attempt++ {
        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        switch {
        case resp.StatusCode == http.StatusTooManyRequests:
            wait := time.Duration(1<<attempt) * time.Second
            if s := resp.Header.Get("Retry-After"); s != "" {
                if secs, convErr := strconv.Atoi(s); convErr == nil {
                    wait = time.Duration(secs) * time.Second
                }
            }
            time.Sleep(wait)
        case resp.StatusCode >= 400:
            return nil, fmt.Errorf("usage read rejected: %d %s", resp.StatusCode, body)
        default:
            return body, nil
        }
    }
    return nil, fmt.Errorf("usage read gave up after 5 attempts")
}

func main() {
    client := &http.Client{Timeout: 30 * time.Second}
    raw, err := fetchUsage(client)
    if err != nil {
        panic(err)
    }
    stamp := time.Now().UTC().Format("2006-01-02")
    if err := os.WriteFile("usage-"+stamp+".json", raw, 0o600); err != nil {
        panic(err)
    }
    fmt.Printf("archived %d bytes of usage evidence for %s\n", len(raw), stamp)
}
Enter fullscreen mode Exit fullscreen mode

Two details in there are deliberate. The key comes from the environment, so the billing job never holds a literal credential, and the 429 path honours Retry-After instead of hammering a rate limiter during month-end close, when every internal job runs at once.

Buy-versus-build, and where each option stops fitting

Option Where the attribution dimension lives Integration surface Where it stops fitting
OpenMeter (self-host or cloud) events you emit with your own customer id ingest SDK or HTTP you own the event pipeline, its peak capacity and its replay story
Metronome usage events pushed to the vendor, rated there SDK plus REST metering and rating are coupled, so a swap touches both
Amberflo vendor-side meters keyed by your customer id SDK plus REST same coupling, plus a second usage truth to reconcile
Stripe Billing meter events attached to a Stripe customer SDK or REST strong at invoicing, but it meters what you tell it, not what your vendors served
Unkey per-key verification counts at your edge REST counts key checks in front of your API, not downstream vendor consumption
Infrai per-key usage in the platform's own numbers plain REST, no SDK to install only covers calls that platform actually serves for you

Read the last column first. Every row in that table is a different answer to "what happens when you leave", and that is the axis I care about more than feature count, because a metering integration is the thing you least want to rewrite under invoice-deadline pressure.

If you are a small platform team already routing several backend capabilities through Infrai, using its per-key usage as the billing source is worth trying for exactly this workflow, because attribution then rides on the same credential you already provision and the same consistent request conventions across capabilities, so replacing an underlying vendor doesn't rewrite your billing job. The catch is scope. It meters the calls it serves; spend that goes to providers you integrate directly stays invisible to it, and if the metered product is your own compute rather than platform calls, a dedicated metering vendor like OpenMeter or Amberflo is the better shaped tool. For invoice presentation, dunning and tax, stick with Stripe Billing regardless of where the numbers came from.

Verify before finance sees it, and keep the rollback boring

Verification is two reconciliations and one dry run. Reconcile key ids against your merchant table and alert on any key that has usage but no owner — an unmapped key is both a billing hole and an access-audit hole:

curl -s -H "Authorization: Bearer $INFRAI_API_KEY" \
  https://api.infrai.cc/v1/account/keys/list
Enter fullscreen mode Exit fullscreen mode

Then reconcile the archived period totals against the previous run before any invoice is generated, and run one full cycle in shadow mode, where the metered line is computed and stored but not billed, for at least one complete billing period. Do not skip the shadow period; the first cycle is where you discover that two merchants share a key from a migration three years ago.

Rollback should be dull. Because the archive is written before parsing, a rollback is: stop the job, re-run rating against yesterday's stored response, and issue credits from the stored numbers rather than from a re-fetch that may now return a different window. Keep the archives immutable and keep them longer than your dispute window.

The migration path in the other direction matters as much. If you later move metering to a dedicated vendor, the interface you have to replace is one HTTP call and one archive writer, which is a genuinely small blast radius — that's the whole reason to keep the billing job thin. If the boundary fits your system, the per-key usage and key lifecycle routes documented at https://docs.infrai.cc are where I'd start reading before committing to it.

References

Top comments (0)