AshDispatch.WebhookHandlers.Resend (AshDispatch v0.5.6)

View Source

Handles 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_webhook(params)

Process a Resend webhook event.

Parameters

  • params - Webhook payload from Resend containing:
    • type - Event type (e.g., "email.opened")
    • created_at - ISO 8601 timestamp
    • data - Event-specific data including email_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

verify(raw_body, headers, secret, opts \\ [])

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 from Plug.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

  • :ok on 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