Packages
mercury_client
1.0.0
A complete, production-grade Elixir client for the Mercury Banking API (accounts, transactions, recipients, invoices, payments, treasury, webhooks, and more), with typed errors, retry with backoff, and lazy auto-paginating streams.
Current section
Files
Jump to
Current section
Files
lib/mercury/webhooks.ex
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=<timestamp>,v1=<digest>`)
> 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