LatticeStripe.Webhook (LatticeStripe v1.7.13)

Copy Markdown View Source

Stripe webhook signature verification and event construction.

LatticeStripe.Webhook provides pure-functional HMAC-SHA256 signature verification for incoming Stripe webhook payloads. It is designed to be used in a Plug pipeline or any web framework — it has no Plug dependency itself.

Usage

# In a Plug or controller action, after reading the raw body:
raw_body = conn.assigns[:raw_body]
sig_header = Plug.Conn.get_req_header(conn, "stripe-signature") |> List.first()
secret = Application.fetch_env!(:my_app, :stripe_webhook_secret)

case LatticeStripe.Webhook.construct_event(raw_body, sig_header, secret) do
  {:ok, event} ->
    handle_event(event)
    send_resp(conn, 200, "ok")

  {:error, :missing_header} ->
    send_resp(conn, 400, "Missing Stripe-Signature header")

  {:error, :timestamp_expired} ->
    send_resp(conn, 400, "Webhook timestamp too old")

  {:error, reason} ->
    send_resp(conn, 400, "Signature verification failed: #{reason}")
end

Important: Raw Body Requirement

Stripe signs the raw, unmodified request body. Most web frameworks parse the body and discard the original bytes. You must configure your framework to preserve the raw body before calling these functions. See the LatticeStripe Plug documentation for a ready-made solution.

Replay Attack Protection

By default, verify_signature/3 rejects webhooks with a timestamp older than 300 seconds (5 minutes). Override with tolerance: seconds in opts.

Multiple Secrets (Secret Rotation)

Pass a list of secrets to verify against any of them. Useful during Stripe webhook secret rotation — the new and old secret both work until rotation completes.

Webhook.verify_signature(payload, header, [old_secret, new_secret])

Snapshot events vs thin events: when to use which

Stripe ships two webhook payload shapes and LatticeStripe exposes a distinct entry point for each:

  • Snapshot events (the classic v1 webhook path — object: "event", full event data embedded, Unix-integer created). Use construct_event/4 and pattern-match LatticeStripe.Event.t(). This is what /v1/webhook_endpoints delivers and what every adopter using LatticeStripe.Webhook.Plug receives today.

  • Thin events (the v2 event-destinations path — object: "v2.core.event", no embedded data, ISO 8601 string created, related_object reference to the underlying resource). Use parse_event_notification/4 and pattern-match LatticeStripe.EventNotification.t(). The notification carries only metadata + a related_object pointer; adopters fetch the full %Event{} via fetch_event/3 and the underlying resource via fetch_related_object/3 (both shipped in this same v1.5 milestone).

