DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

Usage-Based Pricing for SaaS: Metering Infrastructure, Billing Accuracy, and the Engineering Tradeoffs Nobody Talks About

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

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

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

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:

  1. Source count — events emitted by your application (from logs or a secondary event sink)
  2. Pipeline count — events recorded in your metering store
  3. 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)