Standard Webhooks signing and verification for v1 (HMAC-SHA256) and
v1a (ed25519), over msg_id.timestamp.payload.
Pass the exact payload bytes. headers/4 assembles the webhook ID,
timestamp, and signatures; verify/4 validates those headers against the
received bytes and enforces a timestamp tolerance by default.
Secret formats:
- symmetric —
whsec_+ base64 of 24–64 random bytes (prefix optional, matching the references).sign/4enforces the 24–64 byte band (the emitter's length bound); verification accepts nonempty decoded symmetric secrets, including shorter keys, and rejects malformed base64. - asymmetric —
whsk_+ base64 of the 32-byte ed25519 seed (signing),whpk_+ base64 of the 32-byte public key (verification). Prefixes are required — they are whatverify/4dispatches on.
headers_for_mode/5 emits :standard, :dual (Standard Webhooks and
legacy), or :legacy headers for the delivery runtime. Rotation options
include the previous key's signature alongside the current signature.
Summary
Functions
A fresh msg_-prefixed webhook id (URL-safe, never contains .).
A fresh whsec_-prefixed secret (24–64 decoded bytes, spec band).
A fresh ed25519 keypair as {whsk, whpk}.
The three Standard Webhooks headers. :whsec and/or :whsk select the
signature schemes; :previous_whsec / :previous_whsk
append the rotated-out key's signature (space-delimited, per the spec's
zero-downtime rotation — symmetric and asymmetric alike).
The full header set for a subscription's signing_mode (ADR-0002)
Signs the canonical string with HMAC-SHA256 under the decoded symmetric
secret: "v1," <> base64(mac).
Signs the canonical string with ed25519 under the seed decoded from a
whsk_ secret: "v1a," <> base64(signature).
Verifies a webhook's webhook-id / webhook-timestamp /
webhook-signature headers against the raw payload.
Types
Functions
@spec generate_msg_id() :: String.t()
A fresh msg_-prefixed webhook id (URL-safe, never contains .).
@spec generate_secret(pos_integer()) :: String.t()
A fresh whsec_-prefixed secret (24–64 decoded bytes, spec band).
A fresh ed25519 keypair as {whsk, whpk}.
@spec headers(String.t(), integer(), binary(), keyword()) :: %{ required(String.t()) => String.t() }
The three Standard Webhooks headers. :whsec and/or :whsk select the
signature schemes; :previous_whsec / :previous_whsk
append the rotated-out key's signature (space-delimited, per the spec's
zero-downtime rotation — symmetric and asymmetric alike).
@spec headers_for_mode( :legacy | :dual | :standard, String.t(), integer(), binary(), keyword() ) :: %{ required(String.t()) => String.t() }
The full header set for a subscription's signing_mode (ADR-0002):
:standard— the three SW headers.:dual— SW headers PLUS the byte-identical legacy envelope (x-webhook-signature, viaAshHooks.Legacy); requires:legacy_secret(dual MEANS both envelopes).:legacy— the legacy envelope only.
Legacy options: :legacy_secret, :legacy_previous_secret. SW options:
:whsec, :whsk, :previous_whsec.
Signs the canonical string with HMAC-SHA256 under the decoded symmetric
secret: "v1," <> base64(mac).
Raises ArgumentError on invalid inputs — the send path fails loud on
config or programming errors.
Signs the canonical string with ed25519 under the seed decoded from a
whsk_ secret: "v1a," <> base64(signature).
@spec verify(binary(), map(), String.t(), keyword()) :: {:ok, %{id: String.t(), timestamp: integer()}} | {:error, verify_reason()}
Verifies a webhook's webhook-id / webhook-timestamp /
webhook-signature headers against the raw payload.
Dispatches on the secret's prefix: whpk_ verifies v1a (ed25519)
entries; anything else verifies v1 (HMAC) entries and skips all other
version identifiers.
headers is a map with lowercased header names as string keys
and single binary values ("webhook-id", "webhook-timestamp",
"webhook-signature") — the shape Plug and the inbound seam supply after
normalization. Mixed-case keys or multi-value header lists are
{:error, :missing_header} (fail closed); callers normalize before this
boundary.
Options:
:now— integer unix seconds (defaultSystem.system_time(:second)).:tolerance— allowed|now - timestamp|in seconds (default 300).:ignore_timestamp— skip the tolerance check (dead-letter re-drives, offline verification); the timestamp must still parse.
Returns {:ok, %{id: id, timestamp: ts}} or {:error, reason}. Never
raises on hostile input.