System Design ·

Usage-Based Billing Architecture: Metering, Aggregation, and Invoice Generation for API Products

A comprehensive guide to building production-grade usage-based billing for API products. Covers the metering pipeline, idempotent event recording, deduplication, real-time vs. batch aggregation, tiered pricing engines, invoice generation, and the observability needed to catch discrepancies before customers do.

Usage-Based Billing Architecture: Metering, Aggregation, and Invoice Generation for API Products

Flat-rate subscriptions are easy to build. You store a plan, check it on each request, and charge a fixed amount at the end of the month. Usage-based billing is an entirely different engineering problem. You have to count every meaningful event your API produces, aggregate those counts correctly across a billing period, apply a pricing model that may have tiers and discounts, and then turn all of that into an accurate invoice.

The failure modes are subtle and expensive. Missed events mean underbilling customers and underreporting revenue. Duplicate events mean overbilling and customer disputes. A clock skew bug between your event producers and your aggregation pipeline can shift usage across billing periods, producing invoices that are wrong but hard to audit. And when you reconcile against Stripe at the end of the month, any drift between your usage database and what you reported becomes a real financial discrepancy.

This article walks the full pipeline: event ingestion with idempotent recording, deduplication strategies, real-time versus batch aggregation, tiered pricing engines, invoice generation, and the observability layer you need to trust all of it.


The Metering Pipeline

The core abstraction is a usage event: something your customer did that should affect their bill. For an API product, this might be an API call, a model inference, a message sent, or a storage byte written. The metering pipeline captures these events durably, deduplicates them, and makes them available for aggregation.

Event Schema

Start with a typed event schema. Every field you omit here will be painful to reconstruct later.

interface UsageEvent {
  eventId: string;          // UUID, generated by the producer
  customerId: string;       // your internal customer identifier
  metricName: string;       // "api.request", "inference.tokens", "storage.bytes"
  quantity: number;         // amount consumed in this event
  unit: string;             // "requests", "tokens", "bytes"
  occurredAt: string;       // ISO 8601, set by the producer, not your server
  receivedAt?: string;      // set on ingestion, for clock skew detection
  idempotencyKey: string;   // globally unique, used for deduplication
  metadata: Record<string, string>; // model, region, endpoint, etc.
}

Two fields here carry most of the complexity: occurredAt and idempotencyKey.

occurredAt must come from the producer because it determines which billing period the usage falls into. If you use server ingestion time, a retry after a network failure will land events in the wrong period. But because producers can have clock skew, you must also record receivedAt on ingestion and alert when the delta exceeds a threshold (typically 5 minutes in either direction).

idempotencyKey is the deduplication handle. Producers must generate a stable key per event, not per request. A common pattern is sha256(customerId + metricName + occurredAt + requestId) where requestId is the ID of the originating API request. If the same API request is retried, it produces the same idempotency key and the event is deduplicated rather than double-counted.

Idempotent Event Recording

The ingest layer must be safe to call multiple times with the same event. The implementation depends on your storage backend, but the pattern is always the same: insert on conflict do nothing, then return the existing record.

async function ingestEvent(
  db: DatabaseClient,
  event: UsageEvent
): Promise<{ accepted: boolean; duplicate: boolean }> {
  const receivedAt = new Date().toISOString();

  // Detect clock skew before writing
  const skewMs = Math.abs(
    Date.parse(receivedAt) - Date.parse(event.occurredAt)
  );

  if (skewMs > 5 * 60 * 1000) {
    // Log but still accept: clock skew is an alert, not a rejection
    console.warn("Clock skew detected", {
      eventId: event.eventId,
      occurredAt: event.occurredAt,
      receivedAt,
      skewMs,
    });
  }

  const result = await db.queryOne<{ inserted: boolean }>(
    `INSERT INTO usage_events
       (event_id, customer_id, metric_name, quantity, unit, occurred_at, received_at, idempotency_key, metadata)
     VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9)
     ON CONFLICT (idempotency_key) DO NOTHING
     RETURNING true AS inserted`,
    [event.eventId, event.customerId, event.metricName, event.quantity, event.unit,
     event.occurredAt, receivedAt, event.idempotencyKey, JSON.stringify(event.metadata)]
  );

  return {
    accepted: true,
    duplicate: !result?.inserted,
  };
}

The ON CONFLICT (idempotency_key) DO NOTHING clause is the deduplication gate. The unique constraint must be on idempotency_key, not event_id. Producers can reuse the same idempotencyKey across retries (that is the point), so the constraint must target that column.

For high-volume ingest (millions of events per hour), add a Redis SETNX check at the API layer before the database write. Use SET dedupKey "1" NX EX <ttl> where the TTL exceeds your maximum retry window. If the key already exists, return a duplicate response immediately without touching the database. The unique constraint on idempotency_key is the durable guarantee; Redis is the fast path that keeps most duplicates off the write path entirely.


