DEV Community

CianWinslow371
CianWinslow371

Posted on

Per-Key API Spend: Publishing One Analytics Event per Period

Short answer: publish one immutable spend event for each API key and accounting period, then reconcile that event against the provider statement before it reaches dashboards. This keeps a prepaid balance from draining unnoticed while containing the blast radius of a leaked or misused credential.

The constraint is the credential, not the chart

In a fintech system, “spend by key” sounds like a reporting field. It is an operational boundary. A key may belong to a merchant, a batch worker, or a settlement job, and its balance can cross a policy threshold long before a monthly report is read. The useful unit is therefore a signed, period-scoped record with an owner, a source window, and a replay-safe identity.

I model the identity as (key_id, period_start, period_end, currency, source_revision). The period can be an hour for a high-risk prepaid account or a day for a quieter workload. A second delivery of the same record must be harmless; exactly-once effects come from a unique constraint and reconciliation, not from trusting a queue's delivery promise.

The audit trail should answer three questions without opening application logs: who owned the key, what usage window was measured, and which source revision produced the amount. Keep the raw provider response in restricted storage, retain a normalized amount for analytics, and record the hash of the raw payload. That separation limits sensitive exposure while preserving evidence for a dispute.

How should per-key API spend become one analytics event per period?

Start with an event contract that is boring to validate. Here is a Go representation suitable for a producer or an outbox row:

package spend

import (
    "fmt"
    "time"
)

type Event struct {
    EventID       string    `json:"event_id"`
    KeyID         string    `json:"key_id"`
    PeriodStart   time.Time `json:"period_start"`
    PeriodEnd     time.Time `json:"period_end"`
    AmountMinor   int64     `json:"amount_minor"`
    Currency      string    `json:"currency"`
    SourceRevision string   `json:"source_revision"`
    PayloadSHA256 string    `json:"payload_sha256"`
    ObservedAt    time.Time `json:"observed_at"`
}
Enter fullscreen mode Exit fullscreen mode

EventID is deterministic, for example a SHA-256 digest of the normalized identity fields. The consumer stores it with a unique key and applies an upsert that rejects a conflicting amount for the same identity. A duplicate delivery becomes a no-op; a changed amount becomes a reconciliation exception. Do not silently overwrite it.

The producer should close a period only after the usage source has a defined watermark. Late usage is common around retries and clock skew, so emit a correction event with a new source_revision rather than mutating history. Analytics can sum the latest revision, while the ledger retains every revision and its reason code. This is slower than writing a single mutable row, but it gives incident review a chronology instead of a guess.

Consider a concrete overnight run. At 00:05 UTC, the worker closes the previous day for key merchant-184, writes event sha256(period identity), and commits the outbox row with the balance snapshot. The broker delivers that row twice because the consumer acknowledgement arrives after a timeout. The unique event ID turns the second delivery into a no-op. At 02:10, a provider statement adds a late retry from 23:59:58; the meter creates revision 2, stores the original payload hash beside the correction reason, and leaves revision 1 untouched. A dashboard that selects the latest revision shows the corrected amount, while an auditor can still see why the total changed. If the correction would cross a spending limit, the policy service receives the exception before the next batch starts. This sequence is deliberately mundane: it is the kind of case that exposes whether an “exactly once” claim is backed by durable keys, timestamps, and reconciliation, or is only a hope hidden behind a queue setting.

type Sink interface {
    Publish(Event) error
}

func PublishOnce(s Sink, e Event) error {
    if e.EventID == "" || e.KeyID == "" || !e.PeriodEnd.After(e.PeriodStart) {
        return fmt.Errorf("invalid spend event")
    }
    return s.Publish(e)
}
Enter fullscreen mode Exit fullscreen mode

The example intentionally leaves transport and storage abstract. Your implementation still needs a database uniqueness constraint, an outbox transaction, and a dead-letter path with bounded retries. I am not sure whether an hourly or daily period is right for your risk model; measure the time from threshold breach to human response, then choose a period shorter than that window.

Failure modes that make attribution lie

The first failure is key reuse. If one credential serves ten merchants, the event can be perfectly accurate and still useless for chargeback or throttling. Issue separate keys at the smallest ownership boundary that operations can actually revoke. OWASP's Secrets Management Cheat Sheet recommends controlled secret lifecycles and access auditing; those controls belong beside the event pipeline, not in a separate security project.

The second failure is treating a dashboard as a ledger. A late invoice, a retried request, or a currency conversion can move a total after the graph has been cached. Keep minor units and an explicit currency in the event, and reconcile a period against an authoritative statement before marking it final. If a statement is unavailable, label the period provisional instead of inventing certainty.

The third failure is an unbounded retry storm. Backoff with jitter, cap attempts, and expose queue age, duplicate rate, correction rate, and the oldest unreconciled period as metrics. Alert on the prepaid balance's projected exhaustion, not only on a failed publish. A green API response is not proof that an accounting event was durable.

Short paragraph. That distinction catches expensive incidents.

Choosing controls and accepting the trade-offs

Managed event brokers reduce delivery plumbing, while a self-hosted queue gives tighter control over retention and network boundaries. Neither choice fixes an ambiguous ownership model. A warehouse-first design is convenient for analysis but weak for immediate balance protection; a transactional outbox is stronger for durability but adds schema and operational work.

The catch is that this pattern is not suitable when the source cannot provide a stable usage watermark or when key ownership is intentionally pooled. In those cases, keep a coarser account-level meter and use a separate authorization service for limits. Stick with a simple periodic export when the financial consequence of a delayed event is low and the team cannot operate a durable queue. The right design is the one whose failure mode the on-call team can explain at 03:00.

Roll out in a shadow period: write events without enforcing thresholds, compare daily sums with statements, inject duplicate and late records, then enable alerts for one account class. Only after reconciliation variance is understood should a prepaid balance block new work. Preserve the old aggregate during migration so a rollback does not erase audit history.

References

Top comments (0)