System Design ·

Designing a Usage-Based Billing System: Metering, Aggregation, and Invoice Generation for SaaS Products

How to design the metering, aggregation, and billing pipeline for SaaS products that charge by usage. Covers event ingestion, real-time vs batch aggregation tradeoffs, pricing model implementation in TypeScript, Stripe Billing integration patterns, and edge cases like credit grants, proration, and billing disputes.

Designing a Usage-Based Billing System: Metering, Aggregation, and Invoice Generation for SaaS Products

Seat-based pricing is simple to implement and easy to forecast. Usage-based pricing is harder to build but often closer to how customers actually experience value. If you charge per API call, per GB stored, or per compute minute, you need a reliable pipeline that measures what happened, aggregates it correctly, and produces invoices your customers can audit and your finance team can defend. This article is about building that pipeline.

The Metering Problem

Usage billing starts with one question: what did the customer actually consume? The answer requires capturing usage events at the source and moving them into an aggregation system without loss.

The core data structure is a usage event:

interface UsageEvent {
  id: string;               // idempotency key — caller-generated
  customerId: string;
  subscriptionId: string;
  metricName: string;       // "api_calls" | "storage_gb" | "compute_minutes"
  quantity: number;
  timestamp: string;        // ISO 8601, UTC
  metadata?: Record<string, string>; // dimension tags for breakdowns
}

The id field is the most important one. Your ingestion endpoint will receive duplicate events — from retry logic, at-least-once delivery guarantees, and client bugs. Every event needs a caller-supplied idempotency key that you deduplicate on before storing.

// Hono ingestion endpoint
app.post("/v1/usage", async (c) => {
  const body = await c.req.json<UsageEvent>();

  const result = await db
    .insertInto("usage_events")
    .values({
      id: body.id,
      customer_id: body.customerId,
      subscription_id: body.subscriptionId,
      metric_name: body.metricName,
      quantity: body.quantity,
      event_timestamp: body.timestamp,
      received_at: new Date().toISOString(),
      metadata: JSON.stringify(body.metadata ?? {}),
    })
    .onConflict((oc) => oc.column("id").doNothing())
    .returning("id")
    .executeTakeFirst();

  return c.json({ recorded: result !== undefined });
});

ON CONFLICT DO NOTHING is the simplest deduplication strategy. It works when your event IDs are globally unique (UUIDs or a hash of the source system’s natural key). The received_at column is useful for debugging late events that arrive out of order.

The table structure matters for query performance:

CREATE TABLE usage_events (
  id            TEXT PRIMARY KEY,
  customer_id   TEXT NOT NULL,
  subscription_id TEXT NOT NULL,
  metric_name   TEXT NOT NULL,
  quantity      NUMERIC NOT NULL,
  event_timestamp TIMESTAMPTZ NOT NULL,
  received_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  metadata      JSONB NOT NULL DEFAULT '{}'
);

-- Range index for billing period queries
CREATE INDEX idx_usage_events_billing ON usage_events (
  subscription_id,
  metric_name,
  event_timestamp
);

-- BRIN index if events arrive roughly in order and the table is large
CREATE INDEX idx_usage_events_received_brin ON usage_events
  USING brin (received_at);

Real-Time vs Batch Aggregation

Once events are in, you need to aggregate them into usable totals. The right approach depends on how your billing model uses the data.

Batch aggregation runs on a schedule (hourly, daily) and computes totals over a period. It is simple to implement, easy to recompute if something goes wrong, and sufficient for monthly invoices.

Real-time aggregation maintains running counters that are queryable at any moment. This is required if you need live usage dashboards, hard limits that cut off service when a quota is exceeded, or pay-as-you-go billing that closes on a short cycle.

A common pattern is to maintain both. A batch job writes authoritative period totals to an aggregated_usage table. A Redis counter tracks in-flight usage for the current period and feeds the real-time dashboard. At invoice time, the batch table wins.

interface AggregatedUsage {
  subscriptionId: string;
  metricName: string;
  periodStart: string;
  periodEnd: string;
  totalQuantity: number;
  computedAt: string;
}

