Skip to content

Phoenix

There is no Elixir package yet; the check is a few lines with :crypto. The one thing Phoenix needs is the raw body, which Plug.Parsers consumes, so keep a copy for the webhook route.

lib/my_app_web/cache_body_reader.ex
defmodule MyAppWeb.CacheBodyReader do
@moduledoc "Keeps the raw body of webhook requests for signature checks."
def read_body(%Plug.Conn{request_path: "/webhooks"} = conn, opts) do
with {:ok, body, conn} <- Plug.Conn.read_body(conn, opts) do
{:ok, body, update_in(conn.assigns[:raw_body], &[body | &1 || []])}
end
end
def read_body(conn, opts), do: Plug.Conn.read_body(conn, opts)
end
lib/my_app_web/endpoint.ex
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
body_reader: {MyAppWeb.CacheBodyReader, :read_body, []},
json_decoder: Phoenix.json_library()
lib/my_app_web/controllers/webhook_controller.ex
defmodule MyAppWeb.WebhookController do
use MyAppWeb, :controller
def create(conn, params) do
raw_body = conn.assigns |> Map.get(:raw_body, []) |> Enum.reverse() |> IO.iodata_to_binary()
secret = Application.fetch_env!(:my_app, :ricochet_webhook_secret)
if verify(raw_body, conn, secret) do
[id] = get_req_header(conn, "webhook-id")
# Skip `id` if it was already handled; then handle `params`.
send_resp(conn, 200, "")
else
send_resp(conn, 401, "")
end
end
defp verify(raw_body, conn, "whsec_" <> secret) do
key = Base.decode64!(secret)
with [id] <- get_req_header(conn, "webhook-id"),
[timestamp] <- get_req_header(conn, "webhook-timestamp"),
[signatures] <- get_req_header(conn, "webhook-signature"),
{seconds, ""} <- Integer.parse(timestamp),
true <- abs(System.system_time(:second) - seconds) <= 300 do
expected = Base.encode64(:crypto.mac(:hmac, :sha256, key, [id, ".", timestamp, ".", raw_body]))
signatures
|> String.split(" ")
|> Enum.any?(fn
"v1," <> received -> Plug.Crypto.secure_compare(received, expected)
_ -> false
end)
else
_ -> false
end
end
end
lib/my_app_web/router.ex
scope "/", MyAppWeb do
pipe_through :api
post "/webhooks", WebhookController, :create
end
Terminal window
mix phx.server
ricochet listen --forward localhost:4000/webhooks

Deliveries through the CLI carry no webhook-signature (a CLI destination has no signing secret), so turn the check off in local development, for example with an environment variable. Never skip it just because the header is missing: that would accept unsigned requests in production too.