LatticeStripe.Billing.MeterErrorReport (LatticeStripe v2.0.0)

Copy Markdown View Source

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
end

The 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

t()

@type t() :: %LatticeStripe.Billing.MeterErrorReport{
  developer_message_summary: String.t() | nil,
  extra: map(),
  meter: String.t() | nil,
  reason: LatticeStripe.Billing.MeterErrorReport.Reason.t() | nil,
  validation_end: String.t() | nil,
  validation_start: String.t() | nil
}

Functions

from_event(event)

@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.

from_map(report)

@spec from_map(map() | t() | nil) :: t() | nil

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.