AshDispatch.Transports.Webhook (AshDispatch v0.6.13)
View SourceGeneric 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,:smsand:pushdo 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.
Callback implementation for AshDispatch.Transport.deliver/4.
The JSON-serialisable envelope this transport POSTs.
The headers the request will carry, signature included.
Functions
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.
Callback implementation for AshDispatch.Transport.deliver/4.
@spec envelope(map(), map(), AshDispatch.Channel.t(), map()) :: map()
The JSON-serialisable envelope this transport POSTs.
Public for the same reason as canonical_string/3 and request_headers/3:
a receiver should be able to build its parser against the real thing rather
than against a description of it. Hand it the receipt, the context, the
channel and the (already resolved) metadata and you get exactly the map that
gets encoded.
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.