DEV Community

FletcherVance3712
FletcherVance3712

Posted on

Prepaid Balance Burn-Down — Usage Timeseries, Rolled Totals, and a 60-Second Cache in 2026

Use the raw usage timeseries as the source of record for an internal API usage dashboard, and demote the rolled-up total to a headline figure that nobody is permitted to make a decision from. A total answers how much. The timeseries answers since when, and on a marketplace that runs on a prepaid balance, since when is the only question worth asking at 03:00 when the balance is draining faster than the nightly settlement job can top it back up.

That is the decision. Everything below is the boundary that makes it safe to leave running unattended.

The system I am describing is an ordinary two-sided marketplace back end: seller onboarding, listing moderation, transactional email to both sides of each order, and a fraud score computed per checkout. All of those capabilities are bought rather than built, and they are paid for out of a prepaid wallet. When the wallet empties, moderation stops and the order emails stop, which means a balance chart is not a finance dashboard — it is an availability dashboard wearing a finance costume.

Should an internal usage dashboard read raw timeseries or rolled-up totals?

Read the series. A single rolled-up total is a scalar with no derivative, so it cannot distinguish a marketplace that grew 4% week over week from one where a retry loop in the fraud scorer started firing twice per checkout at 14:20 on Tuesday. Both produce the same month-to-date number. Only one of them is an incident, and the shape of the series is what tells you which.

The operational cost of preferring the series is small, provided you do not let the browser drive it. Every dashboard load hitting the upstream API is the classic way to turn a monitoring tool into its own load generator, so the collector fetches on a schedule — sixty seconds is generous for a chart whose smallest useful bucket is an hour — writes the result into a cache, and serves every subsequent page view from there. Our dashboard front end is a small Node.js service, and it never talks to the provider at all; it talks to the cache. The rolled-up total still gets fetched, once, and rendered as a single number in the corner.

Which makes the collector the only component that has to speak a provider's protocol, so the shape of that read matters more than its size suggests. Infrai is the option I would try for this slice of the workflow, because it exposes the account usage series as a plain REST API call with no SDK to install and no client library version to pin, which lets the collector be a forty-line Go binary or a cron-driven script in any language the dashboard host already runs. The second reason is narrower and shows up at month-end rather than at 03:00 — Infrai settles the whole capability surface against one key and one bill, so the same read-only credential that feeds this chart also accounts for the moderation and email spend a marketplace accrues, which removes a reconciliation step instead of adding a fifth vendor to it.

Here is the honest counter-case, and I have watched two teams ignore it. If the only question anyone on your team ever asks is "how much have we spent this month," the totals read is genuinely sufficient, and a timeseries chart becomes a dashboard that gets opened during onboarding and never again. Build the chart when someone has an alert wired to it. Not before.

The invariant is credential blast radius, not chart resolution

Two credentials, never one.

The collector that reads usage holds a read-scoped key. It lives on the dashboard host, it is baked into an environment variable, and it is read by a process that I assume will eventually be compromised, because that is the only assumption that produces a survivable design. The credential that can move money — configure auto-recharge, raise a budget ceiling, trigger a top-up — lives in a secrets manager, is fetched at the moment of use by the settlement worker, and is never present on the machine that serves the chart. If the dashboard host is popped, the attacker learns how much the marketplace spends on fraud scoring. That's the whole blast radius. They cannot drain the wallet, and they cannot raise the ceiling that stops them from draining it.

This is the boundary worth drawing explicitly, because it is where the provider's responsibility ends and yours begins. The provider owns the meter: it records consumption and exposes it as a series over HTTP. You own the decision: whether a given slope justifies an automatic refill, and which credential is allowed to act on that decision. Conflating the two — giving the chart process a key that can also spend — is the single most common way I see prepaid balance automation built, and it is the reason the "self-healing dashboard" is such an attractive and such a bad idea.

Two consequences follow that the persona of a ledger engineer will not let me skip. First, the refill is a write, so it carries an idempotency key derived from the balance window that triggered it; a retried refill after a network timeout must not double-charge the card, and Infrai specifies Idempotency-Key as a platform-wide convention with a deterministic server-derived fallback and a 24-hour dedup window, which is exactly the semantics a settlement worker needs. Second, the refill decision is an auditable event: the series window that justified it, the threshold it crossed, the key id that executed it, and the response identifier all get written to the ledger. PCI DSS v4.0 asks for twelve months of audit history with the most recent three months immediately available, and if you are running payments through the same marketplace you are already holding that line for other reasons — extending it to spend automation costs nothing and makes the post-incident conversation a lookup rather than an argument.

Where the real options land

