---
title: "Usage-Based Pricing: The Metering Infrastructure Nobody Talks About"
published: true
description: "How to architect a metering pipeline for usage-based SaaS billing — covering idempotent ingestion, aggregation tradeoffs, the billing boundary problem, and reconciliation patterns that prevent revenue leakage."
tags: [kotlin, architecture, api, backend]
canonical_url: https://mvpfactory.co/blog/usage-based-pricing-metering-infrastructure
---
What We Are Building
Let me show you a pattern I use in every usage-based SaaS project — a metering pipeline that actually holds up in production.
By the end of this walkthrough you will understand how to ingest usage events idempotently, choose the right aggregation strategy, handle the billing boundary in distributed systems, and build reconciliation that catches revenue leakage before it costs you customers.
This is the engineering work that sits behind every per-seat or per-API-call pricing model. Most teams bolt it on as an afterthought. That is where billing bugs are born.
Prerequisites
- Familiarity with event-driven architecture
- A write-optimised data store (Cassandra, ScyllaDB, or similar)
- Redis for deduplication (or a DB with unique constraint support)
- ClickHouse or a columnar store for raw event retention
- Kotlin (examples below) — the patterns translate directly to any typed backend language
Step 1 — Idempotent Event Ingestion
Your services will emit duplicate events. Networks retry. Clients retry. Queues redeliver. If your pipeline counts every ingested event naively, you will overcharge customers.
Here is the minimal setup to get this working:
data class UsageEvent(
val eventId: String, // UUID from the emitting service
val customerId: String,
val metricName: String,
val quantity: Long,
val timestamp: Instant
)
fun ingest(event: UsageEvent): IngestResult {
if (eventStore.exists(event.eventId)) return IngestResult.DUPLICATE
eventStore.insert(event)
return IngestResult.ACCEPTED
}
Use a Redis SET or a unique constraint on your write-optimised store keyed on eventId. Reject duplicates at the edge, not downstream. Retrofitting this onto a live pipeline is one of the most painful migrations you will ever run — add the constraint before you have any customers.
Step 2 — Aggregation Strategy
This is the tradeoff the numbers force you to make:
| Strategy | Write Cost | Read Cost | Billing Accuracy | Reprocessing |
|---|---|---|---|---|
| Raw event storage | Low | High | Exact | Full replay possible |
| Pre-aggregation (hourly/daily) | High | Low | Approximate | Lossy |
| Hybrid (raw + rollups) | Medium | Low | Exact | Full replay possible |
For most SaaS workloads under 10M events/day, raw storage with periodic rollups is the right answer. Store immutable raw events in ClickHouse, run scheduled aggregation jobs, and have your billing service query the rollup tables.
The docs do not mention this, but the moment you discard raw events for storage efficiency, you lose the ability to reprocess when you find a bug in your aggregation logic. That is a recoverable engineering mistake that becomes an unrecoverable revenue problem.
Step 3 — The Billing Boundary Problem
This is the hardest problem in the list. Your usage events are distributed across services. Your billing cycle has a hard cutoff. Events arrive late.
You need three distinct boundaries in your system:
- Collection boundary — when your pipeline accepts the event
- Effective boundary — the timestamp the emitting service recorded
- Billing boundary — the period the event is counted toward
Always bill on effective timestamp, not ingestion timestamp. Then enforce a grace window — typically 24–72 hours — before closing a billing period:
fun isWithinBillingPeriod(event: UsageEvent, period: BillingPeriod): Boolean {
val graceWindow = Duration.ofHours(48)
return event.timestamp >= period.start &&
event.timestamp < period.end &&
Instant.now() < period.end + graceWindow
}
Events arriving after the grace window get bucketed into the next period or flagged for manual reconciliation. Document this in your terms of service. Customers who ask about it are the ones who will catch your bugs before you do.
Step 4 — Reconciliation to Prevent Revenue Leakage
Most usage-based billing errors are silent. Build a reconciliation service that runs independently and cross-checks three counts:
- Source count — events emitted by your application (from logs or a secondary event sink)
- Pipeline count — events recorded in your metering store
- Billed count — events included in the last invoice
Any gap between source and pipeline count is pipeline loss. Any gap between pipeline and billed count is a billing logic error. Alert on both, with a threshold of >0.1% discrepancy triggering investigation. This service should be completely independent of your billing service — shared infrastructure means shared failure modes.
Gotchas
Pre-aggregation feels like a storage win until it isn't. Pre-aggregation is a read optimisation, not a storage strategy. Keep your raw event log immutable and append-only.
Ingestion timestamp versus effective timestamp. Teams almost universally bill on the wrong one in their first implementation. Effective timestamp is what your customer expects; ingestion timestamp is what your system sees first.
Reconciliation as an afterthought. Most teams build it after discovering a discrepancy. By then multiple billing periods may be closed and unrecoverable. Build the reconciliation job before you go live.
No grace window definition. Undefined grace windows mean inconsistent behaviour across billing periods. Define it in code and in your customer-facing terms before you send invoice one.
Conclusion
Three things, in order of importance: build idempotency into ingestion from day one, never discard raw events, and treat the billing boundary as a first-class architectural concern.
Usage-based pricing is not a billing change — it is an infrastructure change. Get the foundation right and the pricing model becomes a competitive advantage. Get it wrong and you are debugging silent revenue leakage at 2am.
Further reading:
Top comments (0)