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
| 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.
Step-by-Step Implementation
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.
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).
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
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.
-- 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
- 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 onnow(). - Assuming Stripe meters are exactly-once. The meter event API is at-least-once; supply a stable
identifierper 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 versions2025-03-31and later. Migrate tobilling.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 floatSUMover thousands of usage line items accumulates rounding error.