Calling the wrong entry point on a payload will silently produce a mostly-nil struct (the JSON keys don't overlap between the two wire shapes). Route based on which webhook endpoint Stripe is calling — parse_event_notification/4 is for /v2/event-destinations traffic, construct_event/4 is for everything else.

Stripe API Reference

See the Stripe Webhooks documentation for the full webhook reference, event catalog, and delivery guarantees.

Summary

Functions

Verifies a Stripe webhook signature and, if valid, constructs a typed %Event{}.

Like construct_event/4 but raises SignatureVerificationError on failure.

Retrieves the full v2 Event.t() for a thin-event notification.

Retrieves the typed underlying resource referenced by a thin-event notification's related_object.

Generates a Stripe-compatible webhook signature header for testing.

Verifies a Stripe thin-event signature and decodes the payload into an %EventNotification{}.

Verifies a Stripe webhook signature header against a payload and secret.

Like verify_signature/4 but raises SignatureVerificationError on failure.

Types

secret()

@type secret() :: String.t() | [String.t(), ...]

verify_error()

@type verify_error() ::
  :missing_header
  | :invalid_header
  | :no_matching_signature
  | :timestamp_expired

Functions

construct_event(payload, sig_header, secret, opts \\ [])

@spec construct_event(String.t(), String.t() | nil, secret(), keyword()) ::
  {:ok, LatticeStripe.Event.t()} | {:error, verify_error()}

Verifies a Stripe webhook signature and, if valid, constructs a typed %Event{}.

This is the primary function for handling incoming webhooks. It:

  1. Verifies the Stripe-Signature header using HMAC-SHA256
  2. Checks the timestamp is within the tolerance window (replay attack protection)
  3. Decodes the JSON payload into a %LatticeStripe.Event{} struct

Parameters

  • payload - The raw, unmodified request body string
  • sig_header - The value of the Stripe-Signature header (e.g., "t=1234,v1=abc...")
  • secret - Your webhook signing secret (string or list of strings for rotation)
  • opts - Options:
    • :tolerance - max age in seconds (default: 300). Set 0 to disable the staleness check (testing only — see WEBFIX-01 CHANGELOG entry and the inline comment on check_tolerance/2 for the decision context).

Returns

  • {:ok, %Event{}} on success
  • {:error, verify_error()} on failure — see verify_error/0

construct_event!(payload, sig_header, secret, opts \\ [])

@spec construct_event!(String.t(), String.t() | nil, secret(), keyword()) ::
  LatticeStripe.Event.t()

Like construct_event/4 but raises SignatureVerificationError on failure.

Returns

fetch_event(client, notification_or_id)

@spec fetch_event(
  LatticeStripe.Client.t(),
  LatticeStripe.EventNotification.t() | String.t()
) ::
  {:ok, LatticeStripe.Event.t()}
  | {:error, LatticeStripe.Error.t() | :no_event_id}

fetch_event(client, id, opts)

@spec fetch_event(
  LatticeStripe.Client.t(),
  LatticeStripe.EventNotification.t() | String.t(),
  keyword()
) ::
  {:ok, LatticeStripe.Event.t()}
  | {:error, LatticeStripe.Error.t() | :no_event_id}

Retrieves the full v2 Event.t() for a thin-event notification.

Issues GET /v2/core/events/{id} and decodes the response via LatticeStripe.Event.from_map/1. Adopters call this after parse_event_notification/4 returns an %EventNotification{} and they need the authoritative event state (e.g., for snapshot-style v2 events that have no related_object).

Accepts either a %LatticeStripe.EventNotification{} (the id is extracted) or a bare String.t() event ID. The %Client{} is passed explicitly per Phase 47 D-04 — the notification struct stays pure serializable data with no embedded credential material, safe for ETS / logs / distributed Erlang.

Snapshot vs thin-event Event retrieval

For snapshot/v1 event IDs (returned by construct_event/4 on the legacy /v1/webhooks path), use LatticeStripe.Event.retrieve/3 instead — it hits /v1/events/{id} which differs from the v2 endpoint and returns a different payload shape (no related_object, integer created).

created wire-format note

On a %Event{} fetched via fetch_event/3 from /v2/core/events/{id}, the created field arrives as an ISO 8601 string (e.g., "2026-03-09T13:00:28.435Z") — Stripe's wire format for v2 events. Snapshot v1 events delivered through construct_event/4 (and retrieved via Event.retrieve/3) carry an integer Unix timestamp instead. The Event.@type t() :created typespec is currently integer() | nil; this asymmetry is documented and will be widened in a future patch (see Phase 47 Open Question 2). The runtime behavior is correct either way — Event.from_map/1 is infallible-deserialize and Dialyzer is not in use.

Parameters

  • client - A %LatticeStripe.Client{} struct
  • notification_or_id - An %EventNotification{} (whose id is extracted) OR a bare event-ID string (e.g., "evt_test_...")
  • opts - Per-request overrides: :api_version, :idempotency_key, etc. (forwarded to Client.request/2)

Returns

  • {:ok, %Event{}} on success
  • {:error, %LatticeStripe.Error{}} on HTTP failure
  • {:error, :no_event_id} when called with %EventNotification{id: nil} (defensive — does NOT issue an HTTP request)

Examples

# From a parsed notification:
{:ok, notif} = Webhook.parse_event_notification(payload, sig_header, secret)
{:ok, %Event{} = event} = Webhook.fetch_event(client, notif)

# From a bare ID:
{:ok, %Event{}} = Webhook.fetch_event(client, "evt_test_65UIRNU7G1XbhCfOim416TgmEI4ASQ3jHxXt8RFwXoeVwO")

fetch_event!(client, notification_or_id, opts \\ [])

Like fetch_event/3 but raises on failure.

Returns %Event{} on success. Raises LatticeStripe.Error on HTTP failure or on {:error, :no_event_id} (the typed atom is collapsed into a %LatticeStripe.Error{type: :invalid_request_error} for consistent exception semantics).

generate_test_signature(payload, secret, opts \\ [])

@spec generate_test_signature(String.t(), String.t(), keyword()) :: String.t()

Generates a Stripe-compatible webhook signature header for testing.

Use this in tests to produce a Stripe-Signature header that passes verify_signature/3. This avoids hard-coding computed HMAC values in tests and correctly simulates what Stripe's servers send.

Parameters

  • payload - The JSON-encoded payload string
  • secret - The webhook signing secret
  • opts - Options:
    • :timestamp - Unix timestamp integer to embed (default: current time)

Returns

A Stripe-Signature header value string, e.g. "t=1680000000,v1=abc123...".

Example

header = LatticeStripe.Webhook.generate_test_signature(payload, secret)
{:ok, event} = LatticeStripe.Webhook.construct_event(payload, header, secret)

parse_event_notification(payload, sig_header, secret, opts \\ [])

@spec parse_event_notification(String.t(), String.t() | nil, secret(), keyword()) ::
  {:ok, LatticeStripe.EventNotification.t()} | {:error, verify_error()}

Verifies a Stripe thin-event signature and decodes the payload into an %EventNotification{}.

This is the thin-event (/v2/events) counterpart to construct_event/4. It uses the same HMAC-SHA256 verification primitive (verify_signature/4) — same wire format for Stripe-Signature, same tolerance machinery, same error atoms — but decodes the verified payload into LatticeStripe.EventNotification.t() instead of LatticeStripe.Event.t().

Thin events are delivered by Stripe /v2/event-destinations endpoints. The notification carries only metadata + a related_object reference; adopters fetch the full event with fetch_event/3 and the underlying typed resource with fetch_related_object/3.

For snapshot/v1 webhook payloads (object: "event", full data embedded), use construct_event/4 instead. See the module docstring "Snapshot events vs thin events: when to use which" for routing guidance.

Parameters

  • payload - The raw, unmodified request body string
  • sig_header - The value of the Stripe-Signature header (e.g., "t=1234,v1=abc...")
  • secret - Your webhook signing secret (string or list of strings for rotation)
  • opts - Options:
    • :tolerance - max age in seconds (default: 300). Set 0 to disable the staleness check (testing only — see WEBFIX-01 CHANGELOG entry).

Returns

  • {:ok, %EventNotification{}} on success — typed struct exposing id, type, created, context, livemode, and related_object.
  • {:error, :missing_header} — no Stripe-Signature header was provided
  • {:error, :invalid_header} — header is present but malformed
  • {:error, :no_matching_signature} — HMAC doesn't match any provided secret
  • {:error, :timestamp_expired} — timestamp older than tolerance

Identical 4-atom error set as construct_event/4 (the verify boundary is shared).

Example

case LatticeStripe.Webhook.parse_event_notification(payload, sig_header, secret) do
  {:ok, %LatticeStripe.EventNotification{
     type: "v2.core.account.updated",
     related_object: %LatticeStripe.EventNotification.RelatedObject{
       type: "v2.core.account",
       id: account_id
     }} = notif} ->
    handle_account_update(notif, account_id)

  {:ok, %LatticeStripe.EventNotification{related_object: nil} = notif} ->
    # Snapshot-style v2 event (no related object) — fetch full event for context
    {:ok, %LatticeStripe.Event{} = event} =
      LatticeStripe.Webhook.fetch_event(client, notif)

    handle_full_event(event)

  {:error, :timestamp_expired} ->
    Logger.warning("Stripe webhook expired; check clock skew")

  {:error, reason} ->
    Logger.error("Stripe webhook verification failed: #{inspect(reason)}")
end

parse_event_notification!(payload, sig_header, secret, opts \\ [])

@spec parse_event_notification!(String.t(), String.t() | nil, secret(), keyword()) ::
  LatticeStripe.EventNotification.t()

Like parse_event_notification/4 but raises SignatureVerificationError on failure.

Parity with construct_event!/4: returns the typed notification struct on success, raises LatticeStripe.Webhook.SignatureVerificationError (carrying the :reason atom from the 4-atom verify error set) on any verify failure.

Returns

verify_signature(payload, sig_header, secret, opts \\ [])

@spec verify_signature(String.t(), String.t() | nil, secret(), keyword()) ::
  {:ok, integer()} | {:error, verify_error()}

Verifies a Stripe webhook signature header against a payload and secret.

Performs timing-safe HMAC-SHA256 comparison via Plug.Crypto.secure_compare/2. Returns the parsed timestamp integer on success (useful for logging).

Parameters

  • payload - The raw request body string
  • sig_header - The Stripe-Signature header value
  • secret - Signing secret or list of secrets (for rotation)
  • opts - Options:
    • :tolerance - max timestamp age in seconds (default: 300)

Returns

  • {:ok, timestamp} where timestamp is a Unix integer on success
  • {:error, :missing_header} — no header provided
  • {:error, :invalid_header} — header is present but malformed
  • {:error, :timestamp_expired} — timestamp older than tolerance
  • {:error, :no_matching_signature} — HMAC doesn't match any provided secret

verify_signature!(payload, sig_header, secret, opts \\ [])

@spec verify_signature!(String.t(), String.t() | nil, secret(), keyword()) ::
  integer()

Like verify_signature/4 but raises SignatureVerificationError on failure.

Returns