Behavior for inbound webhook providers.
A provider verifies a request's signature, parses its event type, and handles its decoded payload. Optional callbacks resolve per-connection secrets, declare an authenticated timestamp, or identify a complete delivery.
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.
Built-in providers return typed events. Implement handle_event/2 in your
own provider when ingress must execute application actions. Make those
actions idempotent: durable ingress claims prevent overlapping ownership,
but cannot make a remote side effect and the ledger update one transaction.
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
Returns a stable provider-defined identity for a decoded webhook payload.
Converts a verified, parsed payload into the provider's typed event.
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.
Tenant-aware per-connection secret resolution (optional, 1.2.0): resolves
the signing secret from the connection AND the ingest tenant — custody of
Organization × connection. A provider using use AshHooks.Provider gets a
default implementation delegating to webhook_signing_secret/1 (existing
providers compile unchanged); a per-tenant provider overrides /2.
Functions
The use form for new providers: sets the behavior and provides the
tenant-aware webhook_signing_secret/2 as an overridable default that
delegates to webhook_signing_secret/1 when the provider implements it
— implement /1 and both shapes resolve; override /2 for
Organization × connection custody; implement neither and the default
fails closed as {:error, :no_webhook_secret} (the optional-callback
semantics — an app-level provider never sees the callback called at
all). Providers written against the plain @behaviour keep working
unchanged (webhook_signing_secret/3 resolves both).
Default HMAC verify implementation: lowercase-hex HMAC of the raw body under the secret, compared in constant time behind a byte-size guard.
Resolves the optional stable event identity callback.
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.
Resolves the per-connection signing secret for an ingest: an overriding
webhook_signing_secret/2 (tenant-aware custody) wins; otherwise the
call delegates to webhook_signing_secret/1 — providers of either
implementation shape work through this one entry point.
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() :: %{ optional(:replay_window_seconds) => pos_integer(), signature: signature_header_value(), headers: %{optional(String.t()) => String.t()}, method: String.t() | nil, request_uri: String.t() | nil, tenant: term() }
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.:tenant— the ingest tenant when the ledger is multitenant,nilotherwise. A provider whose custody is per-tenant (per-connection secrets keyed by organization) reads it here.:replay_window_seconds— the declaration override when configured; absent when the provider should use its own default.
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 event_identity(payload :: map() | list()) :: {:ok, String.t()} | {:error, :malformed_payload}
Returns a stable provider-defined identity for a decoded webhook payload.
This callback is optional. Ingress uses it only when the inbound declaration
has no explicit host event_id extractor. An implemented callback's error is
authoritative and never falls back to the raw-body digest. Providers without
the callback retain the raw-body SHA-256 identity behavior.
@callback handle_event(event_type :: atom(), payload :: map() | list()) :: {:ok, typed_event :: struct()} | {:error, :retry | :permanent, term()}
Converts a verified, parsed payload into the provider's typed event.
@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.
@callback webhook_signing_secret(connection :: struct(), tenant :: term()) :: {:ok, webhook_secret()} | {:error, :no_webhook_secret}
Tenant-aware per-connection secret resolution (optional, 1.2.0): resolves
the signing secret from the connection AND the ingest tenant — custody of
Organization × connection. A provider using use AshHooks.Provider gets a
default implementation delegating to webhook_signing_secret/1 (existing
providers compile unchanged); a per-tenant provider overrides /2.
Functions
The use form for new providers: sets the behavior and provides the
tenant-aware webhook_signing_secret/2 as an overridable default that
delegates to webhook_signing_secret/1 when the provider implements it
— implement /1 and both shapes resolve; override /2 for
Organization × connection custody; implement neither and the default
fails closed as {:error, :no_webhook_secret} (the optional-callback
semantics — an app-level provider never sees the callback called at
all). Providers written against the plain @behaviour keep working
unchanged (webhook_signing_secret/3 resolves both).
@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 event_identity(module(), map() | list()) :: :not_supported | {:ok, String.t()} | {:error, :malformed_payload}
Resolves the optional stable event identity callback.
:not_supported means the provider leaves identity to the raw request
digest. Callback errors are returned unchanged for ingress to reject.
@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.
@spec webhook_signing_secret(module(), struct(), term()) :: {:ok, webhook_secret()} | {:error, :no_webhook_secret}
Resolves the per-connection signing secret for an ingest: an overriding
webhook_signing_secret/2 (tenant-aware custody) wins; otherwise the
call delegates to webhook_signing_secret/1 — providers of either
implementation shape work through this one entry point.