Aggregation: Real-Time vs. Batch

Once events are in storage, you need to aggregate them into per-customer, per-metric totals that the pricing engine can use. The two approaches have different tradeoffs, and most production systems use both.

Batch Aggregation

Batch aggregation runs on a schedule (every hour, or every few minutes depending on latency requirements) and computes usage totals by summing events within a time window.

interface UsageBucket {
  customerId: string;
  metricName: string;
  periodStart: Date;
  periodEnd: Date;
  totalQuantity: number;
  eventCount: number;
  computedAt: Date;
}

async function aggregatePeriod(
  db: DatabaseClient,
  periodStart: Date,
  periodEnd: Date
): Promise<void> {
  await db.execute(
    `INSERT INTO usage_buckets (customer_id, metric_name, period_start, period_end, total_quantity, event_count, computed_at)
     SELECT customer_id, metric_name,
            $1::timestamptz, $2::timestamptz,
            SUM(quantity), COUNT(*), NOW()
     FROM usage_events
     WHERE occurred_at >= $1 AND occurred_at < $2
     GROUP BY customer_id, metric_name
     ON CONFLICT (customer_id, metric_name, period_start)
     DO UPDATE SET
       total_quantity = EXCLUDED.total_quantity,
       event_count    = EXCLUDED.event_count,
       computed_at    = EXCLUDED.computed_at`,
    [periodStart, periodEnd]
  );
}

The upsert pattern lets you re-run aggregation for a period without creating duplicates. This matters because you will need to re-aggregate periods when you discover and backfill missed events.

The period boundaries deserve attention. If your billing cycle is calendar month, you need to decide how to handle events that arrive after the period closes. Late events (due to network delays, retries, or clock skew) that carry an occurredAt in the previous billing period create a correctness problem: the aggregation for that period is already complete. Three options:

  1. Accept late events with a grace window: allow events up to N minutes late, reject anything older. Simple but lossy.
  2. Re-aggregate on late arrival: any late event triggers a re-aggregation job for its period. Correct but requires careful invoice finalization timing.
  3. Bill on received-at, not occurred-at: sidesteps the problem but creates a different one: charges can shift by up to the network delay. Usually the wrong tradeoff for customers.

Option 2 is correct for most systems. Close the billing period with a configurable finalization delay (typically 1 hour) to absorb late arrivals, then lock the period and generate invoices.

Real-Time Aggregation

For usage dashboards or overage enforcement, batch aggregation is too slow. Maintain running counters in Redis that are reconciled against the event store on a schedule.

// On each event ingest, increment the counter in the same hash key
async function incrementUsageCounter(
  redis: RedisClient,
  customerId: string,
  metricName: string,
  quantity: number,
  billingPeriodKey: string // e.g., "2026-03"
): Promise<number> {
  // HINCRBYFLOAT handles decimal quantities (e.g., fractional tokens)
  return redis.hIncrByFloat(
    `usage:${billingPeriodKey}`,
    `${customerId}:${metricName}`,
    quantity
  );
}

// Reading current usage: Redis fast path, fall back to DB on cache miss
async function getCurrentUsage(
  redis: RedisClient,
  db: DatabaseClient,
  customerId: string,
  billingPeriodKey: string
): Promise<Record<string, number>> {
  const allFields = await redis.hGetAll(`usage:${billingPeriodKey}`);
  const prefix = `${customerId}:`;
  const usage: Record<string, number> = {};

  for (const [field, value] of Object.entries(allFields)) {
    if (field.startsWith(prefix)) {
      usage[field.slice(prefix.length)] = parseFloat(value);
    }
  }

  if (Object.keys(usage).length === 0) {
    return getUsageFromDatabase(db, customerId, billingPeriodKey);
  }

  return usage;
}

Redis counters can diverge from the event store if a counter increment succeeds but the event write fails. Run a reconciliation job hourly: compare Redis totals against the aggregated event store and recompute any counter that drifts beyond a threshold. The event store is the source of truth; Redis is a read-optimized projection.


The Pricing Engine

The pricing engine takes aggregated usage for a billing period and applies your pricing model to produce line items. The model itself can range from simple (flat per-unit) to complex (graduated tiers with committed-use discounts and volume caps).

Tiered Pricing

Graduated tiers are the most common structure for API products. The price per unit decreases as volume increases, with different rates applying to different quantity bands.

interface PriceTier {
  upTo: number | null; // null means unlimited (the last tier)
  unitPrice: number;   // price per unit in this tier (in cents)
  flatFee?: number;    // optional flat fee for reaching this tier
}

interface PricingPlan {
  id: string;
  metricName: string;
  currency: string;
  tiers: PriceTier[];
}

