Skip to content
Software architecture6 min read

Metering architecture for usage-based billing

Usage-based pricing fails in a specific way: the invoice is questioned, and nobody can reconstruct how the number was produced. The fix is architectural rather than commercial — meter at the source, store raw events immutably, and derive every aggregate so it can be rebuilt and defended line by line.

Part ofSaaS & PaaS Product Engineering

Subscription billing is comparatively easy: a plan, a price, a renewal date. Usage-based billing is where SaaS products acquire quiet revenue leakage and loud customer disputes, because the amount owed is computed from events rather than looked up from a contract.

Design for one question

Every decision below follows from a single requirement: when a customer asks why this month's invoice is higher than last month's, you must be able to answer from data rather than from argument. That means the billable events themselves have to survive, not just the totals derived from them.

If the only record of usage is the number on the invoice, the invoice is not evidence — it is an assertion.

Meter at the source, not afterwards

The most common mistake is inferring usage later from something that was recorded for another purpose — counting rows in an audit log, parsing request logs, or summing a mutable counter. Each of those drifts from the thing you are actually charging for.

ApproachProblem
Increment a counter columnLoses history; a bug or rollback silently corrupts the total with no way to recompute
Count rows in a domain tableDeletions and soft-deletes change historical usage retroactively
Parse application logsLog retention is shorter than billing disputes, and log format changes break the pipeline
Emit an explicit usage eventCorrect — the billable fact is recorded deliberately, at the moment it occurs

Emit the event at the point the billable thing happens, from the code path that performs it, carrying everything needed to price it later.

sql
-- Append-only. No UPDATE, no DELETE, ever.
CREATE TABLE usage_events (
  id              uuid PRIMARY KEY,
  tenant_id       uuid NOT NULL,
  metric          text NOT NULL,          -- 'sms_sent', 'api_call', 'seat_active'
  quantity        numeric NOT NULL,
  occurred_at     timestamptz NOT NULL,   -- when the usage happened
  recorded_at     timestamptz NOT NULL DEFAULT now(),
  idempotency_key text NOT NULL,          -- caller-supplied, unique per event
  metadata        jsonb NOT NULL DEFAULT '{}',
  CONSTRAINT usage_events_idem UNIQUE (tenant_id, idempotency_key)
);

CREATE INDEX ON usage_events (tenant_id, metric, occurred_at);

Idempotency is the whole game

Usage events arrive over networks that retry. Without a uniqueness constraint keyed on something the caller controls, a retry becomes double billing, which is the single fastest way to lose a customer's trust in your invoicing.

  • The caller generates the idempotency key, deterministically from the operation — not randomly per attempt
  • Uniqueness is enforced by a database constraint, not by an application-level existence check that races
  • A duplicate insert is a success, not an error — return the original event rather than failing the caller
  • The key is scoped per tenant, so two tenants cannot collide

Aggregate derivably, never destructively

Querying millions of raw events at invoice time does not scale, so you will aggregate. The rule is that every aggregate must be reproducible from the raw events, and no aggregation step may be the only place a number exists.

sql
-- Rebuildable rollup. Dropping and recomputing it must always be safe.
CREATE TABLE usage_daily (
  tenant_id    uuid NOT NULL,
  metric       text NOT NULL,
  usage_date   date NOT NULL,
  quantity     numeric NOT NULL,
  event_count  integer NOT NULL,       -- lets you spot a partial rebuild
  computed_at  timestamptz NOT NULL,
  PRIMARY KEY (tenant_id, metric, usage_date)
);

-- Recompute one day idempotently. Safe to re-run at any time.
INSERT INTO usage_daily (tenant_id, metric, usage_date, quantity, event_count, computed_at)
SELECT tenant_id, metric, occurred_at::date, SUM(quantity), COUNT(*), now()
FROM   usage_events
WHERE  occurred_at >= $1 AND occurred_at < $1 + INTERVAL '1 day'
GROUP  BY tenant_id, metric, occurred_at::date
ON CONFLICT (tenant_id, metric, usage_date) DO UPDATE
  SET quantity = EXCLUDED.quantity,
      event_count = EXCLUDED.event_count,
      computed_at = EXCLUDED.computed_at;

Because the rollup is derived and the recompute is idempotent, a bug in aggregation is a recoverable incident rather than permanent data loss. That property is worth more than any amount of query optimisation.

Late events and closed periods

Events will arrive after the period they belong to has been invoiced. Decide the policy explicitly, write it down, and make the system implement exactly that policy rather than whatever the code happens to do.

  1. Define a grace window — say 48 hours — during which late events still land in their original period.
  2. After the period closes, late events are attributed to the current period instead, with metadata recording the period they actually occurred in.
  3. Never silently mutate a period that has already been invoiced; an invoice you have sent is a statement of fact.
  4. Alert when late events exceed a threshold — it usually means an integration is failing, not that customers changed behaviour.

Reconcile against the billing provider

Whatever you push to Stripe, Razorpay or an invoicing system becomes a second source of truth, and the two will diverge. Reconciliation is a scheduled job, not a one-off migration script.

CheckFrequencyAction on mismatch
Rollup total vs. sum of raw eventsDailyRecompute the affected day and alert
Usage pushed to provider vs. local rollupDailyRe-push the delta; never overwrite blindly
Provider invoice total vs. expected totalPer billing cycleBlock the invoice and escalate to a human
Events with no matching price ruleHourlyAlert — an unpriced metric is silent revenue loss

Show the customer the working

The strongest anti-dispute measure is making usage visible before the invoice arrives. A usage page showing current-period consumption by metric and day, drawn from the same rollups that produce the invoice, converts billing surprises into something the customer already knew about.

It also serves as continuous verification: customers notice anomalies in their own usage far faster than your monitoring will, and they will tell you.

Questions

Should usage events go through a queue or straight into the database?

Straight into the database is usually correct to start with — an append-only insert with a uniqueness constraint is fast, and it removes an entire class of "the queue lost it" failures. Introduce a queue when ingestion volume genuinely threatens the transactional database, and keep the same idempotency key so replay stays safe.

How long should raw usage events be retained?

At minimum, long enough to cover your longest plausible billing dispute plus any statutory record-keeping obligation — in practice this tends to mean years, not months. Raw events are small and compress well; deleting them to save storage removes the only evidence that supports your invoices.

Can the billing provider be the source of truth instead?

Not safely. Providers aggregate what you send them and generally do not retain per-event detail with your own metadata attached. Keep the authoritative raw record yourself and treat the provider as a downstream system you reconcile against — which also means you can change provider without losing billing history.

How do you handle a customer who disputes a charge?

Query the raw events for that tenant, metric and period, and show the count and total alongside the aggregate that produced the invoice. If the two agree, the invoice is defensible. If they disagree, you have found a real bug and the customer is right — which is exactly why the raw events exist.

Want this applied to your systems?

Most of what is described here begins as an assessment: a fixed-scope review producing a prioritised written report you can act on with any supplier.

Prefer email? hello@kornspire.com