Implementing Metered Billing: Stripe vs Custom

You reach this decision the moment metered pricing stops being a spreadsheet idea and becomes a system you must operate: do you push usage into Stripe’s managed meters, or build your own ingestion-aggregation-ledger stack? The choice is rarely about the pricing math — both paths can express graduated, tiered, and per-unit pricing. It is about who owns correctness under retries, late events, and reconciliation. This page sits under Usage-Based Billing Implementation; read that first for the pipeline shape, then use this page to choose a side. For broader pricing context see Subscription Billing Architecture & Pricing Models.

Trade-offs

The decision reduces to a single question: who owns correctness under retries, late events, and reconciliation? Stripe owns it in exchange for lock-in and latency; a custom engine gives you sub-second control and portability in exchange for an on-call pipeline. The map places the two on that ownership axis.

Metered billing ownership Stripe owns aggregation, reconciliation, and compliance at the cost of lock-in and latency; a custom engine owns everything for sub-second control and portability. Stripe Meters owns: aggregate, rate, tax days to launch minutes latency high lock-in Custom engine owns: everything weeks to months sub-second possible portable, on-call
Start on Stripe; build custom only when a concrete requirement — pricing shape, latency, or lock-in — breaks the managed model.
Dimension Stripe Metered Billing Custom Usage Engine
Time to first invoice Days — meters, prices, and webhooks are wiring Weeks to months — you build ingestion, aggregation, rating, ledger
Aggregation latency Minutes (Stripe rolls up async) Sub-second possible with your own counters
Per-event cost Free to meter; bundled into Stripe’s ~2.9% + 30¢ processing Infra only (~$0.0000x/event on a queue + Postgres)
Pricing flexibility Graduated / tiered / volume / package; bounded by Stripe’s model Arbitrary — bespoke caps, blended rates, multi-meter formulas
Reconciliation burden Low — Stripe is the source of truth High — you own drift detection vs. ledger
Compliance burden Stripe carries PCI scope + invoice tax via Stripe Tax You integrate tax + own audit trail and retention
Real-time usage dashboards Limited / delayed Full control, sub-second
Vendor lock-in High — meters, prices, and proration live in Stripe Low — portable schema, swap processors freely
Operational headcount Near zero Ongoing on-call for the pipeline

The honest summary: Stripe wins on time-to-market and reconciliation overhead; custom wins when your pricing exceeds Stripe’s model, when you need sub-second usage visibility, or when you must avoid lock-in. Most teams should start on Stripe and only build custom when a concrete requirement breaks the managed model.

Where the cost curves actually cross

The per-event cost row hides the real economics. Stripe does not charge per meter event, so at low volume the managed path is effectively free beyond the processing percentage you already pay on the invoice. The crossover is not about event fees at all — it is about the marginal engineer. A custom pipeline that ingests, aggregates, rates, and reconciles is roughly one on-call rotation plus a queue and a Postgres primary with a read replica. Call that a fully loaded 200000 to 400000 (in whole currency units) per year once you count the pager. Against that, Stripe’s aggregation being bundled means you break even only when the managed model forces a workaround expensive enough to dominate that number: a pricing shape you cannot express, a latency SLA Stripe’s minutes-scale rollup cannot meet, or a lock-in exit you must fund anyway. If your monthly metered revenue is under roughly 50000 (whole units), the arithmetic almost never favours building, because the pipeline cost is fixed while the saving scales with volume you do not yet have.

The hybrid most teams actually ship

The cleanest real-world answer is rarely pure. A common shape is to run your own ingestion and aggregation — because you want sub-second internal dashboards and a raw usage_events table you fully control — but still submit the per-window aggregate to Stripe Meters for rating, invoicing, and tax. You keep the portable schema and the real-time counters; Stripe keeps the proration math and the PCI scope. The cost is a second source of truth, which means the reconciliation gate in step four stops being optional: your aggregate and Stripe’s rated total must agree to the cent before finalize, or the hybrid quietly bills two different numbers. Teams that adopt this pattern get most of the custom-path visibility for a fraction of the custom-path correctness burden, and they retain a clean migration path if they later decide to rate in-house.

Step-by-Step Implementation

Both paths share the first step — idempotent ingestion — then fork: Stripe submits aggregates to Meters and lets Stripe rate them, while the custom path aggregates and posts to its own ledger. Both rejoin at a reconciliation gate before finalizing. The flow shows the shared-then-forked shape.

Metered implementation fork Idempotent ingestion is shared, then the path forks to Stripe Meters or a custom ledger, and both rejoin at a reconciliation gate. Ingest (shared) idempotent Stripe Meters managed rate Custom ledger aggregate + post Reconcile gate before finalize
Shared idempotent ingestion, a fork by path, and a shared reconciliation gate — the divergence is only in the middle.

1. Ingest events idempotently

Both paths share this step. Stripe’s meter event API is at-least-once, and so is any real queue, so deduplicate before anything downstream. Derive the key from the event’s natural identity — this is the same guarantee an idempotent webhook consumer pattern provides for inbound webhooks.

