defmodule Stripe.Webhook do @moduledoc """ Creates a Stripe Event from webhook's payload if signature is valid. """ @default_tolerance 300 @expected_scheme "v1" @doc """ Verify webhook payload and return a Stripe event. `payload` is the raw, unparsed content body sent by Stripe, which can be retrieved with `Plug.Conn.read_body/2`. Note that `Plug.Parsers` will read and discard the body, so you must implement a [custom body reader][1] if the plug is located earlier in the pipeline. `signature` is the value of `Stripe-Signature` header, which can be fetched with `Plug.Conn.get_req_header/2`. `secret` is your webhook endpoint's secret from the Stripe Dashboard. `tolerance` is the allowed deviation in seconds from the current system time to the timestamp found in `signature`. Defaults to 300 seconds (5 minutes). Stripe API reference: https://stripe.com/docs/webhooks/signatures#verify-manually [1]: https://hexdocs.pm/plug/Plug.Parsers.html#module-custom-body-reader ## Example case Stripe.Webhook.construct_event(payload, signature, secret) do {:ok, %Stripe.Event{} = event} -> # Return 200 to Stripe and handle event {:error, reason} -> # Reject webhook by responding with non-2XX end """ @spec construct_event(String.t(), String.t(), String.t(), integer) :: {:ok, Stripe.Event.t()} | {:error, any} def construct_event(payload, signature_header, secret, tolerance \\ @default_tolerance) do case verify_header(payload, signature_header, secret, tolerance) do :ok -> {:ok, convert_to_event!(payload)} error -> error end end defp verify_header(payload, signature_header, secret, tolerance) do case get_timestamp_and_signatures(signature_header, @expected_scheme) do {nil, _} -> {:error, "Unable to extract timestamp and signatures from header"} {_, []} -> {:error, "No signatures found with expected scheme #{@expected_scheme}"} {timestamp, signatures} -> with {:ok, timestamp} <- check_timestamp(timestamp, tolerance), {:ok, _signatures} <- check_signatures(signatures, timestamp, payload, secret) do :ok else {:error, error} -> {:error, error} end end end defp get_timestamp_and_signatures(signature_header, scheme) do signature_header |> String.split(",") |> Enum.map(&String.split(&1, "=")) |> Enum.reduce({nil, []}, fn ["t", timestamp], {nil, signatures} -> {to_integer(timestamp), signatures} [^scheme, signature], {timestamp, signatures} -> {timestamp, [signature | signatures]} _, acc -> acc end) end defp to_integer(timestamp) do case Integer.parse(timestamp) do {timestamp, _} -> timestamp :error -> nil end end defp check_timestamp(timestamp, tolerance) do now = System.system_time(:second) tolerance_zone = now - tolerance if timestamp < tolerance_zone do {:error, "Timestamp outside the tolerance zone (#{now})"} else {:ok, timestamp} end end defp check_signatures(signatures, timestamp, payload, secret) do signed_payload = "#{timestamp}.#{payload}" expected_signature = compute_signature(signed_payload, secret) if Enum.any?(signatures, &secure_equals?(&1, expected_signature)) do {:ok, signatures} else {:error, "No signatures found matching the expected signature for payload"} end end defp compute_signature(payload, secret) do hmac(:sha256, secret, payload) |> Base.encode16(case: :lower) end # TODO: remove when we require OTP 22 if System.otp_release() >= "22" do defp hmac(digest, key, data), do: :crypto.mac(:hmac, digest, key, data) else defp hmac(digest, key, data), do: :crypto.hmac(digest, key, data) end defp secure_equals?(input, expected) when byte_size(input) == byte_size(expected) do input = String.to_charlist(input) expected = String.to_charlist(expected) secure_compare(input, expected) end defp secure_equals?(_, _), do: false defp secure_compare(acc \\ 0, input, expected) defp secure_compare(acc, [], []), do: acc == 0 defp secure_compare(acc, [input_codepoint | input], [expected_codepoint | expected]) do import Bitwise acc |> bor(bxor(input_codepoint, expected_codepoint)) |> secure_compare(input, expected) end defp convert_to_event!(payload) do payload |> Stripe.API.json_library().decode!() |> Stripe.Converter.convert_result() end end