AshHooks.Provider behaviour (AshHooks v2.0.3)

Copy Markdown View Source

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

parse_error()

@type parse_error() :: :unknown_event_type | :malformed_payload

raw_body()

@type raw_body() :: binary()

signature_algorithm()

@type signature_algorithm() :: :hmac_sha256 | :hmac_sha512

signature_error()

@type signature_error() :: :invalid_signature | :no_webhook_secret | :stale_timestamp

signature_header_value()

@type signature_header_value() :: binary()

verify_context()

@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. nil when the caller does not supply it.
  • :request_uri — the exact request URI the provider signed. nil when not supplied.
  • :tenant — the ingest tenant when the ledger is multitenant, nil otherwise. 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.

webhook_secret()

@type webhook_secret() :: binary()

webhook_secret_scope()

@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

event_identity(payload)

(optional)
@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.

handle_event(event_type, payload)

@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.

parse_event_type(payload)

@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}).

timestamp_header()

(optional)
@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.

verify_signature(raw_body, verify_context, webhook_secret)

@callback verify_signature(raw_body(), verify_context(), webhook_secret()) ::
  :ok | {:error, signature_error()}

webhook_secret_scope()

(optional)
@callback webhook_secret_scope() :: webhook_secret_scope()

Declares whether this provider's webhook signing secret is app-level (the default) or per-connection. Optional.

webhook_signing_secret(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.

webhook_signing_secret(connection, tenant)

(optional)
@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

__using__(opts)

(macro)

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_verify_signature(raw_body, header_value, secret, algorithm)

@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.

event_identity(provider, payload)

@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.

secret_scope(provider)

@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.

timestamp_header(provider)

@spec timestamp_header(module()) :: String.t() | nil

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.

webhook_signing_secret(provider, connection, tenant)

@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.