async function aggregateUsageForPeriod(
  subscriptionId: string,
  metricName: string,
  periodStart: Date,
  periodEnd: Date
): Promise<AggregatedUsage> {
  const row = await db
    .selectFrom("usage_events")
    .select((eb) => eb.fn.sum("quantity").as("total"))
    .where("subscription_id", "=", subscriptionId)
    .where("metric_name", "=", metricName)
    .where("event_timestamp", ">=", periodStart.toISOString())
    .where("event_timestamp", "<", periodEnd.toISOString())
    .executeTakeFirstOrThrow();

  return {
    subscriptionId,
    metricName,
    periodStart: periodStart.toISOString(),
    periodEnd: periodEnd.toISOString(),
    totalQuantity: Number(row.total ?? 0),
    computedAt: new Date().toISOString(),
  };
}

For the Redis real-time counter, increment on every ingested event and set an expiry aligned to the billing period end. Read it from the dashboard. Never read it for invoice computation.

Pricing Model Implementation

Three pricing models cover the majority of usage-based SaaS:

Per-unit: flat rate per unit consumed. $0.001 per API call. Simplest to implement and explain.

Tiered: different rates per tier, applied to units within that tier. First 10,000 calls at $0.001, next 90,000 at $0.0008, everything above at $0.0006. The customer pays the rate for each tier applied to the units in that tier.

Volume: the total quantity determines which rate applies, and that rate applies to all units. Same thresholds as tiered, but once you hit 100,000 calls, everything is billed at $0.0006.

interface PriceTier {
  upTo: number | null; // null = no ceiling (last tier)
  unitPrice: number;   // in cents
}

function computeTieredCharge(quantity: number, tiers: PriceTier[]): number {
  let remaining = quantity;
  let total = 0;
  let consumed = 0;

  for (const tier of tiers) {
    if (remaining <= 0) break;

    const tierCapacity =
      tier.upTo !== null ? tier.upTo - consumed : Infinity;
    const units = Math.min(remaining, tierCapacity);

    total += units * tier.unitPrice;
    remaining -= units;
    consumed += units;
  }

  return total;
}

function computeVolumeCharge(quantity: number, tiers: PriceTier[]): number {
  // Find the applicable tier for the total quantity
  let applicableTier = tiers[tiers.length - 1];
  let consumed = 0;

  for (const tier of tiers) {
    const tierMax = tier.upTo ?? Infinity;
    if (quantity <= tierMax + consumed) {
      applicableTier = tier;
      break;
    }
    consumed += tier.upTo ?? 0;
  }

  return quantity * applicableTier.unitPrice;
}

// Example tier structure: $0.001 for first 10K, $0.0008 for next 90K, $0.0006 beyond
const apiCallTiers: PriceTier[] = [
  { upTo: 10_000, unitPrice: 0.1 },   // $0.001 = 0.1 cents
  { upTo: 100_000, unitPrice: 0.08 },
  { upTo: null, unitPrice: 0.06 },
];

const tieredCharge = computeTieredCharge(150_000, apiCallTiers);
// 10K × 0.10 + 90K × 0.08 + 50K × 0.06 = 1000 + 7200 + 3000 = 11200 cents = $112.00

Store the pricing configuration separately from the usage data and version it. If you change rates on March 1, usage from February needs to be billed at February’s rates.

interface PricingConfig {
  id: string;
  subscriptionId: string;
  metricName: string;
  model: "per_unit" | "tiered" | "volume";
  tiers: PriceTier[];
  effectiveFrom: string;
  effectiveTo: string | null;
}

Stripe Billing Integration

Stripe Billing handles the subscription lifecycle, invoice generation, and payment collection. For usage-based products, the cleanest pattern is to use Stripe’s metered billing with a single meter per metric, then report usage via the Meters API.

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-12-18.acacia",
});

// Report usage to Stripe at period close (or incrementally)
async function reportUsageToStripe(
  stripeSubscriptionItemId: string,
  quantity: number,
  timestamp: number // Unix timestamp
): Promise<void> {
  await stripe.subscriptionItems.createUsageRecord(
    stripeSubscriptionItemId,
    {
      quantity,
      timestamp,
      action: "set", // "set" replaces; "increment" adds — prefer "set" for idempotency
    }
  );
}

