Stripe Metered Billing: Building a Usage Ledger That Survives Out-of-Order Webhooks
Stripe's metered billing webhooks don't arrive in order. Here's how I built an idempotent usage ledger that doesn't double-charge customers when they don't.
Stripe's metered billing is genuinely useful until the first time you realize webhooks don't arrive in chronological order — and your usage ledger has silently double-counted 40% of a customer's API calls. I learned this the hard way building a usage-based billing system for a biotech client running genomics processing jobs. The fix isn't complicated, but it's not obvious from the docs either.
What Metered Billing Actually Is
Stripe's metered billing lets you report usage incrementally throughout a billing period and have Stripe sum it up at invoice time. Instead of charging a fixed monthly fee, you call subscriptionItems.createUsageRecord with a quantity and a timestamp, and Stripe aggregates those records into a line item when the billing cycle closes.
The appeal is real. For my biotech client, each job has variable compute time. They wanted customers billed per CPU-minute consumed. Metered billing is the right tool — you're not trying to track this yourself and reconcile it at month-end. Stripe holds the ledger.
The problem is that Stripe fires invoice.created, customer.subscription.updated, and usage-related events across multiple webhook deliveries, with no ordering guarantee. Stripe's own docs acknowledge this: "Webhooks may be delivered more than once and may not be delivered in order." That sentence does a lot of heavy lifting.
The Failure Mode Nobody Warns You About
Here's what actually bites you. You have a worker that listens for invoice.upcoming (fires ~72 hours before period end) and uses that as a signal to flush any buffered usage to Stripe via createUsageRecord. Reasonable enough.
But you've also got application-level events firing createUsageRecord in near-real-time as jobs complete, because your client wants a live usage dashboard. So now you have two code paths that both write usage records, and your webhook handler is also reading back usage to render that dashboard.
Stripe delivers invoice.upcoming twice due to a retry (maybe your server returned a 500 during a deploy). Your handler fires twice. You've now reported the same usage twice. Stripe summed it. Your customer got double-charged.
Or the subtler version: your webhook handler processes customer.subscription.updated and infers that a billing period rolled over, so it resets your local usage counter — but the event arrived late, after you'd already reported usage for the new period. Now your counter resets in the middle of an active period and you under-report.
Both of these happened to us. The double-charge was caught by a sharp customer. The under-reporting took two billing cycles to notice.
The Fix: A Local Usage Ledger with Idempotency Keys
Stop treating Stripe as the source of truth during the period. Use it as the settlement layer. Maintain your own append-only usage ledger locally, and only report to Stripe from that ledger using stable idempotency keys.
Here's the schema I use:
CREATE TABLE usage_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
idempotency_key VARCHAR(255) NOT NULL UNIQUE,
subscription_item_id VARCHAR(255) NOT NULL,
customer_id VARCHAR(255) NOT NULL,
quantity BIGINT UNSIGNED NOT NULL,
occurred_at TIMESTAMP NOT NULL,
reported_at TIMESTAMP NULL,
stripe_usage_record_id VARCHAR(255) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_subscription_reported (subscription_item_id, reported_at)
);
The idempotency_key is the load-bearing column. I derive it from something stable in the application domain — for the biotech client it was the job UUID plus the metric type:
$idempotencyKey = hash('sha256', "job:{$job->uuid}:metric:cpu_minutes");
When a job completes, I write to this table first, before touching Stripe:
use Illuminate\Support\Facades\DB;
function recordJobUsage(Job $job, int $cpuMinutes): void
{
$key = hash('sha256', "job:{$job->uuid}:metric:cpu_minutes");
DB::table('usage_events')->insertOrIgnore([
'idempotency_key' => $key,
'subscription_item_id' => $job->subscription_item_id,
'customer_id' => $job->stripe_customer_id,
'quantity' => $cpuMinutes,
'occurred_at' => $job->completed_at,
]);
}
insertOrIgnore is doing real work here. If this function gets called twice for the same job — retry logic, duplicate queue messages, whatever — the second insert is silently dropped. One event, one row, one idempotency key. That's the whole game.
Then a separate background process reports unreported events to Stripe:
use Stripe\StripeClient;
function flushUsageToStripe(StripeClient $stripe): void
{
$unreported = DB::table('usage_events')
->whereNull('reported_at')
->orderBy('occurred_at')
->limit(100)
->get();
foreach ($unreported as $event) {
try {
$record = $stripe->subscriptionItems->createUsageRecord(
$event->subscription_item_id,
[
'quantity' => $event->quantity,
'timestamp' => (new \DateTime($event->occurred_at))->getTimestamp(),
'action' => 'increment',
],
[
'idempotencyKey' => $event->idempotency_key,
]
);
DB::table('usage_events')
->where('idempotency_key', $event->idempotency_key)
->update([
'reported_at' => now(),
'stripe_usage_record_id' => $record->id,
]);
} catch (\Stripe\Exception\ApiErrorException $e) {
// Log and continue — the next cron run will retry unreported events
\Log::error('Stripe usage report failed', [
'idempotency_key' => $event->idempotency_key,
'error' => $e->getMessage(),
]);
}
}
}
Notice I'm passing the idempotency_key to Stripe's own idempotency header as well. If flushUsageToStripe runs twice before the reported_at update commits — possible under concurrent cron runs — Stripe will deduplicate on its end too. Belt and suspenders.
Handling Webhooks Without Mutating State Blindly
Now your webhook handler's job gets simpler. For invoice.upcoming, don't use it as a trigger to report usage. That's already happening via the flush job. Use it only as a signal to make sure the flush job runs promptly:
// In your Stripe webhook controller
public function handleInvoiceUpcoming(array $payload): void
{
$subscriptionId = $payload['data']['object']['subscription'] ?? null;
if ($subscriptionId) {
FlushStripeUsageJob::dispatch($subscriptionId)->onQueue('billing');
}
// Nothing else. No counter resets. No direct Stripe writes.
}
For customer.subscription.updated, before you reset any local state, check whether the period actually changed by comparing current_period_start from the event against what you have stored locally:
public function handleSubscriptionUpdated(array $payload): void
{
$sub = $payload['data']['object'];
$subId = $sub['id'];
$newPeriodStart = $sub['current_period_start'];
$stored = DB::table('subscriptions')
->where('stripe_subscription_id', $subId)
->value('current_period_start');
if ($stored && $newPeriodStart <= $stored) {
// Out-of-order or duplicate. Bail.
return;
}
DB::table('subscriptions')
->where('stripe_subscription_id', $subId)
->update(['current_period_start' => $newPeriodStart]);
// Safe to do period-rollover logic now
}
That $newPeriodStart <= $stored check is what kills the out-of-order problem. You're using the event's own data as a logical clock, not the delivery timestamp.
Gotchas That Will Still Get You
Stripe deduplicates idempotency keys for only 24 hours on usage records. After that, the same key can create a new record. This mostly matters if you're retrying a failed report from days ago. In practice, if a usage event hasn't been reported within 24 hours, something is seriously wrong and you need an alert, not a retry.
action: increment vs action: set. I always use increment. If you use set and report out of order, the last write wins and you lose data. increment is commutative. Use it.
The occurred_at timestamp matters. Stripe uses it to associate usage with the correct billing period. If your jobs run near a period boundary, make sure occurred_at reflects when the work actually happened, not when you got around to reporting it. I pull completed_at directly off the job record for this reason.
Concurrent flush workers will fight. If you run multiple instances of your flush job, you need a database-level lock or a SELECT ... FOR UPDATE SKIP LOCKED pattern to avoid two workers claiming the same unreported rows. Laravel's lockForUpdate() works fine here.
When I'd Use This Pattern (and When I Wouldn't)
This setup makes sense any time you have high-cardinality usage events — per-request, per-job, per-minute — coming from application code, and you need billing to be accurate without babysitting Stripe's webhook delivery.
If you're just doing simple seat-based billing and adjusting quantities a few times a month, this is overkill. Reach for Stripe's customer portal and a straightforward subscription update. The ledger pattern earns its complexity when the usage data is generated by your system, not chosen by the customer.
I wouldn't use metered billing at all if the usage aggregation logic is genuinely complex — tiered pricing with rollover credits, multi-dimensional pricing, usage grants. At that point you probably want a dedicated billing engine (Lago, Orb, Metronome) and you use Stripe purely for payment collection. Stripe Billing is good at summing numbers. It's not a rating engine.
The Takeaway
Stripe's metered billing is solid infrastructure with one ugly assumption baked in: that you'll handle the fact that webhooks are unreliable delivery of unordered events. The idempotency key plus an append-only local ledger is the pattern that makes it safe. Once I had this in place for the biotech client, billing accuracy went from "we think it's probably right" to something I could actually verify row by row. That's worth the extra 150 lines of code.
Need help shipping something like this? Get in touch.