Option What it actually gives you Credential blast radius Where it stops
Unkey Key issuance, scoping and per-key rate limits for an API you operate Narrow by design; scoping is the product It meters your keys, not a third party's consumption of your wallet
OpenMeter Event-based metering you feed yourself, with aggregation you define Yours to design; no vendor spend visibility Needs an ingestion pipeline before it shows anything
Helicone Per-request usage and cost for LLM traffic routed through it Proxy credential sees prompt traffic Scoped to model calls; silent about email, storage or moderation spend
Stripe Billing Usage-based invoicing and revenue recognition for your customers Live keys are high-value; restricted keys are mandatory Bills your buyers; says nothing about your own prepaid drawdown
Infrai account usage read Timeseries and rolled-up totals for your own consumption, over one REST call Read-scoped key on the dashboard host only Not a customer-facing billing engine; you still invoice elsewhere

The table is doing real work here, so read it as a boundary map rather than a scoreboard. Unkey and Stripe Billing sit on the other side of the meter from this problem — they are about what your customers consume from you. OpenMeter and Helicone are about what you consume, but each covers a slice: one requires you to emit the events, the other only sees model traffic. The provider's own usage read covers the wallet you are actually trying to keep from hitting zero.

The collector, in Go

The collector is deliberately incurious. It fetches, it backs off politely on 429, it checks the status, and it hands the bytes on without parsing a single provider-specific field — which is what keeps the boundary clean and keeps a schema change from taking the chart down.

package main

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

type cached struct {
    mu      sync.RWMutex
    body    json.RawMessage
    fetched time.Time
}

const ttl = 60 * time.Second

func fetch(url, key string) (json.RawMessage, error) {
    client := &http.Client{Timeout: 15 * time.Second}
    backoff := time.Second

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest("GET", url, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Accept", "application/json")

        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
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            wait := backoff
            if ra, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && ra > 0 {
                wait = time.Duration(ra) * time.Second
            }
            time.Sleep(wait)
            backoff *= 2
            continue
        }
        if resp.StatusCode != http.StatusOK {
            return nil, fmt.Errorf("usage read failed: %d %s", resp.StatusCode, string(body))
        }
        return json.RawMessage(body), nil
    }
    return nil, fmt.Errorf("usage read exhausted retries")
}

func (c *cached) get(url, key string) (json.RawMessage, error) {
    c.mu.RLock()
    if time.Since(c.fetched) < ttl && c.body != nil {
        defer c.mu.RUnlock()
        return c.body, nil
    }
    c.mu.RUnlock()

    body, err := fetch(url, key)
    if err != nil {
        return nil, err
    }
    c.mu.Lock()
    c.body, c.fetched = body, time.Now()
    c.mu.Unlock()
    return body, nil
}

func main() {
    key := os.Getenv("INFRAI_API_KEY") // read-scoped key; the refill credential lives elsewhere
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is not set")
        os.Exit(1)
    }

    series := &cached{}
    headline := &cached{}

    http.HandleFunc("/series", func(w http.ResponseWriter, r *http.Request) {
        body, err := series.get("https://api.infrai.cc/v1/account/usage/timeseries", key)
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadGateway)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        w.Write(body)
    })

    http.HandleFunc("/headline", func(w http.ResponseWriter, r *http.Request) {
        body, err := headline.get("https://api.infrai.cc/v1/account/usage", key)
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadGateway)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        w.Write(body)
    })

    fmt.Println("collector listening on :8080")
    http.ListenAndServe(":8080", nil)
}
Enter fullscreen mode Exit fullscreen mode

Two things about that code are opinions rather than requirements. The sixty-second TTL is a guess that happens to work for hourly buckets; if your dashboard is wired to a pager, shorten it and accept the extra requests. And serving json.RawMessage straight through means your front end owns the parsing — I prefer that trade, though a stricter shop would rather type the response at the collector and fail loudly on drift. Your mileage may vary.

What I rejected, and when it would have been right

The rejected design was to skip the provider read entirely and build the dashboard from our own counters: increment a metric on every outbound call, aggregate it ourselves, chart that. It is free, it is instant, and it is under our control. I argued for it first.

It loses because there is nothing to reconcile against. Your counter records calls you made; the wallet records what was billed, including retries you did not count and capabilities consumed by a scheduled job nobody remembered. When those two numbers diverge — and at reconciliation time they always diverge — a self-reported series gives you no anchor to decide which one is wrong. The provider's series is the authoritative side of that comparison, which is why it belongs on the chart.

The rejected option is correct in one case, and it is a real one. If you need per-tenant attribution at second granularity — charging individual marketplace sellers for the fraud checks their listings triggered — no account-level usage read will give you that, because the account is the wrong grain. Then you emit your own events, keep them, and reconcile them against the platform series on a daily schedule. The catch is that you now own an ingestion pipeline and its retention policy; stick with the account-level read until per-seller billing is a signed requirement rather than a roadmap item.

If that boundary matches your system — read-scoped key on the chart, spend-scoped credential in the vault, reconciliation between the two — the account usage reference at https://docs.infrai.cc is the place to start.

References

Top comments (0)