The most common usage-billing design I see in SaaS codebases sends one Stripe API call per customer request. It works in staging. In production it runs into rate limits during traffic spikes, double-counts usage whenever a queue job retries, and cannot answer the question every finance team eventually asks: "Show me exactly which events make up this invoice line." The billing code is short. The cleanup is not.
I wrote this with help from AI tools and checked every command and claim myself against Stripe's current API reference. I run Loop Origin, where we build custom SaaS platforms in Laravel, Node.js and .NET, and metered billing for AI and API products comes up often. This post is the architecture I use: durable event ingestion, hourly rollups you own, and a thin, idempotent reporter to Stripe Billing Meters.
📚 Series Roadmap — Production AI & SaaS Systems Engineering
- Part 1: Building Production MCP Servers & Tool-Calling AI Agents: Sandboxing, Schemas & Approval Gates
- Part 2: Zero-Downtime Database Migrations in Laravel & Node.js SaaS: Expand-Contract & Lock Timeouts
- Part 3: Multi-Tier Caching & Stampede Protection with Redis/Valkey: Singleflight Locks & SWR
- Part 4 (This Post): SaaS Usage-Based Billing & Metering Architecture: Event Ingestion, Rollups & Stripe Invoicing
- Part 5: Production Observability on a Budget: OpenTelemetry Traces, Structured Logs & Tail Sampling
- Part 6: LLM Gateway & FinOps Architecture for B2B SaaS: Semantic Caching, Routing & Tenant Quotas
How should a SaaS product architect usage-based billing with Stripe?
Direct answer: Record every billable action as an immutable event in your own database with a unique idempotency key, roll events up per tenant, meter and hour, then report each rollup (or its delta) to Stripe Billing Meters with a deterministic identifier. Your ledger stays the source of truth; Stripe aggregates, prices and invoices.
What changed in Stripe, and why it matters
If you built metered billing before 2025, you probably used usage records on subscription items. Stripe removed legacy usage-based billing in API version 2025-03-31.basil: metered prices now must be backed by a Meter, and usage is reported as meter events to POST /v1/billing/meter_events. A few properties of that API shape the whole design:
- Each event carries an
event_name, apayloadwithstripe_customer_idandvalue, an optionaltimestampand an optionalidentifier. - Stripe enforces
identifieruniqueness within a rolling period of at least 24 hours. That protects you from quick retries, not from a job that re-runs tomorrow. -
timestampmust be within the past 35 calendar days and no more than 5 minutes in the future. - In live mode the endpoint allows 1,000 calls per second per account, and only one concurrent call per customer per meter.
- Processing is asynchronous. Invalid events surface later as
v1.billing.meter.error_report_triggeredevents, not as HTTP errors. - Usage must arrive before the invoice for that period finalizes. Late events can be accepted without being added to an already-finalized invoice.
The last three points are why "call Stripe on every request" fails in production. The 24-hour dedupe window is why Stripe cannot be your only defense against double billing.
The architecture
App / API / AI worker
│ (same DB transaction as the business action)
â–¼
usage_events (append-only, UNIQUE(tenant_id, idempotency_key))
│ every 5 min
â–¼
usage_rollups (tenant, meter, hour) ── quantity, reported_quantity
│ reporter job, serialized per customer + meter
â–¼
Stripe Billing Meter events ──► meter-backed Price ──► Invoice
│
└──► nightly reconciliation against meter event summaries
1. Ingest: one immutable row per billable action
Write the usage event in the same transaction as the work it bills for. If the AI completion is saved, the usage is saved. If the transaction rolls back, nothing is billed.
CREATE TABLE usage_events (
id bigserial PRIMARY KEY,
tenant_id bigint NOT NULL,
meter text NOT NULL, -- 'ai_tokens', 'api_calls'
quantity numeric(20,6) NOT NULL CHECK (quantity >= 0),
occurred_at timestamptz NOT NULL,
idempotency_key text NOT NULL, -- e.g. request id + step
properties jsonb NOT NULL DEFAULT '{}',
UNIQUE (tenant_id, idempotency_key)
);
CREATE INDEX ON usage_events (meter, occurred_at);
// Node.js (pg-promise): record usage after the model call succeeds
await db.tx(async (t) => {
await t.none('INSERT INTO completions(id, tenant_id, output) VALUES ($1,$2,$3)',
[completionId, tenantId, output]);
await t.none(`INSERT INTO usage_events
(tenant_id, meter, quantity, occurred_at, idempotency_key, properties)
VALUES ($1, 'ai_tokens', $2, now(), $3, $4)
ON CONFLICT (tenant_id, idempotency_key) DO NOTHING`,
[tenantId, usage.total_tokens, `completion:${completionId}`, { model }]);
});
ON CONFLICT DO NOTHING makes retries harmless. The idempotency key must come from the business action (completion ID, request ID plus step), never from uuid() at insert time, or every retry becomes new revenue and a future refund.
2. Roll up: hourly buckets you can recompute
-- usage_rollups has UNIQUE (tenant_id, meter, hour)
INSERT INTO usage_rollups (tenant_id, meter, hour, quantity)
SELECT tenant_id, meter, date_trunc('hour', occurred_at), sum(quantity)
FROM usage_events
WHERE occurred_at >= now() - interval '48 hours'
GROUP BY 1, 2, 3
ON CONFLICT (tenant_id, meter, hour)
DO UPDATE SET quantity = EXCLUDED.quantity, updated_at = now();
Recomputing a 48-hour window each run absorbs late events from mobile clients and delayed queues. The rollup table also powers the customer's usage dashboard, so you never query Stripe to render a chart. If you are adding these tables to a live database, the expand-contract approach from Part 2 applies.
3. Report: deltas with deterministic identifiers
The reporter sends only what Stripe has not seen yet. Because the meter uses sum, a late correction is just another positive event:
// Laravel job, one per tenant + meter
public function middleware(): array
{
return [(new WithoutOverlapping("meter:{$this->tenantId}:{$this->meter}"))->releaseAfter(30)];
}
public function handle(StripeClient $stripe): void
{
UsageRollup::where('tenant_id', $this->tenantId)
->where('meter', $this->meter)
->whereColumn('quantity', '>', 'reported_quantity')
->where('hour', '>=', now()->subDays(30))
->lazyById(200)
->each(function (UsageRollup $r) use ($stripe) {
$delta = bcsub($r->quantity, $r->reported_quantity, 6);
$version = $r->report_version + 1;
$stripe->billing->meterEvents->create([
'event_name' => $this->meter,
'identifier' => "{$r->tenant_id}:{$r->meter}:{$r->hour->timestamp}:v{$version}",
'timestamp' => $r->hour->timestamp,
'payload' => [
'stripe_customer_id' => $r->tenant->stripe_customer_id,
'value' => $delta,
],
]);
$r->update(['reported_quantity' => $r->quantity, 'report_version' => $version]);
});
}
Three details matter here. WithoutOverlapping keyed by tenant and meter respects Stripe's one-concurrent-call-per-customer-per-meter limit. The identifier is deterministic, so a crash between the API call and the update() produces a duplicate that Stripe rejects within its dedupe window. The 30-day cutoff keeps timestamps safely inside Stripe's 35-day window. Run these jobs on a dedicated queue with retries and backoff; my notes on running Laravel queues in production cover the Supervisor and failed-job side.
To reverse a wrong event, Stripe offers POST /v1/billing/meter_event_adjustments with type=cancel and the event's identifier. I use it only for mistakes; normal corrections go through the delta path.
4. Reconcile: trust, then verify
Every night, compare your rollups with GET /v1/billing/meters/{id}/event_summaries?customer=...&value_grouping_window=hour. Reporting each event with the hour-start timestamp makes Stripe's hourly buckets line up exactly with yours. Alert on any hour where they differ, and subscribe to the meter error events so a missing stripe_customer_id mapping does not silently drop a tenant's usage.
Choosing an ingestion strategy
| Approach | Throughput to Stripe | Duplicate protection | Audit trail | Invoice freshness | When I use it |
|---|---|---|---|---|---|
| Per-request meter event | Up to 1,000 calls/s per account, 1 concurrent per customer/meter | Stripe identifier only (≥24h window) | Only in Stripe | Near real time | Prototypes, low volume |
| Hourly rollup + deltas (this post) | A few calls per tenant per hour | Your unique keys plus Stripe identifier | Full, in your DB | Within the hour | Most B2B SaaS and AI products |
| v2 meter event streams | Up to 10,000 events/s (live mode), 15-minute session tokens | Stripe identifier plus your own | Only in Stripe unless you also store events | Near real time | Very high-volume event billing |
| Own rating engine, Stripe for invoices only | One invoice item per period | Fully yours | Full | End of period | Complex contracts, credits, committed spend |
Pre-launch checklist
- Every billable action writes exactly one event with a business-derived idempotency key.
- Usage is written in the same transaction as the work it bills.
- Rollups are recomputable from raw events for any past window.
- Reporter calls are serialized per customer and meter, with deterministic identifiers.
- No usage timestamp is older than 30 days when reported.
- Usage for a period is reported before the invoice finalizes; you know your grace period.
- Nightly reconciliation against meter event summaries, with alerts.
- Meter error events are subscribed to and routed to on-call.
- Raw events are retained long enough to answer invoice disputes.
FAQ
Should I send raw events or aggregates to Stripe?
For most products, aggregates. Stripe recommends pre-aggregating if you approach the rate limits, and aggregates keep your API calls proportional to tenants, not traffic. Keep raw events in your database for audits.
Why not rely on Stripe's identifier for idempotency?
Because uniqueness is only guaranteed for a rolling period of at least 24 hours. A backfill or replayed job days later would pass. Your own unique constraint has no expiry.
How do I handle refunds or credits for bad usage?
For a wrong event, cancel it with a meter event adjustment. For goodwill credits, use credit notes or customer balance adjustments rather than editing usage, so the usage record stays truthful.
Can usage be reported before the customer subscribes?
You can record it: meters are tied to customers, not subscription items, and Stripe's migration guide lists collecting usage before a subscription exists as a meter feature. Invoices reflect usage only once the customer subscribes to a meter-backed price, so test how early usage lands on the first invoice in a sandbox before relying on it.
What aggregation formula should the meter use?
sum for tokens, calls, GB processed. count when each event is one unit. last (added in the Basil release) suits values like seats or storage snapshots, where you send the current level rather than increments. Deltas only work with sum.
How does this connect to integrations with ERPs or CRMs?
The same pattern applies: an outbox, retries and a dead-letter path. I covered that in Resilient ERP & CRM API Integrations: Retries, DLQs, Replay.
Next in this series, Part 5 covers production observability on a budget, including how to trace a usage event from the API request all the way to the invoice line.
Follow & Connect
- Website: Loop Origin
- LinkedIn: Loop Origin
- X: Muhammad Ali Mughal (@AliMughal887)
- GitHub: Loop Origin (@LoopOrigin)

Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.