Use action: "set" rather than "increment" when you control the aggregation. If your batch job computes a definitive total for the period, set it once. If you report incrementally throughout the period, use "increment" — but be prepared to handle retries that double-count.

For complex tiered pricing that Stripe cannot model natively, calculate the charge yourself and use a one-off invoice item:

async function addUsageLineItemToInvoice(
  stripeCustomerId: string,
  description: string,
  amountCents: number,
  periodStart: Date,
  periodEnd: Date
): Promise<void> {
  await stripe.invoiceItems.create({
    customer: stripeCustomerId,
    amount: amountCents,
    currency: "usd",
    description,
    period: {
      start: Math.floor(periodStart.getTime() / 1000),
      end: Math.floor(periodEnd.getTime() / 1000),
    },
  });
}

This is created as a pending invoice item and attaches to the next invoice Stripe finalizes for that customer. The billing period dates appear on the invoice line item, which customers expect when disputing charges.

Credit Grants and Proration

Credit grants are pre-purchased usage allowances or free-tier credits. They reduce the billable quantity before pricing is applied.

interface CreditGrant {
  id: string;
  customerId: string;
  metricName: string;
  remainingQuantity: number;
  expiresAt: string | null;
}

async function computeBillableQuantity(
  customerId: string,
  metricName: string,
  rawQuantity: number,
  periodEnd: Date
): Promise<{ billable: number; creditsConsumed: number }> {
  const grants = await db
    .selectFrom("credit_grants")
    .selectAll()
    .where("customer_id", "=", customerId)
    .where("metric_name", "=", metricName)
    .where("remaining_quantity", ">", 0)
    .where((eb) =>
      eb.or([
        eb("expires_at", "is", null),
        eb("expires_at", ">", periodEnd.toISOString()),
      ])
    )
    .orderBy("expires_at", "asc") // consume soonest-expiring first
    .execute();

  let remaining = rawQuantity;
  let creditsConsumed = 0;

  for (const grant of grants) {
    if (remaining <= 0) break;

    const consumed = Math.min(remaining, grant.remaining_quantity);
    remaining -= consumed;
    creditsConsumed += consumed;

    await db
      .updateTable("credit_grants")
      .set({ remaining_quantity: grant.remaining_quantity - consumed })
      .where("id", "=", grant.id)
      .execute();
  }

  return { billable: remaining, creditsConsumed };
}

Proration applies when a customer upgrades or downgrades mid-period. The standard approach: close the current period’s usage record at the change date, bill that partial period, and open a new record under the new pricing from the change date forward. Stripe handles this automatically for seat-based subscriptions. For usage-based billing you usually need to do it yourself.

async function applyMidPeriodPricingChange(
  subscriptionId: string,
  changeDate: Date,
  oldConfigId: string,
  newConfigId: string
): Promise<void> {
  // Finalize usage under old pricing up to changeDate
  await finalizeUsagePeriod(subscriptionId, changeDate, oldConfigId);

  // Start new usage accumulation from changeDate under new pricing
  await db
    .updateTable("subscriptions")
    .set({
      active_pricing_config_id: newConfigId,
      current_period_start: changeDate.toISOString(),
    })
    .where("id", "=", subscriptionId)
    .execute();
}

Invoice Generation

An invoice is a snapshot. It captures the customer’s usage, applied pricing, credits, and net amount owed at a specific point in time. Once finalized, it should be immutable. Store the full computation alongside the invoice row so disputes are answerable without re-running the calculation.

interface InvoiceLineItem {
  metricName: string;
  rawQuantity: number;
  creditsApplied: number;
  billableQuantity: number;
  pricingModel: "per_unit" | "tiered" | "volume";
  unitPriceCents: number | null; // null for tiered/volume
  amountCents: number;
  periodStart: string;
  periodEnd: string;
}

interface Invoice {
  id: string;
  customerId: string;
  subscriptionId: string;
  status: "draft" | "finalized" | "paid" | "void" | "disputed";
  lineItems: InvoiceLineItem[];
  subtotalCents: number;
  taxCents: number;
  totalCents: number;
  dueDate: string;
  finalizedAt: string | null;
  stripeInvoiceId: string | null;
}

Write a snapshot of the pricing configuration into the invoice’s line items at generation time. Do not re-reference the live pricing table from the invoice. If you later change the pricing, existing invoices must remain correct.

