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}")
endImportant: 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 eventdataembedded, Unix-integercreated). Useconstruct_event/4and pattern-matchLatticeStripe.Event.t(). This is what/v1/webhook_endpointsdelivers and what every adopter usingLatticeStripe.Webhook.Plugreceives today.Thin events (the v2 event-destinations path —
object: "v2.core.event", no embeddeddata, ISO 8601 stringcreated,related_objectreference to the underlying resource). Useparse_event_notification/4and pattern-matchLatticeStripe.EventNotification.t(). The notification carries only metadata + arelated_objectpointer; adopters fetch the full%Event{}viafetch_event/3and the underlying resource viafetch_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.
Like fetch_event/3 but raises on failure.
Retrieves the typed underlying resource referenced by a thin-event notification's
related_object.
Like fetch_related_object/3 but raises on failure.
Generates a Stripe-compatible webhook signature header for testing.
Verifies a Stripe thin-event signature and decodes the payload into an %EventNotification{}.
Like parse_event_notification/4 but raises SignatureVerificationError on failure.
Verifies a Stripe webhook signature header against a payload and secret.
Like verify_signature/4 but raises SignatureVerificationError on failure.
Types
Functions
@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:
- Verifies the
Stripe-Signatureheader using HMAC-SHA256 - Checks the timestamp is within the tolerance window (replay attack protection)
- Decodes the JSON payload into a
%LatticeStripe.Event{}struct
Parameters
payload- The raw, unmodified request body stringsig_header- The value of theStripe-Signatureheader (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). Set0to disable the staleness check (testing only — see WEBFIX-01 CHANGELOG entry and the inline comment oncheck_tolerance/2for the decision context).
Returns
{:ok, %Event{}}on success{:error, verify_error()}on failure — seeverify_error/0
@spec construct_event!(String.t(), String.t() | nil, secret(), keyword()) :: LatticeStripe.Event.t()
Like construct_event/4 but raises SignatureVerificationError on failure.
Returns
%Event{}on success- Raises
LatticeStripe.Webhook.SignatureVerificationErroron failure
@spec fetch_event( LatticeStripe.Client.t(), LatticeStripe.EventNotification.t() | String.t() ) :: {:ok, LatticeStripe.Event.t()} | {:error, LatticeStripe.Error.t() | :no_event_id}
@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{}structnotification_or_id- An%EventNotification{}(whoseidis extracted) OR a bare event-ID string (e.g.,"evt_test_...")opts- Per-request overrides::api_version,:idempotency_key, etc. (forwarded toClient.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")
@spec fetch_event!( LatticeStripe.Client.t(), LatticeStripe.EventNotification.t() | String.t(), keyword() ) :: LatticeStripe.Event.t()
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).
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 stringsecret- The webhook signing secretopts- 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)
@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 stringsig_header- The value of theStripe-Signatureheader (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). Set0to disable the staleness check (testing only — see WEBFIX-01 CHANGELOG entry).
Returns
{:ok, %EventNotification{}}on success — typed struct exposingid,type,created,context,livemode, andrelated_object.{:error, :missing_header}— noStripe-Signatureheader 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
@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
%EventNotification{}on success- Raises
LatticeStripe.Webhook.SignatureVerificationErroron failure
@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 stringsig_header- TheStripe-Signatureheader valuesecret- Signing secret or list of secrets (for rotation)opts- Options::tolerance- max timestamp age in seconds (default: 300)
Returns
{:ok, timestamp}wheretimestampis 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
Like verify_signature/4 but raises SignatureVerificationError on failure.
Returns
timestamp(integer) on success- Raises
LatticeStripe.Webhook.SignatureVerificationErroron failure