Design usage-based billing and metering
The interviewer asks
“Design a usage-based billing system for a cloud or API platform: every product emits usage (API calls, LLM tokens, GB stored, compute minutes), customers see near-real-time usage and spend, prepaid credits and limits are enforced, and accurate invoices are generated at the end of each month.”
What a strong answer looks like
A strong answer splits the problem into a metering pipeline and a billing pipeline. Metering ingests high-volume usage events with idempotency keys, stores the raw events immutably (the source of truth for audits and re-rating), and aggregates them into per-customer, per-meter, per-hour totals with a stream processor. Billing then rates those aggregates against versioned price plans (tiers, volume discounts, commitments, free quotas, credits), produces draft invoices, and finalises immutable invoices at period close, with corrections issued as credit notes. A separate, fast path keeps a near-real-time balance for prepaid credits and spend limits, accepting a little approximation for speed. The hard parts are exactly-once counting end to end, late and corrected events, the boundary between 'fast and approximate' (limits) and 'slow and exact' (invoices), and plan changes in the middle of a cycle.
Concepts to mention
1 Requirements
Functional
- Ingest usage events from many internal services: customer, meter (api_calls, input_tokens, storage_gb_hours), quantity, timestamp, dimensions (model, region)
- Show customers usage and estimated spend, updated within minutes, broken down by day and dimension
- Support pricing models: per unit, tiered, volume, package, free quota, committed spend with overage, and prepaid credits
- Enforce prepaid credit balance and customer-set spend limits (alert at 80%, block or throttle at 100%)
- Generate monthly invoices with line items; collect via card, bank transfer or UPI; handle taxes (GST) via a tax module
- Handle plan upgrades and downgrades in the middle of a cycle with proration
- Allow corrections: late events, duplicate events and disputed usage, without editing finalised invoices
Non-functional
- No lost or double-counted usage: invoice totals must equal the sum of unique raw events
- Ingestion of ~200k events/s peak with p99 accept latency under 50 ms; producers must never block their own request path on billing
- Usage dashboard freshness under 5 minutes; credit balance freshness under 1 minute
- Invoice run for ~100k customers completes within a few hours after period close
- Raw events retained for at least the audit period (for example 7 years for financial records), cheaply
- Billing outages must not break the product; products degrade to 'allow and meter later'
Out of scope (say it out loud)
- Payment gateway internals and card processing
- Revenue recognition and accounting general ledger (only the export)
- Sales quoting and CPQ tools
- Tax calculation rules in detail
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/usage/events | Batch of usage events; each has event_id (idempotency), customer_id, meter, quantity, timestamp, dimensions. Returns accepted/rejected per event |
GET/v1/customers/{id}/usage?meter=&from=&to=&group_by= | Aggregated usage for dashboards and customer APIs |
GET/v1/customers/{id}/balance | Prepaid credit balance, current period spend estimate and limit status |
PUT/v1/customers/{id}/subscription | Change plan; effective now or at period end; triggers proration |
GET/v1/invoices/{id} | Invoice with line items, taxes and payment status |
POST/v1/invoices/{id}/credit-notes | Issue a correction against a finalised invoice |
gRPCEntitlements.Check(customer, meter) | Fast check used by products: allow, warn or block, served from cache |
4 Data model
raw_usage_eventsKafka (7 days) → S3 Iceberg tablesevent_idcustomer_idmeterquantity (decimal)event_timeingest_timedimensions (map)sourceImmutable source of truth. Iceberg partitioned by date and customer bucket, so re-rating one customer for one month reads a small slice.
usage_aggregatesClickHouse (or Postgres for smaller scale)customer_idmeterdimension_hashhour_bucketquantity_sumevent_countlast_updatedBuilt by the stream processor; ReplacingMergeTree / upserts keyed by (customer, meter, dims, hour) so recomputing a bucket replaces it.
dedupe_keysFlink keyed state (RocksDB) or Redis with TTLevent_id → seenTTL ~ 7 daysDrops duplicate events within the late-arrival window. Older duplicates are caught by the batch recompute from raw events.
price_plansPostgresplan_idversionmetermodel (per_unit, tiered, volume)tiers (JSON)currencyeffective_fromVersioned and immutable once used; a subscription points to a plan version.
subscriptionsPostgressubscription_idcustomer_idplan_versionperiod_startperiod_endcommitmentsdiscountsstatusHistory rows for plan changes so proration knows which plan applied when.
credit_ledgerPostgresentry_idcustomer_idtype (grant, burn, expire, adjust)amountsource_ref (unique)created_at; plus balance rowBurns are written per minute per meter from aggregates, keyed by (customer, meter, minute), so replays do not double burn.
invoices / line_itemsPostgresinvoice_idcustomer_idperiodstatus (draft, finalised, paid, void)totals; line_item: meterquantityunit_priceamountplan_versionFinalised invoices are immutable; corrections are credit notes. Exported to the accounting system.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Usage from an LLM API call
- 1Before serving, the API service calls Entitlements.Check from a local cache refreshed every few seconds; the customer has credits left, so the call proceeds.
- 2After the response, the service emits events: input_tokens = 1,200 and output_tokens = 350 for model X, each with event_id = request_id + meter. It buffers them in a local agent and sends batches every second.
- 3Ingest API validates schema and customer, writes the batch to Kafka (partitioned by customer_id) with acks=all, then returns 202.
- 4A Kafka sink writes raw events to S3 Iceberg every few minutes.
- 5Flink deduplicates by event_id in keyed state, assigns events to hour buckets by event_time, and emits updated aggregates to ClickHouse and per-minute totals to billing.
- 6Billing rates the minute total with the customer's plan version (for example ₹X per 1M tokens), writes a credit burn to the ledger keyed by (customer, meter, minute), and updates the balance in Redis.
- 7If the balance falls below 20% of the grant, an alert is sent; at zero, the entitlements state flips to 'block' (or 'overage allowed' for postpaid) and products see it within seconds.
Monthly invoice run
- 1At period end plus a grace window (for example 24-48 hours for late events), the invoice scheduler creates one job per customer, sharded across workers.
- 2Each job recomputes usage for the period from raw events in S3 (deduped by event_id), not from the streaming aggregates, and compares with the streaming totals; a large difference raises an alert.
- 3It applies the plan versions active in the period (with proration for mid-cycle changes), tiers, free quotas, commitments and discounts, and subtracts credits used.
- 4It calls the tax module for GST by place of supply, writes the draft invoice, and runs checks (no negative totals, change from last month within expected range).
- 5Drafts are finalised (immutable, numbered sequentially as tax rules require), PDF generated, and payment is charged or a payment link is sent.
- 6Finalised invoices are exported to the accounting system; later corrections are credit notes on a later invoice.
7 Deep dives
Exactly-once counting, end to end
Kafka offers exactly-once only inside Kafka transactions; end to end you need effectively-once built from idempotency. The chain: (1) Producers create a deterministic event_id (request_id + meter), so a retry sends the same id. Random UUIDs per send defeat dedupe. (2) Ingest writes to Kafka with idempotent producers and acks=all, so an accepted event is not lost. (3) The stream processor deduplicates by event_id in keyed state with a TTL longer than the expected retry and late window (days). Flink checkpoints make state and offsets consistent; outputs go to sinks that are idempotent (upsert by aggregate key, not increment), so replaying after a failure rewrites the same values. (4) Credit burns use a unique key per customer, meter and minute, so a replay cannot burn twice. (5) The invoice run recomputes from raw events with a global dedupe over the whole period, which also catches duplicates older than the streaming TTL. The final guard is reconciliation: invoiced quantity must equal the count of unique raw events, and the streaming totals must be within a small tolerance of the batch totals. This 'stream for speed, batch for truth' structure is a lambda-style design, and it is fine here because the invoice must be exact.
Late, out-of-order and corrected events
Usage arrives late for normal reasons: an agent buffers during a network blip, a batch job reports storage hours once a day, or a region backlog drains hours later. Aggregate by event_time, not ingest_time, so usage lands in the correct hour and month. Flink uses watermarks to decide when a window is mostly complete, but billing aggregates should allow updates for days (allowed lateness), emitting corrected values for old buckets as upserts. The invoice run waits for a grace period after the month ends (24-48 hours) before finalising. Events that arrive after finalisation are not lost: they are rated into the next invoice as a 'prior period adjustment' line, or ignored by contract if they are older than a cut-off; either way the rule is written and visible. Corrections from producers (for example, a bug double-reported storage for a day) are new events with negative quantities or a 'void' referencing original event ids, never edits to raw data. Raw data stays immutable so you can always explain an invoice: 'these 4,213,882 events, rated with plan version 7, produced line 3'.
Real-time credits and spend limits
Limits must be fast because products check them on every request, but the exact balance comes from a pipeline that is a minute behind. Do not call the billing database on each request. Instead, the billing service computes balance and limit state per customer from per-minute aggregates and writes a small record (balance, state: ok, warn, block, updated_at) to Redis. Products keep a local cache refreshed every few seconds, so a check costs microseconds. This means a customer can overshoot by up to a minute of usage; for most products that is acceptable and should be stated in the terms. For high-value usage (expensive GPU jobs or large batch requests), use a reservation: before the job, reserve an estimated amount from the balance atomically (Redis Lua or a Postgres conditional update), and settle the actual amount after the job finishes. For very large customers, give each product node a local allowance (a slice of the remaining balance) to avoid a hot key, similar to a distributed rate limiter. Fail open with a cap: if the entitlements service is down, allow traffic for known paying customers and meter it later, because billing must not take down the product.
Rating engine, plan versions and proration
Rating converts quantities into money. Keep plans as versioned data, not code: each version defines meters, pricing model, tiers and currency, and is immutable once any customer uses it. A tiered price (first 1M tokens at rate A, next 9M at rate B) applies tiers to cumulative usage in the period, while a volume price applies one rate to all units based on the total, and the difference matters for invoices. Use decimal arithmetic with fixed scale, never floats, and round only at the line-item level with a documented rule. Mid-cycle plan changes split the period: usage before the change is rated with the old version, after it with the new one; fixed fees are prorated by time. Commitments ('₹10 lakh per year minimum') are evaluated at the commitment period end, with a true-up line. Credits apply in a defined order (promotional credits first, expiring soonest first). Because the rating function is pure (usage + plan version + contract → line items), you can re-rate any past period to answer disputes, test a new plan on last month's real usage before launch, and show customers an 'estimated bill' mid-month using the same code.
8 Trade-offs
Streaming aggregates vs batch recompute for invoices
Streaming totals are fresh but can drift due to bugs, late data or dedupe TTL limits. Batch recompute from raw events is exact and auditable but slow. Use streaming for dashboards and limits, batch for invoices, and reconcile the two. The cost is two code paths; mitigate by sharing the rating library.
Build vs buy billing
Products like Stripe Billing, Orb, Metronome or Chargebee handle rating, invoices and payments. Building in-house makes sense at large scale or with unusual pricing; metering (ingest, dedupe, aggregation) is often built in-house even when invoicing is bought. Say this in the interview: it shows judgement.
Strict real-time blocking vs soft limits
Exact blocking at zero balance needs a synchronous, consistent check per request, which adds latency and a single point of failure. Soft limits with a small overshoot are much cheaper and more reliable. Use reservations only for expensive, long-running operations.
9 Bottlenecks
Very large customers create hot partitions
Partitioning Kafka and Flink by customer_id puts one big customer on one partition. Partition by (customer_id, meter, hash of event_id mod N) for pre-aggregation, then combine partial sums per customer in a second stage. The balance key in Redis is split the same way with local allowances.
Invoice run at month end
100k customers recomputed from S3 at once is a large scan. Pre-compute daily compacted rollups from raw events (deduped, exact) during the month so the month-end job only sums 30 days of rollups and the last day's raw events. Shard jobs and make each idempotent by (customer, period).
10 Failure handling
Ingest API or Kafka unavailable
Producers keep events in a local disk-backed buffer (an agent or sidecar) and retry with the same event_ids. The product keeps serving. Alert if buffer age grows, because very late data delays invoices.
Flink job bug double counts
Dashboards show inflated usage, but invoices are recomputed from raw events, and the stream-vs-batch reconciliation flags the gap. Fix the job, replay from Kafka or S3 to rebuild aggregates (upserts overwrite), and correct credit burns with adjustment entries.
Payment collection fails
Invoice stays finalised and unpaid; dunning retries the payment on a schedule, notifies the customer, and after a grace period moves entitlements to restricted. Revenue data is unaffected because invoices are separate from payment status.
11 What impresses
- Saying clearly that Kafka exactly-once does not make billing exactly-once, then building the chain of idempotency
- Separating fast approximate limits from slow exact invoices, with numbers on overshoot
- Keeping raw events immutable and re-rating past periods to answer disputes
- Hot-customer handling with two-stage aggregation and local allowances
- Grace period plus prior-period adjustments for late events
- Fail-open entitlements so billing cannot take the product down
12 Mistakes
- Incrementing counters in a database per event with no idempotency
- Using floating point for money
- Editing finalised invoices instead of issuing credit notes
- Aggregating by ingest time, so late events land in the wrong month
- Making every product request call the billing database synchronously
- Hard-coding prices in application code
13 Follow-ups
Lessons this design uses
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.