Design problems
Payments & fintechHardSDE-2 30 min

Design usage-based billing and metering

Asked at
AmazonFreshworksMicrosoftOpenAISalesforceStripe

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

Immutable raw usage events as source of truthDeterministic event_id and idempotent ingestionEffectively-once via dedupe and idempotent upsert sinksEvent time aggregation with watermarks and allowed latenessVersioned price plans and a pure rating functionStream for freshness, batch recompute for invoicesPrepaid credit ledger and cached entitlementsImmutable invoices, corrections via credit notesDecimal arithmetic and documented roundingProration for mid-cycle plan changes

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

Customers~100k paying, ~1M freeA mid-to-large API platform
Usage events~5B/day, ~60k/s average, ~200k/s peakEach API request emits 1-3 events (requests, input tokens, output tokens); 2B requests/day × 2.5 ≈ 5B
Raw event storage~1 TB/day uncompressed, ~150 GB/day in Parquet5B × ~200 bytes ≈ 1 TB; columnar compression ~5-8x; ~55 TB/year in S3
Aggregates~50M rows/dayHourly buckets: 100k active customers × ~20 meter-dimension combos × 24 hours ≈ 48M
Invoice line items~2-5M per month100k invoices × ~20-50 line items
Credit balance updates~1-2k/sBalances updated from per-minute aggregates, not per event: ~100k active customers ÷ 60 s

3 API

EndpointWhat it does
POST/v1/usage/eventsBatch 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}/balancePrepaid credit balance, current period spend estimate and limit status
PUT/v1/customers/{id}/subscriptionChange 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-notesIssue 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 tables
event_idcustomer_idmeterquantity (decimal)event_timeingest_timedimensions (map)source

Immutable 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_updated

Built 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 TTL
event_id → seenTTL ~ 7 days

Drops duplicate events within the late-arrival window. Older duplicates are caught by the batch recompute from raw events.

price_plansPostgres
plan_idversionmetermodel (per_unit, tiered, volume)tiers (JSON)currencyeffective_from

Versioned and immutable once used; a subscription points to a plan version.

subscriptionsPostgres
subscription_idcustomer_idplan_versionperiod_startperiod_endcommitmentsdiscountsstatus

History rows for plan changes so proration knows which plan applied when.

credit_ledgerPostgres
entry_idcustomer_idtype (grant, burn, expire, adjust)amountsource_ref (unique)created_at; plus balance row

Burns are written per minute per meter from aggregates, keyed by (customer, meter, minute), so replays do not double burn.

invoices / line_itemsPostgres
invoice_idcustomer_idperiodstatus (draft, finalised, paid, void)totals; line_item: meterquantityunit_priceamountplan_version

Finalised invoices are immutable; corrections are credit notes. Exported to the accounting system.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALusage batchescheck limitsvalidated eventsraw sinkhourly aggregatesminute totalscredit burnsbalance, limit staterecompute periodinvoicescharge, exportProduct servicesCustomer dashboardUsage ingest APIEntitlements serviceUsage query APIBilling & ratingserviceKafka (raw usage)Invoice run jobsFlink aggregator +dedupeS3 Iceberg raw eventsRedis (balances,limits)ClickHouse aggregatesPostgres (plans,ledger)Payments + tax + ERP
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Usage from an LLM API call

  1. 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.
  2. 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.
  3. 3Ingest API validates schema and customer, writes the batch to Kafka (partitioned by customer_id) with acks=all, then returns 202.
  4. 4A Kafka sink writes raw events to S3 Iceberg every few minutes.
  5. 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.
  6. 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.
  7. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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).
  5. 5Drafts are finalised (immutable, numbered sequentially as tax rules require), PDF generated, and payment is charged or a payment link is sent.
  6. 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

Q1How do you let customers set a hard budget that blocks usage instantly?
Q2How would you launch a new pricing plan and test it on last month's usage first?
Q3How do you bill storage that is measured as GB-hours from daily snapshots?
Q4A customer disputes last month's token count. How do you prove the invoice?
Q5How would you support multiple currencies and GST for Indian customers?
Q6How do you migrate customers from per-seat to usage-based pricing?

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.

Start free