Packages
Event-driven notification system for Ash Framework with multiple transport types
Current section
Files
Jump to
Current section
Files
lib/webhook_handlers/resend.ex
defmodule AshDispatch.WebhookHandlers.Resend do
@moduledoc """
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
"""
require Logger
@doc """
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
"""
def process_webhook(%{"type" => event_type, "data" => data} = params) do
email_id = Map.get(data, "email_id")
created_at = parse_timestamp(Map.get(params, "created_at"))
Logger.info("Processing Resend webhook: #{event_type} for email_id=#{email_id}")
if email_id do
# Find delivery receipt by provider_id (Resend email ID)
result =
delivery_receipt_resource()
|> Ash.Query.for_read(:get_by_provider_id, %{provider_id: email_id})
|> Ash.read_one(authorize?: false)
case result do
{:ok, %{} = receipt} ->
update_receipt_from_event(receipt, event_type, created_at, data)
{:ok, nil} ->
Logger.warning("Resend webhook: delivery receipt not found for email_id=#{email_id}")
{:error, :not_found}
{:error, _} ->
Logger.warning("Resend webhook: delivery receipt not found for email_id=#{email_id}")
{:error, :not_found}
end
else
Logger.warning("Resend webhook missing email_id: #{inspect(params)}")
{:error, :missing_email_id}
end
end
def process_webhook(params) do
Logger.warning("Resend webhook invalid format: #{inspect(params)}")
{:error, :invalid_format}
end
# Private functions
defp delivery_receipt_resource do
Application.get_env(:ash_dispatch, :delivery_receipt_resource) ||
raise """
AshDispatch: :delivery_receipt_resource not configured!
Add to your config/config.exs:
config :ash_dispatch,
delivery_receipt_resource: MyApp.Deliveries.DeliveryReceipt
"""
end
defp update_receipt_from_event(receipt, event_type, timestamp, data) do
attrs = build_update_attrs(event_type, timestamp, data)
receipt
|> Ash.Changeset.for_update(:record_webhook_event, attrs)
|> Ash.update(authorize?: false)
end
# Delivery lifecycle events
defp build_update_attrs("email.sent", timestamp, data) do
%{
sent_at: timestamp,
provider_response: merge_provider_response(data)
}
end
defp build_update_attrs("email.delivered", timestamp, data) do
%{
delivered_at: timestamp,
provider_response: merge_provider_response(data)
}
end
defp build_update_attrs("email.delivery_delayed", timestamp, data) do
%{
delivery_delayed_at: timestamp,
provider_response: merge_provider_response(data)
}
end
defp build_update_attrs("email.failed", timestamp, data) do
%{
failed_at: timestamp,
provider_response: merge_provider_response(data)
}
end
# Engagement events
defp build_update_attrs("email.opened", timestamp, data) do
%{
opened_at: timestamp,
provider_response: merge_provider_response(data)
}
end
defp build_update_attrs("email.clicked", timestamp, data) do
%{
clicked_at: timestamp,
provider_response: merge_provider_response(data)
}
end
# Bounce/complaint events
defp build_update_attrs("email.bounced", timestamp, data) do
%{
bounced_at: timestamp,
provider_response: merge_provider_response(data)
}
end
defp build_update_attrs("email.complained", timestamp, data) do
%{
complained_at: timestamp,
provider_response: merge_provider_response(data)
}
end
# Catch-all for any other events (e.g., email.received, email.scheduled)
defp build_update_attrs(_event_type, _timestamp, data) do
%{
provider_response: merge_provider_response(data)
}
end
defp merge_provider_response(new_data) do
# Merge new webhook data with existing provider_response
# This allows us to accumulate multiple webhook events
Map.put(new_data, "webhook_received_at", DateTime.utc_now() |> DateTime.to_iso8601())
end
defp parse_timestamp(nil), do: nil
defp parse_timestamp(iso8601_string) when is_binary(iso8601_string) do
case DateTime.from_iso8601(iso8601_string) do
{:ok, dt, _offset} -> dt
{:error, _} -> nil
end
end
defp parse_timestamp(_), do: nil
end