LatticeStripe.Webhook.Plug (LatticeStripe v0.2.0)

Copy Markdown View Source

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: Jason

Option 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.StripeHandler

Operation Modes

When a :handler module is configured, the plug dispatches the verified event to handler.handle_event/1 and sends the HTTP response:

  • Handler returns :ok or {:ok, _} → responds 200 ""

  • Handler returns :error or {:error, _} → responds 400 ""

  • Handler raises → exception propagates (not caught)

  • Handler returns anything else → raises RuntimeError

    plug 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")
end

Raw Body Requirement

Stripe signs the raw, unmodified request body. Most frameworks parse the body and discard the original bytes. Two solutions:

  1. Mount before Plug.Parsers using at: — the plug reads the body directly before parsers consume it.
  2. Use CacheBodyReader — configure Plug.Parsers to cache the raw bytes in conn.private[:raw_body]. See LatticeStripe.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") end

Configuration Options

  • :secret (required) — Webhook signing secret. See "Secret Resolution" above.
  • :handler — Module implementing LatticeStripe.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 return 405 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

call(conn, opts)

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

init(opts)

Validates and normalizes plug options at compile/mount time.

Raises NimbleOptions.ValidationError if options are invalid.