Packages

Complete Stripe SDK for Elixir with parity to the official Ruby SDK. V1+V2 coverage (193 services, 320 resource structs, 525 documented params). Per-event modules, Finch HTTP/2, RustyJSON, automatic retries, OAuth, webhooks, telemetry, per-client config, streaming pagination.

Current section

Files

Jump to
tiger_stripe lib stripe webhook.ex
Raw

lib/stripe/webhook.ex

defmodule Stripe.Webhook do
@moduledoc """
Webhook signature verification and event construction.
## Usage
case Stripe.Webhook.construct_event(payload, sig_header, secret) do
{:ok, event} -> handle_event(event)
{:error, error} -> send_resp(conn, 400, error.message)
end
"""
alias Stripe.{Deserializer, Error}
@default_tolerance 300
@doc """
Verify a webhook signature and construct a typed event struct.
## Parameters
* `payload` - Raw request body (binary string, NOT parsed JSON)
* `sig_header` - Value of the `Stripe-Signature` header
* `secret` - Webhook endpoint signing secret (`whsec_...`)
## Options
* `:tolerance` - Maximum age of the event in seconds (default: 300)
"""
@spec construct_event(binary(), String.t(), String.t(), keyword()) ::
{:ok, struct() | map()} | {:error, Error.t()}
def construct_event(payload, sig_header, secret, opts \\ []) do
with :ok <- verify_header(payload, sig_header, secret, opts) do
case JSON.decode(payload) do
{:ok, data} -> {:ok, Deserializer.cast(data)}
{:error, _} -> {:error, Error.signature_verification_error("Invalid JSON payload")}
end
end
end
@doc """
Verify a webhook signature header without constructing the event.
Returns `:ok` or `{:error, %Error{}}`.
"""
@spec verify_header(binary(), String.t(), String.t(), keyword()) ::
:ok | {:error, Error.t()}
def verify_header(payload, sig_header, secret, opts \\ []) do
tolerance = Keyword.get(opts, :tolerance, @default_tolerance)
# Match Ruby order: parse → verify signature → check timestamp
with {:ok, timestamp, signatures} <- parse_header(sig_header),
:ok <- verify_signature(payload, timestamp, secret, signatures) do
verify_timestamp(timestamp, tolerance)
end
end
@doc """
Compute the expected signature for a payload.
"""
@spec compute_signature(integer(), binary(), String.t()) :: String.t()
def compute_signature(timestamp, payload, secret) do
signed_payload = "#{timestamp}.#{payload}"
:crypto.mac(:hmac, :sha256, secret, signed_payload)
|> Base.encode16(case: :lower)
end
# -- Private ----------------------------------------------------------------
defp parse_header(nil) do
{:error, Error.signature_verification_error("No Stripe-Signature header present")}
end
defp parse_header(header) when is_binary(header) do
parts =
header
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.map(fn part ->
case String.split(part, "=", parts: 2) do
[key, value] -> {key, value}
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
timestamp =
case List.keyfind(parts, "t", 0) do
{"t", ts} -> String.to_integer(ts)
nil -> nil
end
signatures =
parts
|> Enum.filter(fn {k, _} -> k == "v1" end)
|> Enum.map(fn {_, v} -> v end)
cond do
is_nil(timestamp) ->
{:error, Error.signature_verification_error("Unable to extract timestamp from header")}
signatures == [] ->
{:error, Error.signature_verification_error("No v1 signatures found in header")}
true ->
{:ok, timestamp, signatures}
end
end
defp verify_timestamp(timestamp, tolerance) do
now = System.system_time(:second)
age = now - timestamp
if age > tolerance do
{:error,
Error.signature_verification_error(
"Timestamp outside the tolerance zone (#{age}s > #{tolerance}s)"
)}
else
:ok
end
end
defp verify_signature(payload, timestamp, secret, signatures) do
expected = compute_signature(timestamp, payload, secret)
if Enum.any?(signatures, &secure_compare(&1, expected)) do
:ok
else
{:error,
Error.signature_verification_error(
"No matching v1 signature found. Are you passing the raw request body?"
)}
end
end
# Constant-time string comparison to prevent timing attacks.
defp secure_compare(a, b) when byte_size(a) != byte_size(b), do: false
defp secure_compare(a, b) do
a_bytes = :binary.bin_to_list(a)
b_bytes = :binary.bin_to_list(b)
result =
Enum.zip(a_bytes, b_bytes)
|> Enum.reduce(0, fn {x, y}, acc -> Bitwise.bor(acc, Bitwise.bxor(x, y)) end)
result == 0
end
end