Note the two-layer dedup in the handler: a Redis SET NX with a seven-day TTL for the hot path, and an ON CONFLICT (event_id) DO NOTHING in Postgres as the durable backstop. The Redis check absorbs the retry storms cheaply — a client replaying the same event_id five times in a second never touches the database — but Redis is not your system of record, and a key can expire or be evicted under memory pressure. The unique constraint on event_id is what actually guarantees each event counts once, even if the same replay arrives eight days later after the Redis key is gone. If you keep only one layer, keep the database constraint; the cache is an optimisation, not the invariant. Size the TTL to your maximum credible retry horizon plus your delivery-delay budget, not to a round number: seven days comfortably covers a producer that was offline over a long weekend and drains its backlog on Monday.

import crypto from 'crypto';
import { Request, Response } from 'express';
import { redisClient, db } from './db';

export async function ingestUsageEvent(req: Request, res: Response): Promise<void> {
  const { customerId, meter, quantity, eventId, occurredAt } = req.body;

  const idemKey = crypto.createHash('sha256')
    .update(`${customerId}:${meter}:${eventId}`)   // identity, never received-at
    .digest('hex');

  const isNew = await redisClient.set(`idem:${idemKey}`, '1', { NX: true, EX: 604800 });
  if (!isNew) {
    res.status(200).json({ status: 'duplicate', idemKey });   // ✅ safe replay
    return;
  }

  await db.query(
    `INSERT INTO usage_events (event_id, customer_id, meter, quantity, occurred_at)
     VALUES ($1, $2, $3, $4, $5)
     ON CONFLICT (event_id) DO NOTHING`,             // ✅ durable dedup backstop
    [eventId, customerId, meter, quantity, new Date(occurredAt).toISOString()],
  );
  res.status(202).json({ status: 'accepted' });
}

2. Aggregate into the billing window (custom path)

Stripe aligns events to current_period_start / current_period_end for you. A custom engine must bucket by UTC event time. Store every timestamp as TIMESTAMPTZ and never trust local server time for boundaries.

SELECT customer_id, meter, SUM(quantity) AS total_quantity
FROM usage_events
WHERE occurred_at >= $1   -- period_start (TIMESTAMPTZ, UTC)
  AND occurred_at <  $2   -- period_end   (TIMESTAMPTZ, UTC)
GROUP BY customer_id, meter;

3a. Submit to Stripe Meters (managed path)

On the Stripe path you send meter events; Stripe aggregates and rates them onto the subscription. Use the current Meters API — the legacy createUsageRecord() endpoint was removed in Stripe API version 2025-03-31 (basil).

The identifier field is doing more work than it looks. Stripe deduplicates meter events on identifier within the meter’s aggregation window, so passing crypto.randomUUID() as shown is only correct when your caller is already deduplicated upstream — which, in this pipeline, it is, because step one collapsed retries before this function runs. If you were to call reportToStripe directly from a retrying HTTP handler, a fresh random UUID per attempt would defeat Stripe’s dedup and double-count. The safer construction is to reuse the same deterministic event_id-derived value you computed during ingestion, so a retry that somehow reaches Stripe twice carries the same identifier and collapses. One more subtlety: Stripe’s rollup is asynchronous and lands on the subscription minutes later, so a meter event accepted at 202 is not yet visible on the invoice preview. Never read back the meter total immediately and treat it as authoritative; the reconciliation gate exists precisely because the write and the readable aggregate are eventually, not immediately, consistent.

import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function reportToStripe(customerId: string, value: number): Promise<void> {
  await stripe.billing.meterEvents.create({
    event_name: 'api_calls',
    payload: { stripe_customer_id: customerId, value: String(value) },
    identifier: crypto.randomUUID(),   // Stripe dedups on identifier within a window
  });
}

3b. Post to a custom ledger (build path)

On the custom path, rate the aggregate and write a ledger entry plus an outbox row in one transaction. The outbox pattern makes the ledger commit and the downstream event publish atomic.

BEGIN;
INSERT INTO ledger_entries (ledger_entry_id, customer_id, invoice_id, amount_cents, kind, window_key)
VALUES (gen_random_uuid(), :customer_id, :invoice_id, :amount_cents, 'usage', :window_key)
ON CONFLICT (window_key) DO NOTHING;
INSERT INTO outbox (event_type, payload, status)
VALUES ('usage.rated', :payload, 'PENDING');
COMMIT;

4. Reconcile before finalizing

Whichever path you chose, diff your raw-event total against the system of record before charging. On Stripe, compare your ledger to invoice.lines for the exact period; on custom, compare the counter to a fresh SUM over usage_events.

const localTotal = await getLocalLedgerTotal(subscriptionItemId);
const stripeTotal = await fetchStripeMeterTotal(subscriptionItemId, periodStart, periodEnd);
if (Math.abs(localTotal - stripeTotal) > 0) {
  await flagReconciliationDrift(subscriptionItemId, localTotal, stripeTotal);  // ⚠️ block finalize
}

