Production webhook pain rarely starts in your business logic — it starts at the HTTP boundary: wrong secret, mutated body, stale timestamp, or a handler that returns 5xx and triggers Stripe retries. This guide is the symptom spine for diagnosis; build setup stays in Webhooks and Webhooks: Thin Events.

Debug from the delivery boundary inward: Dashboard → HTTP status → signature → payload shape → dispatch.

Your app starts work. Webhooks confirm reality.

Add LatticeStripe to your project:

{:lattice_stripe, "~> 1.7"}

Start here — snapshot vs thin

Pick the surface that matches your endpoint. Using the wrong parser produces confusing "empty" structs — not a Stripe outage.

Endpoint surfacePayload shapeFirst SDK callWrong symptom
Snapshot /v1 (Plug)Full event JSONWebhook.Plug → handlerThin controller calls construct_event on v2 body → mostly-nil fields
Thin /v2/events{id, type, related_object}Webhook.parse_event_notification/4Snapshot handler expects nested data.object → decode errors or nil object
After verify (thin)Notification onlyfetch_event/3 or fetch_related_object/3Acting on notification JSON without fetch → stale or incomplete state

If verification passes but fields look wrong, you are likely on the wrong guide: switch to Webhooks: Thin Events or Webhooks.

Symptom index

SymptomFirst checksDeep section
HTTP 400 on webhookSecret, raw body, Plug order, clock skewSignature verification failures
HTTP 500 after 400s fixedHandler exceptions, fetch failuresFetch-after-verify debugging
Duplicate side effectsIdempotency on event.id, not resource stateDelivery / replay
Events never arriveDashboard endpoint URL, firewall, wrong mode keyDelivery / replay
Slow 5xx retry stormHandler timeout, enqueue-after-2xx missingCommon dispatch patterns
Thin payload, nil objectWrong entry point for /v2Start here table

Signature verification failures

Work through this list before changing application logic:

  1. Endpoint secret mismatch — CLI stripe listen prints a signing secret; Dashboard endpoint has a different one. The secret in your app must match the sender.
  2. Raw body missing — Verification needs conn.private[:raw_body] (or equivalent). If Plug.Parsers ran first, signatures will never match. See Webhooks.
  3. Plug orderLatticeStripe.Webhook.Plug must be before Plug.Parsers.
  4. Clock skew:timestamp_expired means tolerance exceeded; sync NTP on nodes.
  5. Header present and well-formed:missing_header, :invalid_header before crypto.
  6. Secret rotation — pass a list of secrets to the plug during rotation windows.

Verify error vocabulary

LatticeStripe returns tagged errors (no raise) for verify failures on the thin path; snapshot Webhook.Plug uses the same atoms:

ErrorTypical causeFix
:missing_headerProxy stripped Stripe-SignatureCheck load balancer / CDN config
:invalid_headerMalformed header stringLog header length only, not value
:no_matching_signatureWrong secret or mutated bodySecret match + raw body invariant
:timestamp_expiredClock drift or replay windowNTP + check tolerance config

For thin controllers, Webhook.parse_event_notification/4 verifies before JSON decode. Payload shape errors (Jason.DecodeError) raise after verify — handle both paths in your receive/2 action.

Snapshot vs thin verify entry points

# Snapshot — plug verifies inside the pipeline
plug LatticeStripe.Webhook.Plug, at: "/webhooks/stripe", ...

# Thin — controller calls parse explicitly
{:ok, notif} = Webhook.parse_event_notification(raw_body, sig_header, secret)

Wrong entry point symptom: you call snapshot construct_event helpers on a thin body and get a struct with mostly nil fields — switch guides, do not patch around nils.

Log event type and event.id only — not full payloads. Never use IO.inspect on webhook bodies in production.

Fetch-after-verify debugging

Thin events require a fetch after verify. Snapshot handlers may still call retrieve for authoritative state. When fetch fails:

{:error, %LatticeStripe.Error{request_id: request_id} = err} ->
  Logger.error("stripe fetch failed", request_id: request_id, code: err.code)

Fetch paths

# Full event object (either surface after thin verify)
{:ok, event} = Webhook.fetch_event(client, notif)