Handling Billing Disputes

A billing dispute starts with “I didn’t use that much.” Your response requires:

  1. The raw event log for the disputed period, filterable by customer and metric.
  2. The aggregation query that produced the invoice total.
  3. The credit grants that were applied and their remaining balances.
  4. The pricing configuration that was active when the invoice was generated.

If you stored all four, you can reproduce any invoice amount from first principles. Build an internal admin endpoint that runs that reproduction on demand.

async function reproduceInvoiceComputation(
  invoiceId: string
): Promise<{ reproduced: number; recorded: number; match: boolean }> {
  const invoice = await getInvoice(invoiceId);

  let reproduced = 0;
  for (const line of invoice.lineItems) {
    const events = await db
      .selectFrom("usage_events")
      .select((eb) => eb.fn.sum("quantity").as("total"))
      .where("subscription_id", "=", invoice.subscriptionId)
      .where("metric_name", "=", line.metricName)
      .where("event_timestamp", ">=", line.periodStart)
      .where("event_timestamp", "<", line.periodEnd)
      .executeTakeFirstOrThrow();

    const raw = Number(events.total ?? 0);
    const billable = raw - line.creditsApplied;
    const charge = computeCharge(billable, line.pricingModel, line.metricName);
    reproduced += charge;
  }

  return {
    reproduced,
    recorded: invoice.totalCents,
    match: reproduced === invoice.totalCents,
  };
}

When reproduced and recorded differ, you have a bug. This happens most often when events arrive late (after the billing period closed), when a credit deduction had a concurrency issue, or when a pricing config was applied to the wrong period. Build reconciliation jobs that flag these discrepancies before the invoice is paid.

Tradeoffs

DecisionOption AOption BWhen to choose AWhen to choose B
Aggregation strategyBatch (scheduled job)Real-time countersMonthly invoices, simpler opsLive quota enforcement, dashboards
Usage deduplicationDB unique constraintApplication-layer checkSimplest, correct by defaultWhen you need pre-insert validation
Stripe integrationStripe Meters APIManual invoice itemsStripe-native pricing modelsComplex tiers Stripe can’t express
Pricing storageVersioned config tableEvent-time snapshot on invoiceAuditable pricing historyFull invoice reproducibility
Credit consumptionFIFO by expiryPro-rata across all active grantsCustomers prefer using soon-expiring creditsFairer multi-grant distribution
Invoice finalizationSync at period closeAsync job queueLow volume, tolerable latencyHigh customer count, burst finalization

Production Considerations

Late events: Usage events from mobile clients or edge-deployed systems arrive late. Define a cutoff window (typically 24-48 hours) after which late events go into the next billing period rather than the closed one. Document this in your terms. Store received_at alongside event_timestamp so you can audit which events were late.

Idempotent finalization: Invoice finalization is not idempotent by default unless you make it so. Use a database-level unique constraint on (subscription_id, period_start, period_end) for the invoices table. If the finalization job runs twice, the second insert fails rather than creating a duplicate invoice.

Tax calculation: Usage-based amounts make tax calculation harder because the amount is unknown until period close. Integrate a tax service (Avalara, TaxJar) as the final step before invoice finalization, after the usage total is known. Never pre-calculate tax on running totals.

Quota enforcement: If you cut off service when a customer exceeds a quota, the enforcement path must read from your real-time counter, not from the batch aggregation table. Keep the two paths separate: real-time counters for enforcement, batch totals for billing.

Audit log: Every credit deduction, every pricing config lookup, and every invoice finalization should write an append-only audit record. When a customer’s account manager calls asking why the invoice changed from the estimate, that log is the only answer.

Testing: The edge cases that create billing errors are combinatorial: partial period + pricing change + credit expiry + late events arriving simultaneously. Write integration tests that cover at least: a period with no usage, a period with usage but sufficient credits (zero invoice), a pricing tier boundary (1 unit above a tier threshold), a mid-period pricing change, and a disputed invoice that reproduces correctly.

The math in a usage billing system is not complex. The correctness requirements are. Every number on an invoice is something a customer will question if it looks wrong, and your answer needs to come from data, not approximation.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.