function calculateTieredCost(
  quantity: number,
  tiers: PriceTier[]
): { totalCents: number; breakdown: Array<{ tier: number; quantity: number; subtotal: number }> } {
  const breakdown: Array<{ tier: number; quantity: number; subtotal: number }> = [];
  let remaining = quantity;
  let totalCents = 0;
  let tierStart = 0;

  for (let i = 0; i < tiers.length; i++) {
    const tier = tiers[i];
    const tierEnd = tier.upTo ?? Infinity;
    const tierCapacity = tierEnd - tierStart;
    const tierQuantity = Math.min(remaining, tierCapacity);

    if (tierQuantity <= 0) break;

    const subtotal = Math.round(tierQuantity * tier.unitPrice + (tier.flatFee ?? 0));
    breakdown.push({ tier: i, quantity: tierQuantity, subtotal });
    totalCents += subtotal;

    remaining -= tierQuantity;
    tierStart = tierEnd;

    if (remaining <= 0) break;
  }

  return { totalCents, breakdown };
}

// Example: 50k requests at graduated pricing
// 0-10k: $0.001/request
// 10k-100k: $0.0008/request
// 100k+: $0.0006/request
const exampleTiers: PriceTier[] = [
  { upTo: 10_000,  unitPrice: 0.1 },  // $0.001 = 0.1 cents
  { upTo: 100_000, unitPrice: 0.08 }, // $0.0008 = 0.08 cents
  { upTo: null,    unitPrice: 0.06 }, // $0.0006 = 0.06 cents
];

const { totalCents, breakdown } = calculateTieredCost(50_000, exampleTiers);
// First 10k: $1.00
// Next 40k: $3.20
// Total: $4.20

All amounts are integers in the smallest currency unit (cents for USD). Never use floating point for monetary arithmetic. The rounding in Math.round(tierQuantity * tier.unitPrice) must happen once per tier, not accumulated across multiplications.

Committed-Use Discounts

If customers commit to a minimum spend, they get a lower rate. Treat the commitment as a pre-applied discount with a minimum floor, not as a post-billing credit.

function calculateWithCommitment(
  quantity: number,
  committedQuantity: number,
  discountedUnitPrice: number,
  overageUnitPrice: number
): number {
  const committedUnits = Math.min(quantity, committedQuantity);
  const overageUnits = Math.max(0, quantity - committedQuantity);

  // Customers always pay for the committed amount, even if they use less
  const committedCost = Math.round(committedQuantity * discountedUnitPrice);
  const overageCost = Math.round(overageUnits * overageUnitPrice);

  return committedCost + overageCost;
}

The minimum floor is the detail that catches engineers off guard: a customer who commits to 100k requests but uses 40k still pays for 100k. Omitting this means you under-bill every customer who under-utilizes their commitment.


Invoice Generation

Invoice generation runs at the end of each billing period. It reads finalized usage buckets, applies the pricing engine for each metric, and produces an invoice with line items.

async function generateInvoice(
  db: DatabaseClient,
  customerId: string,
  periodStart: Date,
  periodEnd: Date
): Promise<{ id: string; lineItems: InvoiceLineItem[]; totalCents: number }> {
  const usageBuckets = await db.query<UsageBucket>(
    `SELECT * FROM usage_buckets
     WHERE customer_id = $1 AND period_start >= $2 AND period_end <= $3`,
    [customerId, periodStart, periodEnd]
  );

  const pricingPlans = await getPricingPlansForCustomer(db, customerId);
  const lineItems: InvoiceLineItem[] = [];

  for (const bucket of usageBuckets) {
    const plan = pricingPlans.find(p => p.metricName === bucket.metricName);
    if (!plan) continue;

    const { totalCents, breakdown } = calculateTieredCost(bucket.totalQuantity, plan.tiers);
    lineItems.push({
      description: `${bucket.metricName} (${periodStart.toISOString().slice(0, 10)} to ${periodEnd.toISOString().slice(0, 10)})`,
      metricName: bucket.metricName,
      quantity: bucket.totalQuantity,
      totalCents,
      tierBreakdown: breakdown,
    });
  }

  const totalCents = lineItems.reduce((sum, li) => sum + li.totalCents, 0);

  // Persist as a draft; finalize only after validation passes
  const { id } = await db.queryOne<{ id: string }>(
    `INSERT INTO invoices (customer_id, period_start, period_end, line_items, total_cents, status, generated_at)
     VALUES ($1, $2, $3, $4, $5, 'draft', NOW()) RETURNING id`,
    [customerId, periodStart, periodEnd, JSON.stringify(lineItems), totalCents]
  );

  return { id, lineItems, totalCents };
}

Generate invoices as drafts first. This gives you a window to run validation checks before finalizing. Compare the draft total against any quotas or anomaly thresholds. If a customer’s bill is 10x their prior month with no clear explanation (new feature adoption, organic growth), flag it for manual review before finalizing.

