AshDispatch.Transports.Webhook (AshDispatch v0.8.4)
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.
As of 0.7.0 every transport that reaches a person asks the same question, through
AshDispatch.Transports.Preferences.with_consent/5. The note that used to stand here — that:slack,:discord,:smsand:pushdid not — described a real gap: a preference honoured on one channel was silently ignored on another, and the difference was invisible to the person who set it.
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.
The signing secret for a channel: metadata.secret, or the value of the
environment variable named by metadata.secret_env.
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.
The signing secret for a channel: metadata.secret, or the value of the
environment variable named by metadata.secret_env.
secret_env exists because of a real tension. Channels declared in the
dispatch do DSL are compile-time data, but a signing key is
runtime data: baked in at compile time, a key rotation would not take
effect until someone recompiled — and nothing would say so. The alternative
was to move the whole channel into the event module's channels/1 callback,
which works but forfeits the DSL for every other property of that channel.
Reading the name at compile time and the value at dispatch time keeps both: the channel stays declarative, and the key stays operational.
secret wins when both are given, so an explicit value can override the
environment in a test.