Standard Webhooks canon signing and verification — v1 (HMAC-SHA256) and
v1a (ed25519) from day one (ADR-0006), over the canonical string
msg_id.timestamp.payload.
Conformance anchors: the official Go reference library's own test vector
(TestWebhookSign) is reproduced byte-for-byte by sign/4; verification
semantics (space-delimited multi-signatures, unknown version identifiers
skipped, timestamp tolerance, whsec_ prefix optional) match the official
reference libraries read first-hand. ed25519 usage is anchored to RFC 8032
§7.1 known-answers.
Secret formats:
- symmetric —
whsec_+ base64 of 24–64 random bytes (prefix optional, matching the references).sign/4enforces the 24–64 byte band (the spec's emitter guidance);verify/4rejects only EMPTY secrets so a vendor with a shorter secret still interoperates. - 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 is the single seam the delivery runtime uses to emit
:standard, :dual (SW + byte-identical legacy envelope, ADR-0002), or
:legacy headers.
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 (both day one); :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 (both day one); :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, matching the official references.
headers is a map with EXACTLY the 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.