Reconciling with Stripe

When Stripe collects payment, you need to synchronize your internal usage totals to Stripe’s usage records before the billing period closes. The key detail is the action field:

await stripe.subscriptionItems.createUsageRecord(
  stripeSubscriptionItemId,
  {
    quantity: Math.round(totalQuantity),
    timestamp: Math.floor(Date.now() / 1000),
    action: "set", // absolute total, NOT "increment"
  },
  {
    idempotencyKey: `usage-sync:${customerId}:${metricName}:${billingPeriodKey}`,
  }
);

Always use action: "set". If the sync job runs twice (retry after timeout), action: "set" replaces the existing total. action: "increment" doubles it. The idempotency key scoped to customerId + metricName + billingPeriodKey makes the Stripe call itself safe to retry.


Architecture Tradeoffs

DimensionApproach AApproach BNotes
DeduplicationRedis SETNX + DB unique constraintDB unique constraint onlyRedis adds throughput; DB is the source of truth. Use both.
Aggregation timingReal-time Redis countersHourly batch from event storeRedis for dashboards; batch for billing. Both are needed.
Clock skew handlingAlert and acceptReject late eventsRejection is simpler but loses real usage. Alert and accept.
Pricing computationAt invoice time from raw usagePre-computed during aggregationCompute at invoice time so plan changes don’t require backfill.
Stripe sync actionset (absolute)increment (delta)Always use set to make sync idempotent.
Late event handlingGrace window + re-aggregateReject after period closeRe-aggregation is correct; grace window prevents customer disputes.

Observability

Billing systems fail silently. No exception is thrown when an event is dropped. No alert fires when a counter drifts. You have to build the detection layer yourself.

The metrics that matter:

  • Ingest duplicate rate: duplicate_events / total_events per hour. A sudden spike means a producer is stuck in a retry loop. A sustained baseline above 0.1% means your idempotency key generation is flawed.
  • Clock skew histogram: distribution of abs(receivedAt - occurredAt) in milliseconds. P99 skew above 2 minutes indicates a producer with a misconfigured clock or a network relay introducing delay.
  • Aggregation coverage: for each billing period, count(aggregated customers) / count(customers with events). If this drops below 1.0, some customers’ usage was not aggregated. This is a billing defect.
  • Stripe sync lag: age of the oldest unsynced usage record. If this grows past one hour, your sync job has stalled.
  • Invoice total anomaly: ratio of current period invoice total to prior period for each customer. Flag anything above 3x for manual review before finalization.

A coverage check is worth running after every aggregation job:

-- Customers with events in the period but no bucket (aggregation gap)
SELECT DISTINCT ue.customer_id
FROM usage_events ue
WHERE ue.occurred_at >= $1 AND ue.occurred_at < $2
AND NOT EXISTS (
  SELECT 1 FROM usage_buckets ub
  WHERE ub.customer_id = ue.customer_id
  AND ub.period_start = $1
);

Any rows returned are billing defects. Emit an alert and trigger re-aggregation for the affected customers before invoice generation runs.


Production Considerations

Event volume at scale. At millions of events per hour, the ingest layer becomes a write-heavy bottleneck. Partition the usage_events table by occurred_at (monthly partitions) so that aggregation queries scan only the relevant partition. Index on (customer_id, metric_name, occurred_at) within each partition. For extreme write throughput, buffer events in Kafka and write to the database in batches rather than per-event.

Billing period finalization timing. Never finalize a billing period while aggregation jobs are still running for it. Use a period state machine: open (accepting events), closing (grace window, no new events accepted), closed (aggregation final), invoiced. State transitions must be locked against the aggregation job.

Plan change mid-period. If a customer upgrades from one pricing tier to another mid-period, you have two options: prorate the period or start the new plan at the next period boundary. Proration requires splitting the aggregation buckets at the plan change timestamp, which adds complexity to the pricing engine. Starting at the next boundary is simpler and more predictable for customers.

Backfill and corrections. Events can be discovered late (from a producer bug, a replay from audit logs). Your aggregation job must be safe to re-run for any period. The upsert pattern in the aggregation query handles this. For invoices already finalized, you need a correction mechanism: a credit note or an adjustment invoice, not an edit to the original.


Closing

Usage-based billing is a correctness problem more than a scaling problem. The hard part is not handling high event volume; it is ensuring that every event is counted exactly once, attributed to the correct billing period, and reflected accurately in both your invoice and your payment provider. The pipeline described here, idempotent ingest, reconciled aggregation, deterministic pricing, draft-then-finalize invoice generation, and continuous observability, gives you the levers to detect and fix discrepancies before customers do.

Get the event schema and deduplication right first. Every other problem is easier to fix when your event store is an accurate source of truth.

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.