AshDispatch.Transports.Webhook (AshDispatch v0.6.12)
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 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.
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.