Verification & Testing

The tests are the same for both paths because the invariants are the same: dedup counts once, event-time bucketing is correct, and the reconciliation diff is zero. The one path-specific test is that Stripe collapses two events with the same identifier. The panel lists them.

Metered billing tests Replay dedup, event-time bucketing across DST, Stripe identifier collapse, and a zero-cent reconciliation diff. Replay same eventId one row DST window TIMESTAMPTZ no off-by-hour Identifier two, same id Stripe counts 1 Reconcile ledger vs raw diff = 0
Same invariants, same tests — the reconciliation diff is the one gate that must be hard, not a dashboard.

Replay the same eventId twice and assert exactly one row in usage_events and one increment in the aggregate. Submit an out-of-order event (event time earlier than the last processed) and assert it lands in the window matching its occurred_at. For the Stripe path, create two meter events with the same identifier inside one window and assert Stripe counts them once. For reconciliation, seed a known set of events, run aggregation, and assert the ledger total equals the hand-computed sum to the cent. Run these against TIMESTAMPTZ values spanning a DST transition to catch off-by-one-hour window bugs.

The Stripe identifier-collapse test is the one case where a unit test is not enough, because the dedup happens inside Stripe, not in your code. Run it against a real test-mode meter and poll the meter’s aggregated value until it settles, rather than asserting synchronously — the rollup delay that makes step 3a eventually consistent applies here too, and a test that reads the total one millisecond after the second create call will see zero and flake. Give the assertion a bounded retry with a timeout measured in tens of seconds, and fail loudly if the settled total is two instead of one, because that is the signal your identifier is not stable across retries.

A concrete boundary case worth pinning

The window-boundary test deserves its own fixture: an event whose occurred_at is exactly period_end. The aggregation query in step two uses a half-open interval, >= period_start AND < period_end, which means an event stamped at the exact microsecond of period_end belongs to the next window, not this one. Assert that explicitly with a stored value at the boundary and one microsecond below it, and confirm they land in different invoices. Teams that use a closed upper bound (<=) double-count the boundary event into both periods, which surfaces as a one-unit reconciliation drift that only appears on cycles where a real event happened to land on the tick. Because it is data-dependent, it passes in staging and fails in production months later, so encode the interval convention in a test rather than a comment.

-- Reconciliation assertion: counter must equal raw sum for the window
SELECT (SELECT SUM(quantity) FROM usage_events
        WHERE customer_id = :cid AND occurred_at >= :start AND occurred_at < :end)
     = (SELECT total_quantity FROM usage_counters
        WHERE customer_id = :cid AND period_start = :start) AS reconciled;

Gotchas & Production Pitfalls

The pitfalls split into identity mistakes (keying on the wrong thing), API mistakes (assuming exactly-once or using a removed endpoint), and money mistakes (floats, un-diffed ledgers). The map groups them so each correction is a single rule.

Metered billing pitfalls Identity pitfalls key on received-at or arrival time, API pitfalls assume exactly-once or use removed endpoints, and money pitfalls use floats or skip reconciliation. Identity key on now() bucket by arrival → event_id + event-time API assume exactly-once removed endpoint → identifier + Meters API Money float SUM drift un-diffed ledger → cents + hard reconcile
Three categories of mistake — identity, API, and money — each with one rule that prevents it.
  • Deriving idempotency keys from a received timestamp. The key changes on every retry, so dedup silently fails and you double-count. Always key on the client’s event_id, never on now().
  • Assuming Stripe meters are exactly-once. The meter event API is at-least-once; supply a stable identifier per logical event so Stripe collapses retries within the aggregation window.
  • Bucketing by arrival time. Delayed delivery then shifts usage into the wrong invoice. Bucket strictly by event time and treat post-watermark events as carry-forward adjustments.
  • Using the removed createUsageRecord() endpoint. It returns errors on API versions 2025-03-31 and later. Migrate to billing.meterEvents.create() against a meter.
  • Underestimating custom reconciliation. A custom ledger that no one diffs against raw events will drift invisibly until a customer disputes a charge. Make the pre-finalize reconciliation query a hard gate, not a dashboard.
  • Mixing money types. Store amounts as integer minor units (amount_cents BIGINT); a float SUM over thousands of usage line items accumulates rounding error.

Frequently Asked Questions

What does a provider’s metering not handle well? Complex tier interactions, allowances shared across products, and drawdown against a commitment. Simple per-unit and tiered metering is well covered.

Can both be used together? Yes, and it is a common arrangement: aggregate and rate in your own pipeline, then push a single computed quantity or amount to the provider for invoicing. It keeps the rating logic yours and the invoicing theirs.

Which approach is easier to audit? Your own, provided you keep raw events. A provider’s aggregate is a number you cannot decompose, and a customer disputing usage will ask for the decomposition.

When is the provider’s metering clearly the right choice? Early, at low volume, with a simple pricing model. The engineering saved is real, and the migration to your own pipeline later is tractable if you keep the raw events from the start.