Delivery Receipts
View SourceDelivery receipts track the lifecycle of every notification sent through AshDispatch. This guide covers the receipt model, status flow, and available actions for managing deliveries.
What Delivery Receipts Track
Every time a notification is dispatched, a DeliveryReceipt is created to track:
- Recipient information - Who the notification is for (email, user_id, etc.)
- Content - Full email subject, body, and any additional content
- Status - Current delivery state (pending, sent, failed, etc.)
- Provider data - Response from email provider, message IDs
- Timing - When created, sent, delivered, opened, clicked
- Source - What triggered the notification (order, ticket, etc.)
This provides a complete audit trail and enables:
- Retry failed deliveries
- Debug delivery issues
- Track engagement (opens, clicks)
- User notification history
Status Flow
Delivery receipts follow a state machine pattern:
┌─────────────┐
│ pending │
└──────┬──────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│scheduled │ │ skipped │ │ (sync) │
└────┬─────┘ └──────────┘ └────┬─────┘
│ │
▼ │
┌──────────┐ │
│ sending │◄──────────────────┘
└────┬─────┘
│
┌─────┴─────┐
▼ ▼
┌────────┐ ┌────────┐
│ sent │ │ failed │──────┐
└────────┘ └────┬───┘ │
│ │ (max retries)
│ (retry) ▼
│ ┌─────────────────┐
└───►│ failed_permanent │
└─────────────────┘Status Descriptions
| Status | Description |
|---|---|
pending | Just created, not yet processed |
scheduled | Oban job enqueued for async delivery |
sending | Currently being sent |
sent | Successfully delivered to provider |
failed | Delivery failed, may be retried |
failed_permanent | Won't retry (invalid email, unsubscribed) |
skipped | Intentionally not sent (user opted out) |
Available Actions
Core Status Transitions
These actions are typically called by workers and internal processes:
# Mark as sending (worker starting delivery)
Ash.update(receipt, :mark_sending)
# Mark as sent (delivery successful)
Ash.update(receipt, :mark_sent, %{
provider_id: "msg_abc123",
provider_response: %{"status" => "sent"}
})
# Mark as failed (delivery failed, will retry)
Ash.update(receipt, :mark_failed, %{
error_message: "Connection timeout"
})
# Mark as permanently failed (won't retry)
Ash.update(receipt, :mark_failed_permanent, %{
error_message: "Invalid email address"
})
# Skip delivery (user opted out)
Ash.update(receipt, :skip, %{
error_message: "User disabled notifications"
})Manual Actions (Admin UI)
send_now
The send_now action allows administrators to manually trigger delivery for a scheduled or pending receipt. This is useful for:
- Retrying stuck deliveries - When a job is stuck in scheduled state
- Testing in production - Trigger a specific email immediately
- Support requests - Resend a notification to a user
Usage:
# From admin UI or IEx
receipt
|> Ash.Changeset.for_update(:send_now, %{}, actor: current_admin)
|> Ash.update(authorize?: true)Behavior:
- Creates a new Oban job to process the delivery immediately
- Only works from
scheduledorpendingstatus - Respects configured authorization (see below)
Authorization:
By default, any authenticated actor can use send_now. To restrict it (e.g., super admins only), configure an authorizer:
# config/config.exs
config :ash_dispatch,
send_now_authorizer: MyApp.Deliveries.SendNowAuthorizer# lib/my_app/deliveries/send_now_authorizer.ex
defmodule MyApp.Deliveries.SendNowAuthorizer do
def authorize(%{super_admin: true}), do: :ok
def authorize(_actor), do: {:error, "Only super admins can manually trigger email sending"}
endSee Configuration for full documentation.
retry
The retry action re-queues a failed delivery for another attempt:
receipt
|> Ash.Changeset.for_update(:retry, %{}, actor: current_admin)
|> Ash.update(authorize?: true)Behavior:
- Only works from
failedstatus - Increments
retry_count - Creates new Oban job
- Validates against max retry limit
Recording Webhook Events
When email providers send webhooks (delivery confirmations, opens, clicks), use record_webhook_event:
Ash.update(receipt, :record_webhook_event, %{
delivered_at: DateTime.utc_now(),
provider_response: webhook_payload
})Supported event timestamps:
sent_at- Email accepted by providerdelivered_at- Email delivered to inboxdelivery_delayed_at- Delivery delayedfailed_at- Delivery failedopened_at- Email openedclicked_at- Link clickedbounced_at- Email bouncedcomplained_at- Spam complaint received
Querying Receipts
List all receipts (admin)
DeliveryReceipt
|> Ash.Query.for_read(:list_all, %{
status: :failed,
transport: :email
})
|> Ash.read(actor: admin, authorize?: true)List for specific user
DeliveryReceipt
|> Ash.Query.for_read(:list_for_user, %{user_id: user_id})
|> Ash.read(actor: admin, authorize?: true)Find by provider ID (webhooks)
DeliveryReceipt
|> Ash.Query.for_read(:get_by_provider_id, %{provider_id: "msg_abc123"})
|> Ash.read_one(authorize?: false)Calculated Fields
Delivery receipts include useful calculated fields:
| Field | Description |
|---|---|
oban_job | The associated Oban job (if any) |
source_url | URL path to the source resource |
source_label | Human-readable label for source type |
admin_url | Admin-specific URL for the source |
from_email | Sender email address extracted from content |
from_name | Sender name extracted from content |
These are useful for building admin UIs that link back to the originating record.
Sender Information
The from_email and from_name calculations extract sender information from the stored content field:
# Load sender info with the receipt
receipt = Ash.get!(DeliveryReceipt, id, load: [:from_email, :from_name])
# Display sender
"#{receipt.from_name} <#{receipt.from_email}>"
# => "Acme <noreply@acme.test>"This is useful for:
- Displaying the sender in admin UIs
- Debugging which domain emails were sent from (staging vs production)
- Filtering receipts by sender domain
Building an Admin UI
A typical delivery receipt admin interface might include:
List View
- Filter by status, transport, event_id, audience
- Show recipient, status, sent_at
- Actions: View, Retry, Send Now
Detail View
- Full receipt information
- Email content preview (subject, body)
- Provider response
- Timeline of status changes
- Link to source resource
- Oban job details
Example LiveView
def handle_event("send_now", %{"id" => id}, socket) do
receipt = Deliveries.get_receipt!(id)
case receipt
|> Ash.Changeset.for_update(:send_now, %{}, actor: socket.assigns.current_user)
|> Ash.update(authorize?: true) do
{:ok, _} ->
{:noreply, put_flash(socket, :info, "Email queued for immediate delivery")}
{:error, error} ->
{:noreply, put_flash(socket, :error, Exception.message(error))}
end
endAutomatic Retry
AshDispatch includes automatic retry for failed deliveries via Oban cron:
# In your Oban config
config :my_app, Oban,
plugins: [
{Oban.Plugins.Cron,
crontab: [
# Retry failed emails every 15 minutes
{"*/15 * * * *", AshDispatch.Workers.RetryFailedEmails, max_attempts: 1}
]}
]The retry worker:
- Finds receipts in
failedstatus withretry_count < max_retries - Re-queues them for delivery
- Marks permanently failed after max retries exceeded
Best Practices
1. Use ReceiptStatus helper
For status updates in custom workers, use the centralized helper:
alias AshDispatch.ReceiptStatus
# Instead of manual changeset creation
{:ok, receipt} = ReceiptStatus.mark_sending(receipt)
ReceiptStatus.mark_sent(receipt, provider_response)2. Store provider IDs
Always store provider message IDs for webhook correlation:
ReceiptStatus.mark_sent(receipt, %{
"id" => provider_message_id,
"status" => "queued"
})3. Include source information
When creating receipts, include source type and ID for traceability:
%{
event_id: "orders.shipped",
source_type: "Elixir.MyApp.Orders.Order",
source_id: order.id,
# ...
}4. Respect user preferences
Preferences are checked before delivery, but note WHICH key does it — the two are not the same (see Configuration):
config :ash_dispatch,
# Consulted by every transport before delivery:
user_preference: MyApp.NotificationPreferences,
# Consulted by the email worker and manual triggers only:
preference_provider: MyApp.LegacyPreferencesA receipt skipped by the transport gate carries
error_message: "user_opted_out"; one skipped by the email worker's provider
path carries "User opted out of this email category". A count of opt-outs
has to know about both.
Webhook Signature Verification
Resend webhooks mutate receipt state (opened/bounced/delivered), so verify
the Svix signature before handing the payload to process_webhook/1:
case AshDispatch.WebhookHandlers.Resend.verify(raw_body, headers, secret) do
:ok -> AshDispatch.WebhookHandlers.Resend.process_webhook(params)
{:error, _} -> # respond 400
endraw_body must be the request body exactly as received — cache it before
your JSON parser runs. See AshDispatch.WebhookHandlers.Resend.verify/4.
Sensitive Content Retention
Receipts store full rendered bodies, which for OTP codes and password-reset
links means live secrets in the database. Declare such events with
metadata: [sensitive_content: true] and schedule
AshDispatch.Workers.ScrubSensitiveContent (cron): bodies older than
config :ash_dispatch, :scrub_after_hours (default 24) are blanked while
recipient, subject, status and delivery timestamps stay intact. :failed
receipts are left for the retry path first.
Next Steps
- Configuration - All configuration options including
send_now_authorizer - Architecture - Internal module documentation
- User Preferences - Implement notification opt-outs
- Oban Configuration - Job queue setup