MVP Factory
ai startup development

Usage-based pricing: the metering infrastructure nobody talks about

KW
Krystian Wiewiór · · 5 min read

Meta 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: saas, backend, microservices, architecture, api


TL;DR

Usage-based pricing sounds simple until you try to implement it at scale. The real engineering challenges — idempotent event ingestion, aggregation strategy, the distributed billing boundary, and reconciliation — are where most teams bleed revenue or overcharge customers. This post walks through the architecture decisions that matter.


Why this matters now

Most teams treat metering as an afterthought. Ship the product, bolt on Stripe, hand-wave over event counting. That works until it doesn’t.

The shift to usage-based pricing isn’t a billing change — it’s an infrastructure change. In my experience building production systems, this distinction matters enormously. With B2B SaaS companies under increasing pressure to justify every seat and show tangible value per dollar, accurate metering is no longer optional. It is the product.


The four problems you will actually face

1. Idempotent event ingestion

Your services will emit duplicate events. Networks retry. Clients retry. Queues redeliver. If your metering pipeline counts every ingested event naively, you will overcharge customers and face chargebacks.

The solution is a deduplication key at ingestion time:

data class UsageEvent(
    val eventId: String,      // UUID from the emitting service
    val customerId: String,
    val metricName: String,
    val quantity: Long,
    val timestamp: Instant
)

// At ingestion layer — idempotency check before write
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 a write-optimized store (Cassandra, ScyllaDB) keyed on eventId. Reject duplicates at the edge, not downstream.

2. Aggregation strategy: pre-aggregate vs. raw storage

This is the tradeoff the numbers force you to make:

StrategyWrite CostRead CostBilling AccuracyReprocessing
Raw event storageLowHighExactFull replay possible
Pre-aggregation (hourly/daily)HighLowApproximateLossy
Hybrid (raw + rollups)MediumLowExactFull replay possible

For most SaaS workloads under 10M events/day, raw storage with periodic rollups is the right answer. Store immutable raw events in a columnar store (ClickHouse is excellent here), and run scheduled aggregation jobs to populate rollup tables that your billing service queries.

The moment you discard raw events for storage efficiency, you lose the ability to reprocess if you discover a bug in your aggregation logic. That’s a recoverable engineering mistake that becomes an unrecoverable revenue problem.

3. The billing boundary problem in distributed systems

This is the hardest problem. Your usage events are distributed across services. Your billing cycle has a hard cutoff. Events arrive late.

You need three distinct boundaries:

  • 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. But you need a grace window — typically 24–72 hours — to accept late-arriving events before you close a billing period and invoice.

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. Customers who ask about it are the ones who will catch your bugs before you do.

4. Reconciliation patterns to prevent revenue leakage

Most usage-based billing errors are silent. You won’t know you’re under-counting until you run a reconciliation job — and by then you may have already closed several billing periods.

Build a reconciliation service that runs independently of your billing service and cross-checks:

  1. Source count — events emitted by your application (pull from application 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 billing logic error. Alert on both, with percentage thresholds (>0.1% discrepancy warrants investigation).


What actually matters

Three things, in order of importance.

Build idempotency into ingestion from day one. Retrofitting deduplication onto a live pipeline is one of the most painful migrations you will ever run — add the eventId constraint before you have any customers.

Never discard raw events. Pre-aggregation is a read optimization, not a storage strategy. Keep your raw event log immutable and append-only. Your future self debugging a billing discrepancy at 2am will thank you.

Treat the billing boundary as a first-class system concern. Define your grace window, document it, enforce it in code, and build reconciliation as a separate service with independent alerting. Revenue accuracy isn’t a billing team problem — it’s an engineering architecture problem.


Share: Twitter LinkedIn