defmodule Mercury.Webhook do @moduledoc "A registered Mercury webhook endpoint." @type t :: %__MODULE__{ id: String.t(), url: String.t(), status: String.t(), event_types: [String.t()], created_at: DateTime.t() | nil, updated_at: DateTime.t() | nil } defstruct [:id, :url, :status, :event_types, :created_at, :updated_at] @doc false def from_json(map) when is_map(map) do %__MODULE__{ id: map["id"], url: map["url"], status: map["status"], event_types: map["eventTypes"] || [], created_at: Mercury.Types.datetime(map["createdAt"]), updated_at: Mercury.Types.datetime(map["updatedAt"]) } end end defmodule Mercury.Webhooks do @moduledoc """ The Mercury Webhooks API — registering endpoints, and verifying inbound webhook delivery signatures. """ import Bitwise, only: [bxor: 2, bor: 2] alias Mercury.{Client, HTTP, Page, Pagination, Support, Webhook} @doc """ Registers a new webhook endpoint. `attrs` accepts snake_case keys, e.g.: Mercury.Webhooks.create(client, %{ url: "https://example.com/webhooks/mercury", event_types: ["transaction.created", "transaction.updated"] }) `POST /webhooks` """ @spec create(client :: Client.t(), attrs :: map() | keyword()) :: {:ok, Webhook.t()} | {:error, Exception.t()} def create(%Client{} = client, attrs) do with {:ok, body} <- HTTP.post(client, "webhooks", attrs) do {:ok, Webhook.from_json(body)} end end @spec create!(client :: Client.t(), attrs :: map() | keyword()) :: Webhook.t() def create!(client, attrs), do: Support.bang!(create(client, attrs)) @doc "Returns a single webhook by ID. `GET /webhooks/{id}`" @spec get(client :: Client.t(), webhook_id :: String.t()) :: {:ok, Webhook.t()} | {:error, Exception.t()} def get(%Client{} = client, webhook_id) do with {:ok, body} <- HTTP.get(client, "webhooks/#{webhook_id}") do {:ok, Webhook.from_json(body)} end end @spec get!(client :: Client.t(), webhook_id :: String.t()) :: Webhook.t() def get!(client, webhook_id), do: Support.bang!(get(client, webhook_id)) @doc """ Returns one page of webhooks. `GET /webhooks` Accepts an `:event_type` filter in addition to the common list options. """ @spec list(client :: Client.t(), opts :: keyword()) :: {:ok, Page.t(Webhook.t())} | {:error, Exception.t()} def list(%Client{} = client, opts \\ []) do with {:ok, body} <- HTTP.get(client, "webhooks", opts) do {:ok, Page.from_json(body, "webhooks", &Webhook.from_json/1)} end end @spec list!(client :: Client.t(), opts :: keyword()) :: Page.t(Webhook.t()) def list!(client, opts \\ []), do: Support.bang!(list(client, opts)) @doc "A lazy, auto-paginating stream of every webhook." @spec stream(client :: Client.t(), opts :: keyword()) :: Enumerable.t() def stream(%Client{} = client, opts \\ []) do opts = Keyword.new(opts) Pagination.stream(fn cursor -> list(client, Keyword.put(opts, :start_after, cursor)) end) end @doc """ Updates a webhook. Setting `status: :active` reactivates a disabled webhook. `PUT /webhooks/{id}` """ @spec update(client :: Client.t(), webhook_id :: String.t(), attrs :: map() | keyword()) :: {:ok, Webhook.t()} | {:error, Exception.t()} def update(%Client{} = client, webhook_id, attrs) do with {:ok, body} <- HTTP.put(client, "webhooks/#{webhook_id}", attrs) do {:ok, Webhook.from_json(body)} end end @spec update!(client :: Client.t(), webhook_id :: String.t(), attrs :: map() | keyword()) :: Webhook.t() def update!(client, webhook_id, attrs), do: Support.bang!(update(client, webhook_id, attrs)) @doc "Deletes a webhook endpoint. `DELETE /webhooks/{id}`" @spec delete(client :: Client.t(), webhook_id :: String.t()) :: :ok | {:error, Exception.t()} def delete(%Client{} = client, webhook_id) do case HTTP.delete(client, "webhooks/#{webhook_id}") do {:ok, _body} -> :ok {:error, _error} = error -> error end end @spec delete!(client :: Client.t(), webhook_id :: String.t()) :: :ok def delete!(client, webhook_id), do: Support.bang!(delete(client, webhook_id)) @doc "Sends a test event to the webhook endpoint. `POST /webhooks/{id}/verify`" @spec verify(client :: Client.t(), webhook_id :: String.t(), attrs :: map() | keyword()) :: :ok | {:error, Exception.t()} def verify(%Client{} = client, webhook_id, attrs \\ %{}) do case HTTP.post(client, "webhooks/#{webhook_id}/verify", attrs) do {:ok, _body} -> :ok {:error, _error} = error -> error end end @spec verify!(client :: Client.t(), webhook_id :: String.t(), attrs :: map() | keyword()) :: :ok def verify!(client, webhook_id, attrs \\ %{}), do: Support.bang!(verify(client, webhook_id, attrs)) @doc """ Verifies the signature on an *inbound* webhook delivery using constant-time HMAC-SHA256 comparison. > #### Confirm the signing scheme first {: .warning} > > The reference Go SDK does not implement inbound signature > verification, so this assumes the common convention of a > hex-encoded `HMAC-SHA256(raw_request_body, signing_secret)` digest > sent in a header. **Confirm the exact header name and encoding > against the current [Mercury webhooks > documentation](https://docs.mercury.com/reference/webhooks)** for > your endpoint before relying on this in production — providers > sometimes use a composite header (e.g. `t=,v1=`) > instead of a bare digest, in which case extract the digest yourself > and pass it as `signature`. `raw_body` must be the *raw, unparsed* request body bytes — signatures will not match against a re-encoded/re-serialized payload. ## Examples Mercury.Webhooks.verify_signature(raw_body, signature_header, signing_secret) #=> true | false """ @spec verify_signature( raw_body :: binary(), signature :: String.t(), signing_secret :: String.t() ) :: boolean() def verify_signature(raw_body, signature, signing_secret) when is_binary(raw_body) and is_binary(signature) and is_binary(signing_secret) do expected = :hmac |> :crypto.mac(:sha256, signing_secret, raw_body) |> Base.encode16(case: :lower) secure_compare(String.downcase(strip_prefix(signature)), expected) end defp strip_prefix("sha256=" <> rest), do: rest defp strip_prefix(signature), do: signature defp secure_compare(a, b) when byte_size(a) == byte_size(b) do a |> :binary.bin_to_list() |> Enum.zip(:binary.bin_to_list(b)) |> Enum.reduce(0, fn {x, y}, acc -> bor(acc, bxor(x, y)) end) |> Kernel.==(0) end defp secure_compare(_a, _b), do: false end