Behaviour for inbound webhook providers.
A provider module owns one vendor's webhook contract: how a delivery's signature is verified, where its signing secret lives, how the event type is parsed from the payload, and how a payload becomes a typed event.
verify_signature/3 implementations whose scheme is exactly "lowercase-hex
HMAC over the raw body" delegate to default_verify_signature/4;
scheme-specific providers (composite strings, separate timestamp headers,
replay windows) implement their own.
This behaviour is the migration boundary for adopting platforms: provider
modules written against a previous in-house behaviour of this shape migrate
by alias change — the callback names, arities, context map, and
default_verify_signature/4 semantics are kept identical for that reason.
Summary
Types
The request context verify_signature/3 receives as its second argument.
Carries everything any provider's signature scheme can need
Where a provider's webhook signing secret lives
Callbacks
Parses the event type from the DECODED request body — a map for
object-shaped vendors, a list for batch vendors (HubSpot delivers
top-level arrays). Providers receiving a shape their vendor never sends
fail closed through their catch-all clause ({:error, :malformed_payload}).
The request header this provider's signature scheme carries a trustworthy
timestamp in (nil when the scheme has none). A DSL replay_window_seconds
requires a non-nil value here — the compile-time verifier rejects the
combination otherwise, and there is no replay protection without it.
Optional, defaulting to nil.
Declares whether this provider's webhook signing secret is app-level (the default) or per-connection. Optional.
Resolves the signing secret used to verify an inbound webhook.
Functions
Default HMAC verify implementation: lowercase-hex HMAC of the raw body under the secret, compared in constant time behind a byte-size guard.
Resolves a provider's webhook secret scope, defaulting to :app_level when
the optional webhook_secret_scope/0 callback is not implemented.
Resolves a provider's trustworthy timestamp header, defaulting to nil
when the optional timestamp_header/0 callback is not implemented.
Types
@type parse_error() :: :unknown_event_type | :malformed_payload
@type raw_body() :: binary()
@type signature_algorithm() :: :hmac_sha256 | :hmac_sha512
@type signature_error() :: :invalid_signature | :no_webhook_secret | :stale_timestamp
@type signature_header_value() :: binary()
@type verify_context() :: %{ signature: signature_header_value(), headers: %{optional(String.t()) => String.t()}, method: String.t() | nil, request_uri: String.t() | nil }
The request context verify_signature/3 receives as its second argument.
Carries everything any provider's signature scheme can need:
:signature— the value extracted from the provider's signature header.:headers— the full lowercased request header map, for schemes that read an additional header (e.g. a separate timestamp header).:method— the uppercase HTTP method, for schemes that sign over it.nilwhen the caller does not supply it.:request_uri— the exact request URI the provider signed.nilwhen not supplied.
A provider whose scheme needs only the signature reads :signature and
ignores the rest.
@type webhook_secret() :: binary()
@type webhook_secret_scope() :: :app_level | :per_connection
Where a provider's webhook signing secret lives:
:app_level— one app-wide shared secret signs every connection's webhooks. The default when the callback is not implemented.:per_connection— each connection carries its own signing secret.
Callbacks
@callback parse_event_type(payload :: map() | list()) :: {:ok, atom()} | {:error, parse_error()}
Parses the event type from the DECODED request body — a map for
object-shaped vendors, a list for batch vendors (HubSpot delivers
top-level arrays). Providers receiving a shape their vendor never sends
fail closed through their catch-all clause ({:error, :malformed_payload}).
@callback timestamp_header() :: String.t() | nil
The request header this provider's signature scheme carries a trustworthy
timestamp in (nil when the scheme has none). A DSL replay_window_seconds
requires a non-nil value here — the compile-time verifier rejects the
combination otherwise, and there is no replay protection without it.
Optional, defaulting to nil.
@callback verify_signature(raw_body(), verify_context(), webhook_secret()) :: :ok | {:error, signature_error()}
@callback webhook_secret_scope() :: webhook_secret_scope()
Declares whether this provider's webhook signing secret is app-level (the default) or per-connection. Optional.
@callback webhook_signing_secret(connection :: struct()) :: {:ok, webhook_secret()} | {:error, :no_webhook_secret}
Resolves the signing secret used to verify an inbound webhook.
The secret SOURCE is provider-dependent, so it lives with the provider
module rather than in the generic ingress. App-level providers read an
app-wide config value; per-connection providers read it off the connection
argument. Returns {:error, :no_webhook_secret} when unconfigured — the
caller verifies nothing and does not run the handler.
Functions
@spec default_verify_signature( raw_body(), signature_header_value(), webhook_secret(), signature_algorithm() ) :: :ok | {:error, :invalid_signature | :no_webhook_secret}
Default HMAC verify implementation: lowercase-hex HMAC of the raw body under the secret, compared in constant time behind a byte-size guard.
The byte-size guard both short-circuits wrong-length signatures and
satisfies :crypto.hash_equals/2's equal-length requirement.
An EMPTY secret fails closed as {:error, :no_webhook_secret} — an HMAC
under the empty key is computable by anyone, so a secret source that quietly
resolves to "" must not verify anything.
@spec secret_scope(module()) :: webhook_secret_scope()
Resolves a provider's webhook secret scope, defaulting to :app_level when
the optional webhook_secret_scope/0 callback is not implemented.
Loads the provider module first: function_exported?/3 reports false for a
module that is not yet loaded, which would misread an unloaded
per-connection provider as app-level.
Resolves a provider's trustworthy timestamp header, defaulting to nil
when the optional timestamp_header/0 callback is not implemented.
Loads the provider module first — same unloaded-module guard as
secret_scope/1.