---
title: "Usage-Based Billing That Doesn't Lie: Building an Idempotent Metering Pipeline"
published: true
description: "Architect a usage-based billing system that survives duplicate events, clock skew, and retroactive corrections — with idempotent aggregation and Stripe sync."
tags: kotlin, architecture, api, security
canonical_url: https://mvpfactory.co/blog/usage-based-billing-metering-pipeline
---
## What We Are Building
By the end of this post you will know how to architect a metering pipeline that survives the failures that kill naive implementations in production: duplicate events, clock skew, and retroactive corrections. We will wire together an idempotent ingestion layer, a windowed aggregation worker, and a Stripe sync that will not double-charge your customers on retry.
Fair warning: this is the kind of deep-focus engineering where hours disappear. HealthyDesk has saved me from back-to-back sessions staring at ledger tables — worth having running in the background.
## Prerequisites
- Familiarity with event-driven systems and queues
- A basic understanding of Stripe's billing API (UsageRecord writes)
- Kotlin for the code examples, but the patterns translate directly to any server-side stack
---
## The Pattern I Use in Every Billing Project
Usage metering looks like logging. It is not. It is a **financial system**, and the correctness bar is entirely different. The naive implementation — fire events to a queue, aggregate nightly, push totals to Stripe — works in staging and fails in production in exactly three ways:
| Failure class | Naive result |
|---|---|
| Duplicate events | Double-billing customers |
| Clock skew | Events land in the wrong billing window |
| Retroactive corrections | Your ledger is permanently wrong |
Here is the architecture that actually holds.
---
## Step 1 — Idempotent Event Ingestion
Every usage event needs a **client-generated** idempotency key. Not a server UUID. The client owns the identity of the event.
kotlin
data class UsageEvent(
val idempotencyKey: String, // SHA-256(tenantId + resource + timestamp + nonce)
val tenantId: String,
val metric: String,
val quantity: Long,
val occurredAt: Instant, // client-side wall clock
val receivedAt: Instant // server-side, set at ingestion boundary
)
Store `idempotencyKey` with a unique constraint. Duplicate submissions return HTTP 200 with the original result — no error, no retry storm. The client never needs to know. This is structural idempotency, not a convention you hope engineers will follow.
Use `occurredAt` for billing window assignment. Events arriving more than 24 hours late relative to `receivedAt` should trigger a manual review flag, not silent acceptance. NTP drift on well-configured infrastructure is milliseconds, not hours. An event outside that 24-hour delta is almost certainly a client bug, a deployment artifact, or a manipulation attempt.
---
## Step 2 — Windowed Aggregation
Never aggregate raw events at query time for billing. Pre-aggregate into immutable time-window buckets.
kotlin
fun aggregateWindow(tenantId: String, windowStart: Instant, windowEnd: Instant) {
val events = eventStore.query(tenantId, windowStart, windowEnd)
val totals = events.groupBy { it.metric }.mapValues { (_, evts) -> evts.sumOf { it.quantity } }
// Upsert — safe to re-run
aggregateStore.upsert(
AggregateRecord(tenantId, windowStart, windowEnd, totals, computedAt = Instant.now())
)
}
The upsert is load-bearing. Re-running aggregation over a closed window must produce the same result. That property is what makes retroactive corrections possible without corrupting your ledger.
---
## Step 3 — Stripe Sync
Use Stripe's `Idempotency-Key` header on every `UsageRecord` write, keyed to your internal aggregate ID:
http
POST /v1/subscription_items/{si_id}/usage_records
Idempotency-Key: agg_{tenantId}{windowStart}{metric}
Stripe's deduplication window is 24 hours. Retries within that window with the same key are safely deduplicated. Outside 24 hours, the same key will not deduplicate — never retry a sync job across day boundaries without first checking whether the record already exists.
---
## Step 4 — Retroactive Corrections
When you discover a metering bug — and you will — the instinct is to patch historical aggregates. Do not. Model corrections as signed adjustment events:
kotlin
data class UsageCorrection(
val originalAggregateId: String,
val delta: Long, // negative to reduce, positive to add
val reason: String,
val authorizedBy: String,
val appliedAt: Instant
)
Your aggregate store stays append-only. Corrections are reversible. Your finance team has an audit trail when a customer disputes a charge.
---
## Gotchas
**Conflating `occurredAt` and `receivedAt`** is a schema mistake you cannot easily fix later. Billing window assignment lives on `occurredAt`. Skew and fraud detection lives on the delta between the two. Separate them from day one.
**Retrying Stripe writes without idempotency keys** will silently double-report usage. Every write, every time, must carry a deterministic key derived from your internal aggregate ID.
**In-place aggregate updates** will eventually cause an audit crisis. Treat your aggregate store as an append-only ledger from the start. No deletes, no patches — only corrections modeled as signed records.
---
## Conclusion
Let me show you the three decisions that change everything: make idempotency structural with client-generated keys and unique constraints; separate `occurredAt` from `receivedAt` in your schema on day one; and treat your aggregate store as an append-only ledger with explicit correction records.
The docs do not mention this, but most teams learn all three of these lessons from a billing incident, not from reading ahead. Now you can skip that part.
**Further reading:** [Stripe idempotency keys](https://stripe.com/docs/api/idempotent_requests) · [Stripe usage records API](https://stripe.com/docs/api/usage_records)
Top comments (0)