Phoenix Plug for Stripe webhook signature verification and event dispatch.
LatticeStripe.Webhook.Plug verifies the Stripe-Signature header on incoming
webhook requests, constructs a typed %LatticeStripe.Event{}, and either assigns
it to the connection (pass-through mode) or dispatches it to your handler module.
Mounting Strategies
Option A: Endpoint-level with at: path matching
Mount the plug in endpoint.ex before Plug.Parsers, using the at: option
to restrict it to a specific path. The plug intercepts matching requests and
passes everything else through.
# endpoint.ex
plug LatticeStripe.Webhook.Plug,
at: "/webhooks/stripe",
secret: System.fetch_env!("STRIPE_WEBHOOK_SECRET"),
handler: MyApp.StripeHandler
plug Plug.Parsers,
parsers: [:json],
pass: ["application/json"],
json_decoder: JasonOption B: Router-level via forward (with CacheBodyReader)
Mount after Plug.Parsers using Plug.Parsers + CacheBodyReader so the raw
body is preserved. Then forward in your router:
# endpoint.ex
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Jason,
body_reader: {LatticeStripe.Webhook.CacheBodyReader, :read_body, []}
# router.ex
forward "/webhooks/stripe", LatticeStripe.Webhook.Plug,
secret: System.fetch_env!("STRIPE_WEBHOOK_SECRET"),
handler: MyApp.StripeHandlerOperation Modes
Handler mode (recommended)
When a :handler module is configured, the plug dispatches the verified event
to handler.handle_event/1 and sends the HTTP response:
Handler returns
:okor{:ok, _}→ responds200 ""Handler returns
:erroror{:error, _}→ responds400 ""Handler raises → exception propagates (not caught)
Handler returns anything else → raises
RuntimeErrorplug LatticeStripe.Webhook.Plug,
secret: "whsec_...", handler: MyApp.StripeHandler
Pass-through mode
Without a :handler, the plug assigns the verified event to
conn.assigns.stripe_event and passes the connection to the next plug or
controller. Your controller reads the event and sends its own response.
plug LatticeStripe.Webhook.Plug,
secret: "whsec_...",
at: "/webhooks/stripe"
# In your controller:
def webhook(conn, _params) do
event = conn.assigns.stripe_event
handle_event(event)
send_resp(conn, 200, "ok")
endRaw Body Requirement
Stripe signs the raw, unmodified request body. Most frameworks parse the body and discard the original bytes. Two solutions:
- Mount before
Plug.Parsersusingat:— the plug reads the body directly before parsers consume it. - Use
CacheBodyReader— configurePlug.Parsersto cache the raw bytes inconn.private[:raw_body]. SeeLatticeStripe.Webhook.CacheBodyReader.
Secret Resolution
The :secret option supports runtime resolution to avoid compile-time secrets:
# Static string (simple)
secret: "whsec_..."
# List of strings (secret rotation — any match succeeds)
secret: ["whsec_old...", "whsec_new..."]
# MFA tuple (resolved at call time)
secret: {MyApp.Config, :stripe_webhook_secret, []}
# Zero-arity function (resolved at call time)
secret: fn -> System.fetch_env!("STRIPE_WEBHOOK_SECRET") endConfiguration Options
:secret(required) — Webhook signing secret. See "Secret Resolution" above.:handler— Module implementingLatticeStripe.Webhook.Handler. If omitted, runs in pass-through mode.:at— Mount path (e.g.,"/webhooks/stripe"). When set, the plug only processes requests matching this path; other paths pass through. Non-POST requests to this path return405 Method Not Allowed.:tolerance— Maximum age of the webhook timestamp in seconds (default: 300).
Summary
Functions
Processes the connection through webhook verification.
Validates and normalizes plug options at compile/mount time.
Functions
Processes the connection through webhook verification.
Routes based on HTTP method and path (when at: is configured):
- POST to matching path (or any path when
at:is nil) → verify and handle - Non-POST to matching path → 405 Method Not Allowed
- Any request to non-matching path → pass through
Validates and normalizes plug options at compile/mount time.
Raises NimbleOptions.ValidationError if options are invalid.