# Related resource when related_object is present
{:ok, obj} = Webhook.fetch_related_object(client, notif)
  • 429 / rate limits — back off; Stripe retries webhooks but your fetch loop can amplify load.
  • Race with your own writes — key idempotency on event.id, not on "payment already processed" resource flags alone; duplicate deliveries are normal.
  • :no_related_object — use fetch_event/3 instead of fetch_related_object/3.
  • Unknown type{:error, {:unknown_object_type, type}} means dispatch table needs an explicit branch; do not silently ignore.

Idempotency sketch

case MyApp.IdempotentEvents.claim(event.id) do
  :ok -> process(event)
  :already_processed -> :ok
end

Store claims by Stripe event.id (or thin notification id), not by charge or PI id alone — the same resource can emit multiple event types.

See Error Handling for retry classification and support paths.

Delivery, replay, and Stripe retries

Stripe delivers at-least-once. Automatic retries follow HTTP status: 2xx acks, non-2xx schedules retry with backoff. Your handler must be idempotent.

HTTP status decision table

Your responseStripe behaviorOperator note
2xxDelivery marked succeededWork may still fail async after ack
4xx (except 429)Generally no retryUse for verify misconfig — fix secret/body
5xx / timeoutRetries with backoffCan look like "duplicate" events — idempotency required
429RetryRate limit your handler if self-inflicted

Local replay: stripe events resend <event_id> re-sends the same event id — useful for reproducing handler bugs without waiting for live traffic.

Dashboard "Resend" — same semantics: duplicate event.id, not a new logical event. Do not treat Resend as "generate a new payment event."

Missing events checklist

  1. Dashboard → Developers → Webhooks → select endpoint → Event deliveries
  2. Confirm test vs live mode matches the key on the sending Stripe account
  3. curl your endpoint URL from outside your VPC (TLS, cert, path)
  4. Check if endpoint was disabled after repeated failures
  5. For thin destinations, confirm /v2/events subscription includes the event types you expect

Missing events: confirm live vs test mode keys, endpoint URL reachable from Stripe, and Dashboard delivery logs before assuming SDK bugs.

Common dispatch patterns

Debug lens only — full controller spines live in canonical guides.

PatternRiskWhat to check
2xx after enqueueWork lost if queue diesReturn 2xx only after durable write or accept replay cost
5xx on slow workRetry stormMove work to Task/Oban; return 2xx when safely queued
Connect routingWrong tenant updatedMatch event.account or thin context before dispatch
Always 200 on verify failSilent data lossReturn 4xx on verify failure so Stripe surfaces misconfig

Keep dispatch modules under ~15 lines in logs — trace event.type, event.id, and outcome status, not PII fields.

Observability checklist

Ensure these events reach your metrics or APM:

  • [:lattice_stripe, :webhook, :verify, :stop] — success/failure and timing
  • [:lattice_stripe, :request, :stop] — include request_id metadata on failures

Correlate Dashboard delivery attempt timestamps with verify stop events. If verify metrics are green but business state is wrong, the bug is post-verify dispatch.

Minimum fields to log per delivery

FieldWhy
event.idIdempotency and Dashboard correlation
event.typeDispatch routing
HTTP status you returnedExplains retry behavior
request_id (on fetch errors)Stripe support escalation

Do not log full data.object blobs — card, bank, and PII fields belong in Stripe's Dashboard, not your log index.

Dashboard ↔ app correlation workflow

  1. Copy event.id from Dashboard delivery detail
  2. Search app logs for that id (or your idempotency claim row)
  3. If absent, verify failed before dispatch — check verify telemetry
  4. If present with 2xx but wrong state, inspect fetch + dispatch logs for that id

See Telemetry for handler attachment examples.

charge.* events

Charge is the result record of a payment attempt, not payment initiation. Use PaymentIntent for payment flows; use Charge to read/reconcile existing charges. Full API: LatticeStripe.Charge moduledoc (no separate Charge guide in v1.7).

For charge.succeeded and siblings:

  • Prefer following payment_intent on the event when present — PI is the flow spine.
  • Use LatticeStripe.Charge.retrieve/3 when you need charge-specific fields (balance_transaction, application_fee_amount, etc.).
  • Anti-pattern: do not use LatticeStripe.Charge.search/3 to confirm a payment that just succeeded — search index lags; use retrieve or PaymentIntent state.

See also