AshDispatch.WebhookHandlers.Resend (AshDispatch v0.5.6)
View SourceHandles webhooks from Resend for email delivery events.
All events are tracked with dedicated timestamp fields for easy querying:
Delivery Lifecycle Events
- email.sent → sent_at
- email.delivered → delivered_at
- email.delivery_delayed → delivery_delayed_at
- email.failed → failed_at
Engagement Events
- email.opened → opened_at
- email.clicked → clicked_at
Bounce/Complaint Events
- email.bounced → bounced_at
- email.complained → complained_at
Other Events
- email.received, email.scheduled → stored in provider_response only
All events also store full webhook payload in provider_response for debugging.
See: https://resend.com/docs/api-reference/webhooks
Usage
From a Phoenix controller:
defmodule MyAppWeb.ResendWebhookController do
use MyAppWeb, :controller
alias AshDispatch.WebhookHandlers.Resend
def handle(conn, params) do
case Resend.process_webhook(params) do
{:ok, _receipt} ->
json(conn, %{status: "ok"})
{:error, :not_found} ->
json(conn, %{status: "ok", message: "receipt not found"})
{:error, reason} ->
json(conn, %{status: "error", message: inspect(reason)})
end
end
end
Summary
Functions
Process a Resend webhook event.
Verifies a Resend webhook's Svix signature before you hand the payload to
process_webhook/1. Without this the endpoint is unauthenticated receipt
mutation — anyone who guesses a provider id can mark mail opened/bounced.
Functions
Process a Resend webhook event.
Parameters
params- Webhook payload from Resend containing:type- Event type (e.g., "email.opened")created_at- ISO 8601 timestampdata- Event-specific data includingemail_id
Returns
{:ok, receipt}- Successfully processed webhook{:error, :not_found}- Delivery receipt not found for email_id{:error, :missing_email_id}- Webhook missing email_id field{:error, :invalid_format}- Webhook payload invalid{:error, reason}- Other error
Verifies a Resend webhook's Svix signature before you hand the payload to
process_webhook/1. Without this the endpoint is unauthenticated receipt
mutation — anyone who guesses a provider id can mark mail opened/bounced.
Resend signs "svix-id.svix-timestamp.raw_body" with HMAC-SHA256 using
the base64 key from the whsec_-prefixed signing secret, base64-encoding
the result. The svix-signature header can carry several space-separated
v1,<sig> entries during secret rotation — any match passes. Comparison
is constant-time; the timestamp must be within :tolerance_s seconds
(default 300) of now.
Parameters
raw_body- the request body EXACTLY as received (cache it before your JSON parser consumes it — a re-encoded body will not verify)headers- map with"svix-id","svix-timestamp","svix-signature"(e.g. built fromPlug.Conn.get_req_header/2)secret- the signing secret from Resend's dashboard (whsec_...)opts-:tolerance_s(replay window),:now(unix seconds, for tests)
Returns
:okon a valid signature{:error, :missing_headers | :invalid_secret | :invalid_timestamp | :stale_timestamp | :signature_mismatch}
Example (Phoenix controller)
def handle(conn, params) do
raw = conn.assigns[:raw_body]
headers = %{
"svix-id" => List.first(get_req_header(conn, "svix-id")),
"svix-timestamp" => List.first(get_req_header(conn, "svix-timestamp")),
"svix-signature" => List.first(get_req_header(conn, "svix-signature"))
}
case Resend.verify(raw, headers, System.fetch_env!("RESEND_WEBHOOK_SECRET")) do
:ok -> # ... Resend.process_webhook(params)
{:error, _} -> conn |> put_status(400) |> json(%{error: "invalid signature"})
end
end