Skip to content
Blog

Metered usage that never reached the Stripe invoice

Your own dashboard says the account burned 4.1 million units last month. The invoice Stripe sent bills for 2.6 million. Both numbers are internally consistent, and the invoice is consistent with the usage Stripe actually holds.

The gap is everything that was sent and never landed.

Where events go missing

A meter event is accepted asynchronously. A 2xx from /v1/billing/meter_events means Stripe took the request, not that the event was attributed to a customer and counted against a meter. Validation happens afterwards, and what it rejects arrives as a v1.billing.meter.error_report_triggered event carrying an error code, not as a failure your call site could have caught.

Four things commonly cause that rejection.

The event name has to match the meter exactly. event_name on the event is what routes it, an event name belongs to one meter and no more, and a name matching no meter raises v1.billing.meter.no_meter_found. One code path with an old name after a rename sends usage nowhere, silently, while every other path keeps working.

The payload has to carry the key the meter’s customer mapping expects. That key is customer_mapping.event_payload_key and it defaults to stripe_customer_id, holding a Stripe customer ID. An internal user ID sent into that field resolves to nobody and the usage belongs to nobody. The numeric key is configurable the same way through value_settings.event_payload_key and defaults to value.

The timestamp has to fall inside the window Stripe accepts, which is the past 35 calendar days and no more than 5 minutes into the future. Backfills after an outage are the usual casualty, since the events being replayed are by definition old.

And the customer has to be subscribed to a price tied to that meter. A metered price points at one through recurring.meter. Usage recorded against a meter that no subscription item prices is recorded correctly and billed at nothing.

Separately, a finalized invoice is not recalculated. Usage that lands after the period’s invoice has finalized does not join it, and Stripe will not reissue that invoice even for usage you explicitly cancel afterwards. Anything sent in a nightly batch is exposed at every period boundary.

Why the invoice looks fine

The invoice is arithmetically correct for the usage Stripe holds. Recomputing it from the meter summaries agrees to the unit. The only way to see the shortfall is to compare against a system Stripe cannot see, which is yours.

And it is an undercharge, so the customer will not raise it. The account consuming the most is the one being underbilled, and their incentive is silence.

Where Murphy stops

This failure has two halves and Murphy only reads one of them.

The half it catches is the events Stripe rejected. A customer it could not map, an event name that no longer matches a meter, a backfill that arrived outside the acceptance window: Stripe records every one of those and emits them, so they are facts inside Stripe and Murphy sees them the same day rather than at the end of the period. The same goes for usage that landed against a meter no subscription item is priced on, and for usage that arrived after the invoice finalized and is now stranded.

The half it does not catch is the events that never left your building. A dropped queue message, a worker that died mid-batch, a code path that silently stopped emitting: none of that reaches Stripe, so none of it reaches Murphy. Nothing that reads only your billing provider can tell you about a number your billing provider was never sent.

That half is yours, and the next section is how to do it. It is worth doing whether or not you ever run Murphy.

Confirming it

Take one customer and one closed billing period. Pull the meter event summaries with GET /v1/billing/meters/:id/event_summaries, which takes a customer and a start and end time, so it answers exactly the per-customer question you have. Put that next to your own count of events for the same window from your application’s log, and put both next to the quantity on the invoice line.

Three numbers that should be identical. Where the first two disagree, events were lost on the way in. Where the last two disagree, the usage was held but not priced.

Subscribe to v1.billing.meter.error_report_triggered and v1.billing.meter.no_meter_found while you are there. Rejected events are recorded, with codes like meter_event_no_customer_defined and timestamp_too_far_in_past, just not anywhere your code was looking.

Fixing it

Reconcile from your own records rather than trusting the acknowledgement. Keep a local log of what you sent, compare it per customer per period against what Stripe holds, and alert on the gap. That comparison is the only thing that catches an event which was accepted and then dropped.

Make the event name a shared constant rather than a string literal at each call site, and send the customer identifier your meter mapping actually expects. A meter is fixed once configured apart from its display name, so a rename is a new meter and a new price pointed at it, not an edit.

Send events continuously as well, so nothing is sitting in a batch when a period closes.

For periods already billed short, add the missing usage as a one-off invoice item on the customer’s next invoice rather than reopening a finalized one. Meter event adjustments only reach events sent in the last 24 hours, so they are not the tool for a gap you found a month later.

All of this is the Billing Meters path. Stripe now points new usage-based integrations at Metronome, which it acquired and describes as a Stripe product, and keeps Billing Meters supported for the ones already on it.

Unbilled usage is the plainest form of silent revenue leak: the account consuming the most is the one being underbilled, and it has no reason to raise it with you.