The typed data payload of a v1.billing.meter.error_report_triggered event:
which meter rejected usage, over which window, with which error codes, and —
most importantly — the idempotency key of every failing write.
Async meter event ingestion answers 202 and validates later. When validation
fails, Stripe reports it here, minutes after the fact, on an event that names
no meter event and carries no usage payload. This module is the diagnose half
of the metering surface: it turns that report into something you can join
against your own records.
You must fetch the event first — the webhook body does not contain data
This is the single thing adopters get wrong. v1.billing.meter.* events are
v2 thin events. The body Stripe POSTs to your endpoint carries only
id, type, created, related_object and a reason — it has no data
member at all. data is a fetched attribute: you must re-request the
versioned event over your authenticated channel to obtain it.
That re-fetch is also what makes the payload trustworthy. The delivered body
is attacker-reachable; the fetched event is not. from_event/1 therefore
accepts only a %LatticeStripe.Event{} — which is precisely what
LatticeStripe.Webhook.fetch_event/3 returns — and never a raw request body.
All seven official Stripe SDKs encode this structurally, by giving their
event-notification classes no data member whatsoever. Treat a notification
as an announcement, not as a payload.
alias LatticeStripe.{EventNotification, Webhook}
alias LatticeStripe.Billing.MeterErrorReport
alias LatticeStripe.Billing.MeterErrorReport.{ErrorType, SampleError}
def handle_notification(
%EventNotification{type: "v1.billing.meter.error_report_triggered"} = notif,
client
) do
# `data` is a fetched attribute — the webhook body does not contain it.
{:ok, event} = Webhook.fetch_event(client, notif)
report = MeterErrorReport.from_event(event)
for %ErrorType{code: code, sample_errors: samples} <- report.reason.error_types,
%SampleError{request_identifier: key, error_message: msg} <- samples do
MyApp.Billing.MeterEvents.mark_failed_by_idempotency_key(key, code, msg)
end
endThe webhook object registry cannot reach this payload
LatticeStripe's internal webhook object registry deserializes a payload by
matching a map that carries an "object" key. This payload has none — it is
event data, not an object — so it falls straight through the registry's
dispatch and comes back as a raw map. There is no registry entry
for it and there cannot be one: a registry row would be a dead key that a
later contributor would assume works. Call from_event/1 (or from_map/1)
explicitly. This is a structural fact about the wire format, not a gap.
Why this module is flat while its parts nest
MeterErrorReport sits at depth 2 and its Reason, ErrorType and
SampleError sit at depth 3. That is one rule applied twice.
Depth 2 is for things addressed directly. LatticeStripe.Billing.MeterEventSummary
is flat because it is a resource. This module is flat because it is not a
resource at all — no endpoint, no id, no object string, nothing to
address. Both are top-level concepts a caller reaches for by name.
Depth 3 is for value objects that own no request and are only ever reached through their parent. The three sub-modules issue no HTTP call and construct no request; they exist to give every level of the payload a name instead of leaving nested raw maps behind.
The window: validation_start and validation_end
These delimit the window of usage the report covers — the only thing that
tells an operator which usage was rejected. They pair directly with the
window a reconciler reads through
LatticeStripe.Billing.MeterEventSummary.list/4: a report covering
[validation_start, validation_end) explains a hole in the summaries over the
same span.
NOTE: these are ISO 8601 strings, not Unix integers
validation_start and validation_end arrive as RFC3339 strings like
"2024-09-26T17:46:10.000Z". This is a legitimate type asymmetry against
every v1 object in this library, whose timestamps are Unix seconds
(LatticeStripe.Billing.MeterEventSummary's start_time and end_time
among them). Stripe ships the v2 wire value verbatim and LatticeStripe
preserves it, matching the LatticeStripe.EventNotification created
precedent. Adopters who need a DateTime should call
DateTime.from_iso8601/1 themselves.
One official Stripe SDK types v2 timestamps as integers. That is wrong for this payload, and it is deliberately not copied here.
This struct will put idempotency keys in your logs
There is no custom Inspect implementation here, deliberately. A bare
inspect(report) — or a Logger.error("…: #{inspect(report)}") — will emit
every request_identifier the report carries, and those are idempotency keys.
LatticeStripe.EventNotification hides its :reason field for exactly this
reason, and that precedent deliberately does not transfer: there, the key
is incidental metadata; here, the key is the diagnostic payload, and
hiding it would defeat this module's entire purpose. You are told so you can
decide what reaches your logs.
Design: no list, no retrieve, no create
This module ships no read or write verbs, and that is not a gap. Stripe
serves this payload from no endpoint at all — there is no
/v1/billing/meter_error_reports collection, no billing.meter_error_report
object, and nothing to retrieve one from. The only way to obtain one is to
receive the event and fetch it.
Nor does it ship grouping or counting helpers: Stripe already groups by
error_types and supplies error_count at both levels. See
LatticeStripe.Billing.MeterErrorReport.Reason.
Summary
Functions
Decode a fetched %LatticeStripe.Event{} into a %MeterErrorReport{}.
Decode the raw data map into a %MeterErrorReport{}.
Types
Functions
@spec from_event(LatticeStripe.Event.t()) :: t()
Decode a fetched %LatticeStripe.Event{} into a %MeterErrorReport{}.
This is the primary constructor. It is the only one that can populate
:meter, because the meter id lives in the event's related_object and never
in data.
{:ok, event} = LatticeStripe.Webhook.fetch_event(client, notification)
report = LatticeStripe.Billing.MeterErrorReport.from_event(event)
report.meter
#=> "mtr_test_61RCjiqdTDC91zgip41IqPCzPnxqqSVc"An event carrying no related_object decodes fine and leaves :meter nil.
That is the shape of the sibling v1.billing.meter.no_meter_found event,
which ships this payload byte-for-byte with no related object at all —
reasonably enough, since the whole point of that event is that no meter was
found.
Raises ArgumentError if the event carries no data. That is the signature
of passing a delivered webhook body rather than a fetched event; see the
module documentation.
Decode the raw data map into a %MeterErrorReport{}.
The low-level constructor. Prefer from_event/1, which is the only one that
can fill in :meter.
:meter is always nil here, and that is an asserted contract rather than
an oversight: data never names the meter, so this function structurally
cannot know it.
Idempotent: applied to an already-decoded struct it returns it unchanged, and
from_map(nil) returns nil. A nil reason (it is nullable on the wire)
decodes to nil. Unknown top-level keys land in :extra.