AshDispatch.Transports.Webhook (AshDispatch v0.6.12)

View Source

Generic webhook transport: POSTs the event to an HTTP endpoint, async via Oban (AshDispatch.Workers.SendWebhook).

Use it when the receiver is a system rather than a person — a gateway that fans out to a chat product, an internal service that mirrors events, a partner integration.

Configuration

%Channel{
  transport: :webhook,
  audience: :user,
  webhook_url: "https://gateway.internal/dispatch",
  metadata: %{
    # Optional. When set, the request is signed (see "Signing").
    secret: System.get_env("GATEWAY_WEBHOOK_SECRET"),
    # Optional. Extra headers sent verbatim — useful for routing or for
    # telling the receiver which key to verify with.
    headers: %{"x-consumer" => "chat-gateway"}
  }
}

webhook_url may also come from channel.opts["webhook_url"], matching the Slack transport.

The payload

A stable envelope, so a receiver can be written once:

{
  "event_id":   "meetings.no_show",
  "receipt_id": "018f…",
  "user_id":    "9c2a…",      // null for non-user audiences
  "recipient":  "kim@example.com",
  "audience":   "user",
  "transport":  "webhook",
  "content":    {  },        // the rendered content map
  "metadata":   {  },        // channel metadata, minus `secret`
  "sent_at":    "2026-09-08T12:00:00Z"
}

secret is stripped from the forwarded metadata — a signing key must never travel inside the body it signs.

Signing

When metadata.secret is set the request carries

<signature_header>: sha256=<lowercase hex>

(default header x-webhook-signature, override with metadata.signature_header) computed as HMAC-SHA256 over

METHOD "\n" request_path "\n" sorted_query ["\n" raw_body]

where sorted_query is the URL's query decoded, sorted by key and re-encoded, and the body part is present for POST/PATCH/PUT — which a webhook always is. Binding the path and query as well as the body means a captured signature cannot be replayed against a different endpoint on the same host.

The signature covers the exact bytes that go on the wire: the transport serialises the JSON itself and hands the worker that string, rather than letting the HTTP client re-encode a map. Re-encoding is the classic way a webhook signature becomes intermittently wrong — key order and float formatting are not guaranteed to survive a round trip.

Preferences

This transport honours per-recipient opt-out via AshDispatch.UserPreference.allows_receipt?/4, like :email and :in_app. A webhook is frequently the first hop to a human (a chat DM, a push relay), and delivering to someone who opted out because the last hop happens to be HTTP would be the wrong default.

Note for maintainers: :slack, :discord, :sms and :push do not check preferences today. That looks like a gap rather than a decision, but changing them is a behaviour change for existing consumers and is left to its own change.

Summary

Functions

The canonical string a receiver must rebuild to verify a signature.

The headers the request will carry, signature included.

Functions

canonical_string(method, url, body)

@spec canonical_string(String.t(), String.t(), String.t()) :: String.t()

The canonical string a receiver must rebuild to verify a signature.

Public so a consumer can test its own verifier against ours instead of against its reading of the docs.

deliver(receipt, context, channel, event_config)

Callback implementation for AshDispatch.Transport.deliver/4.

request_headers(url, body, metadata)

@spec request_headers(String.t(), String.t(), map()) :: map()

The headers the request will carry, signature included.

Public for the same reason as canonical_string/3: the signing contract should be provable from the outside. Pass the channel metadata and you get back exactly what goes on the wire.