AshHooks.Provider behaviour (AshHooks v0.1.0)

Copy Markdown View Source

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

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() :: %{
  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. nil when the caller does not supply it.
  • :request_uri — the exact request URI the provider signed. nil when not supplied.

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

handle_event(event_type, payload)

@callback handle_event(event_type :: atom(), payload :: map() | list()) ::
  {:ok, typed_event :: struct()} | {:error, :retry | :permanent, term()}

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.

Functions

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.

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.