Design usage metering and billing
Open in the interactive app: practice, quiz, mock interview →In one minute
- Every usage event carries a producer-assigned idempotency key. Pipelines retry, so duplicates are certain. The key, enforced at the aggregator and again in the archive, bills one use once.
- Bill by event time, with a closing rule. Aggregate by when usage happened, close after a stated lateness bound, and send later arrivals through a visible adjustment path.
- Keep raw events, aggregates and invoices apart. Events are immutable facts, aggregates are rebuildable, and a final invoice changes only through credit notes and adjustment lines.
- Money is integers with a written rounding rule. Micro-unit prices, one rounding step per line, a ledger that sums to zero, and no floats.
- Reconcile against an independent count, such as gateway counters, before invoices go out.
Requirements
Functional
- Accept usage events (API calls, tokens, GB-hours) from many services, each tied to a customer, a metric, a quantity and an event time.
- Aggregate usage per customer, metric and window, and expose it in near real time for dashboards, quotas and spend alerts.
- Rate usage against a price plan: flat rates, graduated tiers, included allowances, minimums and per-plan overrides, with plan changes mid-period.
- Produce monthly invoices with line items, apply prepaid and promotional credits, and handle corrections after the invoice is issued.
- Support audit: for any invoice line, show the raw events and the plan version behind it.
Non-functional
- Billing accuracy: no event is counted twice, and no accepted event is lost. Invoices reproduce exactly when re-run from the same inputs.
- Ingest 200k events per second at peak with p99 under 100 ms at the API, durable before it acknowledges.
- Dashboards lag by under 5 minutes. Invoices need correctness, not speed: a daily or monthly batch is fine.
- Raw events retained at least 13 months for disputes.
Out of scope: payment collection and retries on cards, tax calculation, and the rate limiter that protects the API (a separate, approximate mechanism).
Back-of-envelope
- Events: 40k paying customers and 5B billable events per day. 5B / 86,400 ≈ 58k events/s on average. With a 3x daily peak, about 175k events/s.
- Raw size: about 200 bytes per event (ids, customer, metric, quantity, timestamps, a few dimensions). 5B × 200 B = 1 TB/day. Compressed columnar files in object storage shrink that about 5x, so five years is about 1.8 PB raw or 365 TB compressed.
- Ingest bandwidth: 175k × 200 B ≈ 35 MB/s at peak, about 105 MB/s of disk writes at replication factor 3. Modest for a partitioned log.
- Dedup state: keep event ids for 48 hours. 10B ids × about 24 bytes ≈ 240 GB. Split over 64 partitions that is under 4 GB each, which fits in an embedded key-value store on local SSD but would be wasteful in RAM.
- Aggregates: 40k customers × 5 metrics × 24 hours = 4.8M hourly rows per day. At 100 bytes that is 480 MB/day and about 880 GB over five years. This is the table dashboards and rating read.
- Invoices: 40k per month with about 20 lines each. The invoice side is a correctness problem, not a scale problem.
API
Producers send batches. Each event has an id the producer generates and keeps stable across retries.
POST /v1/usage-events
Authorization: Bearer <service-token>
{
"events": [
{"event_id": "gw-7f3a-000912", "customer_id": "cus_8832", "metric": "llm_tokens_out",
"quantity": 1840, "timestamp": "2026-03-14T09:15:02.113Z",
"dimensions": {"model": "large", "region": "eu"}}
]
}
-> 207 {"results": [{"event_id": "gw-7f3a-000912", "status": "accepted"}]}
status is one of: accepted | duplicate | rejected(reason)GET /v1/customers/cus_8832/usage?metric=llm_tokens_out&from=2026-03-01&to=2026-03-15&granularity=day
POST /v1/usage-events/adjustments {"cancel": {"event_id": "gw-7f3a-000912"}, "reason": "double-emitted by gateway bug"}
POST /v1/invoices/preview {"customer_id": "cus_8832", "period": "2026-03"}
POST /v1/invoices/{id}/finalize Idempotency-Key: close-cus_8832-2026-03
POST /v1/credit-grants {"customer_id": "cus_8832", "amount_cents": 50000, "expires_at": "2027-03-01"}Stripe's meter-event API has the same shape: an event name, a customer in the payload, a numeric value, a caller-chosen identifier, and a timestamp accepted only within the past 35 days or up to 5 minutes ahead (Stripe API). Its adjustment endpoint cancels a single event by identifier and reports pending until processed (Stripe API). A bound on how old an event may be is what makes closing a window possible at all.
Data model
usage_events(immutable, append-only, in a log plus columnar files). Key(customer_id, event_id), partitioned bycustomer_idhash and event-time day. Columns: metric, quantity (integer), event time, ingest time, source, dimensions.usage_aggregates. Primary key(customer_id, metric, window_start)withquantity,event_count, andwatermark_version. Upserted by the aggregator. Because it is derived, it can always be rebuilt fromusage_events.price_plansandplan_versions. A version is immutable, witheffective_from. Tier rows hold(plan_version, metric, up_to, unit_price_micros). A customer subscription points at a version, never at a mutable plan.invoicesandinvoice_lines. Line items store quantity, unit price, plan version, a rounded amount in minor units, andadjusts_periodfor correction lines. Unique key(customer_id, period)makes finalising idempotent.ledger_entries. Double entry: each posting has a debit account, a credit account, an amount, and a reference to the invoice or credit grant. Postings in one transaction sum to zero.credit_grants: amount, remaining, expiry. Drawdown is soonest-expiring first.
Store money as integers in the smallest unit that matters (cents for amounts, micro-dollars for unit prices) or as Postgres numeric, which is exact, and never as float or double precision, which the Postgres docs describe as inexact (PostgreSQL docs).
High-level design
flowchart LR GW["Services / API gateway"] --> BUF["Local buffer + retry"] BUF --> ING["Ingest API"] ING --> LOG["Durable log (partitioned by customer)"] LOG --> AGG["Dedup + window aggregator"] LOG --> RAW["Raw event archive (object store)"] AGG --> AGGDB["Usage aggregates"] AGGDB --> DASH["Dashboards, quotas, spend alerts"] AGGDB --> CLOSE["Period close job"] PLAN["Price plan versions"] --> CLOSE CLOSE --> INV["Invoices + ledger"] INV --> PAY["Payment + credit notes"] GW --> CNT["Independent counters"] CNT --> REC["Reconciliation"] AGGDB --> REC INV --> REC
Write path. A service emits an event with a stable id into a local buffer (a disk-backed queue), which retries until the ingest API acknowledges. The ingest API validates the schema, rejects timestamps outside the allowed window, and appends to a log partitioned by customer. It acknowledges only after the log has replicated the write. The aggregator reads each partition, drops duplicate ids using local keyed state, and adds the quantity to the hourly window for the event's time. It commits state and offsets together so a restart does not double-add. A second consumer writes the same stream to the raw archive.
Read path. Dashboards and spend alerts read usage_aggregates, which lag by a minute or two and show the current window as provisional. The period-close job runs after the lateness bound has passed, reads aggregates for each customer, rates them against the plan versions in force, writes the invoice and ledger postings in one transaction, and emits an event to payments. Reconciliation runs alongside: it compares independent counters, aggregates, and invoiced quantities, and flags drift beyond a threshold.
Deep dives
1. Idempotent ingestion and duplicate suppression
Duplicates come from three places: a producer retries after a timeout that hid a success, the log redelivers after a consumer restart, and a bug emits an event twice with different ids. Mechanism handles the first two; reconciliation handles the third.
Options for the first two:
- Rely on the transport. Log systems offer idempotent producers and transactional writes, which stop retry duplicates inside one pipeline. They do not stop a service that restarts and re-emits from its own queue with new sequence numbers.
- Unique key in the sink. A unique
(customer_id, event_id)in a database. Exact, but costly at 175k events/s. - Keyed dedup state in the stream processor, plus a unique key in the archive. The aggregator keeps ids for 48 hours in local state; the archive writer deduplicates again by key on compaction.
Choice: the third. The key is producer-assigned and stable across retries, which the idempotent-consumer pattern depends on (microservices.io) and which Stripe applies to payment requests (Stripe blog). Stripe's meter API enforces identifier uniqueness over a rolling period of at least 24 hours, not forever; Lago's docs describe the same trade-off, with its high-volume pipeline using both transaction_id and timestamp for uniqueness (Lago docs). A retry after the dedup window looks new, so the window must exceed the longest retry horizon. The same id with a different quantity is flagged as a conflict, not silently dropped.
2. Windows, late events and the closing rule
Aggregate by event time, or a backed-up queue bills a midnight burst to the wrong day. That needs a notion of "no more events older than T are expected", which stream systems call a watermark. Flink's bounded-out-of-orderness strategy states it directly: the watermark trails the maximum timestamp seen by a fixed delay (Flink docs). The Dataflow paper adds the key insight that a watermark is a heuristic, so a window can fire again for late data, with accumulating or retracting output (Akidau et al., VLDB 2015).
Billing adds a twist: the invoice has a hard close. The scheme has three clocks:
- Dashboard windows stay open and update as late data arrives. Nobody is charged yet.
- The invoice close happens after a lateness bound, for example 48 hours past the period end, which is also the maximum event age the API accepts.
- Post-close late events (a mobile client offline for a week, a replayed backfill) are still stored, marked
late, and billed as an adjustment line on the next invoice.
Adjustment lines should be re-rating deltas. Rate the whole original period with the late usage included, subtract what was already billed, and bill the difference. Rating the late quantity alone is wrong under graduated tiers, because its tier depends on everything before it. The practice problem implements this.
3. Rating, credits, and money correctness
Rating is a pure function: (plan version, aggregated quantity) -> line amount. Keep it pure, versioned and re-runnable, so an auditor or a support engineer can reproduce any line.
- Units and rounding. Unit prices like $0.000002 per token do not fit in cents. Hold prices in micro-dollars, multiply by an integer quantity, and round once, at the invoice line, to minor units with a documented mode (half up here). Rounding per event instead of per line produces systematic drift: a million events each rounded up by a fraction of a cent adds visible dollars.
- Plan changes mid-period. Split the period at the effective instant and rate each segment under its own version.
- Credits. Prepaid and promotional credits are balances with expiry. Stripe models them as credit grants applied to usage-based invoices, for prepayment or promotions with expiry dates (Stripe docs). Drawdown is an append-only ledger entry per invoice, not an edited balance, and the draw happens in the same transaction that finalises the invoice.
- Ledger. Post double-entry rows so every transaction sums to zero. Square's Books service chose this model after finding that a single-entry settlement database let payouts drift from payments, because it forces each cent moved to be matched by a cent received (Square Engineering).
- Finalisation is idempotent. A unique
(customer_id, period)means a re-run after a crash yields the same invoice.
Down the stack
Layer 1The contract
- Exactly-once effect, at-least-once delivery. An event with a given
(customer_id, event_id)contributes to at most one invoice line, once. A retry within the dedup window returnsduplicateand changes nothing; the same id with a different payload returns a conflict. - Bounded staleness. Dashboards include events acknowledged more than 2 minutes ago. Invoices include every in-period event acknowledged before the close; later arrivals are billed as adjustments within one cycle.
- Reproducibility. Re-running the close for a period with the same raw events and plan versions yields byte-identical lines. Finalised invoices are never edited; corrections are new documents that reference the old one.
- Acknowledgement means durable. Success is returned only after the log has replicated the batch.
Layer 2The mechanism
- Ingest. Batches of up to 500 events are appended to a replicated log partitioned by
hash(customer_id), so one aggregator owns each customer and dedup stays local. - Aggregator state machine. For each event: look up
(customer, event_id)in the keyed store; if present, count a duplicate and stop; else add the id with a 48-hour TTL, add the quantity to the(customer, metric, hour)cell, and emit the updated cell. Offsets and state commit atomically, which is the same exactly-once-by-checkpoint idea stream engines use. Kleppmann describes this reliance on idempotence and atomic commit as the way stream processors get exactly-once effects (Designing Data-Intensive Applications, 1st ed., ch. 11). - Window closing. A watermark per partition equals the maximum event time minus the lateness bound. The close job requires watermark ≥ period end plus bound for every partition of the customer, so a stalled partition delays invoices instead of producing short ones.
- Rating. Tiers are rows
(up_to, unit_price_micros). Graduated rating walks them, takingmin(remaining, up_to - previous_cap)units in each. Everything isint64, whose maximum of 9.22 × 10^18 micro-dollars is about $9.2 trillion. - Close transaction. One database transaction inserts the invoice, lines, ledger postings, credit drawdowns and an outbox row for the payment event.
Layer 3The machine
- Durability cost. A replicated log append waits on an
fsyncor a replica quorum, around 1 to 5 ms in a zone. A batch of 500 events amortises that to microseconds per event, and 35 MB/s is trivial for sequential writes on NVMe, which sustains over 1 GB/s. - State store. Dedup state in an embedded LSM tree on local SSD takes a write to the memtable plus a sequential write-ahead log append per new id, and a point read, mostly served by Bloom filters and block cache, for each lookup.
- Clocks. Event timestamps come from many machines. NTP keeps most hosts within tens of milliseconds, but a drifting host can be seconds or minutes off. Stamp ingest time too, and reject events outside the accepted window, or one wrong clock moves usage across a month boundary.
- Arithmetic. An IEEE 754 double has 53 bits of mantissa, so integers are exact only to 9.0 × 10^15, and 0.1 + 0.2 ≠ 0.3. Float sums depend on addition order, which differs between a streaming run and a batch rebuild. Integer sums do not.
Why it's built this way
- A lost acknowledgement can only be answered by retrying, so duplicates exist and idempotency keys come first.
- Clocks differ and queues back up, so billing uses event time, whose watermark is a guess. A lateness bound and an adjustment path are therefore part of the product.
- Customers dispute bills, so raw facts are immutable, aggregates are rebuildable, and invoices are corrected by new documents.
- Money must add up to the cent, so integers, one rounding step and a double-entry ledger that fails loudly.
- A counter cannot see its own bugs, so an independent count reconciles before money moves.
Failure modes
- Retry storm or 10x traffic. A producer outage ends and 4 hours of buffered events arrive at once. Ingest sheds load with
429andRetry-After, producers back off with jitter, and the log absorbs the burst. Dashboards show staleness; invoices wait for watermarks. - Hot customer. One tenant sends 30% of traffic and saturates its partition. Sub-partition by
(customer_id, hash(event_id) mod k)for ingest, pre-aggregate in the producer or a combiner stage, and merge sub-aggregates per window, which works because sums are commutative. - Region outage. Producers keep buffering and ingest fails over. Stable ids make replays dedupe, and watermarks wait for the lost partitions, so close is delayed, not wrong.
- Dedup state loss. Restore from the last checkpoint and replay the log from the matching offset. If that is impossible, rebuild aggregates from the raw archive, which deduplicates by key independently.
- Pricing bug. A wrong tier price goes live. Plan versions are immutable, so the blast radius is invoices rated with that version. A preview diff against last month gates finalisation, and mispriced invoices get credit notes, not edits.
- Double emission by a service bug. Different ids, same usage, invisible to dedup. Reconciliation against the gateway's independent counter trips when the ratio leaves a band around 1.0, and a human cancels the events with an adjustment.
In the wild
- Twilio, 2013: a read-only Redis balance store triggers repeated card charges. A network blip made replicas resync, the Redis master then lost its balance data and, through an incorrect configuration, came back as a replica of itself, read-only and with balances at zero, and each billable usage then triggered an auto-recharge that could not update the balance. About 1.4% of customers were affected, billing was shut down, and charges were refunded with a 10% credit. Twilio changed recovery to pivot a replica, and added fail-safes so charges and suspensions are blocked when balances are missing or unwritable (Twilio post-mortem).
- Stripe, 2017: idempotency keys for safe retries. Stripe's API accepts a client-chosen key on mutating requests and stores the outcome, so a retry after a timeout gets the stored result instead of a second charge. Clients retry with exponential backoff and jitter. The post is about payments, but its reasoning (a lost response must not become a second effect) is the reason usage APIs ask callers for a stable event identifier (Stripe blog).
- Square, 2019: moving settlement onto an immutable double-entry ledger. Square found its settlement tables could drift from payments, since nothing stopped a payout reference being nulled out, and sharding added cross-shard transfers on top. It built Books on Cloud Spanner, where every journal entry balances to zero (Square Engineering).
Trade-offs
| Decision | Option A | Option B | Choose when |
|---|---|---|---|
| Dedup location | Stream processor state with TTL | Unique key in a database | A for volume with a bounded retry horizon, B when events are low volume and retries can be arbitrarily old |
| Billing time basis | Event time with lateness bound | Ingest (processing) time | A when customers dispute usage dates, B only if producers are unreliable and you accept mis-dated usage |
| Late data | Adjustment line on next invoice | Hold invoice open longer | A for predictable invoice dates, B for contracts that demand exactness and tolerate delay |
| Money type | Integer micro/minor units | Decimal type in the database | A in code that rates in the pipeline, B in SQL-heavy reporting; never floats |
| Build or buy | Own pipeline | Metering vendor or Stripe-style meters | Buy until pricing logic or volume outgrows it |
Pitfalls in production
- Random event ids generated per retry. The retry looks new, so it is billed twice. Generate the id once, at the point of the real-world action, and persist it with the pending event.
- Floats in rating. A total differs by a cent between the streaming path and a batch rebuild. Use integers or exact decimals end to end and test with a million random events.
- Silent late-event drops. Events beyond the watermark vanish and invoices undercount. Route them to the adjustment path and alert on the late rate.
- Editing finalised invoices. Support patches a total in place and the ledger no longer matches. Issue a credit note or adjustment and keep the original.
- Mutable price plans. Someone edits a tier and last quarter's invoices cannot be reproduced. Version plans and pin subscriptions to versions.
- Trusting one count. A bug in the aggregator undercharges for weeks and nothing notices. Reconcile against an independent counter every day and gate invoice finalisation on it.
Leadership lens
- Ownership. Metering is a platform capability with an SLO, but plans and invoice semantics belong to finance and product. Agree who approves plan changes and signs off reconciliation.
- Roll out in shadow. Run beside the old system for two or three cycles, compare invoice by invoice, and explain every difference first.
- Build first: idempotent ingest, the raw archive, and the reconciliation job. Real-time dashboards and fancy plans come after you can prove the counts are right.
- Cost drivers. Raw event storage (1 TB/day here), dedup state, and dispute handling. A self-serve usage export is the cheapest dispute resolver.
- Design-review questions. What happens to an event 10 days late? Who can cancel events, and where is that logged? What if the watermark stalls on day 1?
Check yourself
A producer times out on the ingest call and retries with the same
event_id. The first attempt had in fact succeeded. What does the customer get billed, and what stops a double charge?Show answer
One event's worth. The retry carries the same producer-assigned id, so the aggregator's keyed state (and the archive's unique key) reports a duplicate and adds nothing. This only holds within the dedup window and if the id is stable across retries.
A customer on graduated tiers has 15 units billed for March. Three late units for March arrive after the invoice closed. Why not simply rate the three units on their own?
Show answer
Tier boundaries depend on cumulative quantity. The late units may fall in a more expensive tier than a fresh start would price them. Re-rate 18 units, subtract the amount already billed for 15, and bill the difference.
Why round once per invoice line, not once per event?
Show answer
Per-event rounding builds a systematic bias over millions of events and makes totals depend on batching. One rounding of the exact micro-unit total is deterministic and off by at most half a cent per line.
The aggregator, the invoice job, and the gateway all agree in March, yet customers report being overcharged. Where could the error live and what would find it?
Show answer
A service that emitted every event twice with different ids passes all internal checks. An independent count (provider logs, upstream request counters) compared by customer and day exposes the ratio drift. Internal agreement is not proof.
Sources and further study
Primary
- Akidau et al., The Dataflow Model (VLDB 2015): event time, watermarks, triggers and accumulating versus retracting output for late data.
- Stripe engineering, Designing robust and predictable APIs with idempotency: why a retry must not repeat the effect, and how keys and backoff with jitter achieve it.
- Square Engineering, Books: an immutable double-entry accounting database service: why a ledger that balances to zero prevents a class of silent drift.
Systems docs
- Stripe API: meter events: identifier uniqueness window and the accepted timestamp range.
- Stripe API: meter event adjustments: cancelling an event instead of rewriting it.
- Stripe docs: billing credits: prepaid and promotional credit grants.
- Lago docs: ingesting usage: an open-source metering engine's event schema and deduplication behaviour.
- Apache Flink: generating watermarks: bounded-out-of-orderness watermarks in practice.
- PostgreSQL: numeric types: exact
numericversus inexact floating point.
Books
- Martin Kleppmann, Designing Data-Intensive Applications (1st ed.), ch. 11 Stream Processing: event time, windows, and exactly-once effects through idempotence.
Other curricula
- microservices.io, Idempotent Consumer: the pattern as a message-handling building block.
Watch
- Tech Talk: Fix Your Ledger Before It Breaks Your Payments (Modern Treasury): ledger design problems that show up as payment bugs.
- System Design: Payment Gateway (System Design Fight Club): an idempotent authorize-and-capture workflow, end to end.
Free resources
- Stripe docs: usage-based pricing models: the plan shapes (flat, tiered, graduated) a rating engine must support.
- AWS Builders' Library: Making retries safe with idempotent APIs: client tokens, semantic equivalence and what to return on a replay.
- Azure Architecture Center: Event Sourcing pattern: immutable events as the source of truth with derived views.