DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Usage-Based Pricing Engineering: Metering, Aggregation, and the Billing Pipeline That Doesn't Lie to Your Customers

---
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.

Enter fullscreen mode Exit fullscreen mode


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.

Enter fullscreen mode Exit fullscreen mode


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())
)
Enter fullscreen mode Exit fullscreen mode

}


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:

Enter fullscreen mode Exit fullscreen mode


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:

Enter fullscreen mode Exit fullscreen mode


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)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)