Skip to content

Tracking & Webhooks

amsdal_mail can receive delivery and engagement events from ESPs via webhooks. Currently, AWS SES is supported (via SNS notifications).

How It Works

Your App → SES (sends email)
                ↓
           SES fires event (delivered, bounced, opened, clicked, ...)
                ↓
           SNS topic receives event
                ↓
           SNS sends HTTP POST to your webhook endpoint
                ↓
           amsdal_mail parses event → emits EmailTrackingEvent on EventBus
                ↓
           Your listener handles the event

Setup

1. Enable Webhooks

export AMSDAL_MAIL_WEBHOOK_ENABLED=true

Optional settings:

Variable Default Description
AMSDAL_MAIL_WEBHOOK_ENABLED false Enable webhook endpoint
AMSDAL_MAIL_WEBHOOK_BASE_PATH /webhooks/mail Base path for webhook routes
AMSDAL_MAIL_WEBHOOK_SES_SECRET Optional secret for request verification

2. Configure SES → SNS → Webhook

  1. Create an SNS topic in AWS Console
  2. Add an HTTP/HTTPS subscription pointing to https://your-domain/webhooks/mail/ses
  3. Create a Configuration Set in SES (if you don't have one)
  4. Add an Event Destination in the Configuration Set:
  5. Destination: the SNS topic
  6. Event types: Send, Delivery, Bounce, Complaint, Reject, Open, Click, DeliveryDelay

3. Confirm SNS Subscription

When you add the HTTP subscription, SNS sends a SubscriptionConfirmation request to your endpoint. The webhook handler logs the SubscribeURL — visit it to confirm the subscription.

Webhook Endpoint

When enabled, the plugin registers:

POST /webhooks/mail/{esp_name}

Currently the only supported esp_name is ses.

The handler:

  1. Verifies the request (cross-validates SNS headers against the JSON body)
  2. Parses the SES event into EmailTrackingContext objects
  3. Emits an EmailTrackingEvent for each via the AMSDAL EventBus

Tracking Event Types

Event Type Description
queued Message queued for delivery
sent Message sent to recipient's mail server
delivered Message delivered to recipient
deferred Delivery delayed (will retry)
bounced Message bounced (hard or soft)
rejected Message rejected by ESP
failed Send failed
opened Recipient opened the email
clicked Recipient clicked a link
complained Recipient marked as spam
unsubscribed Recipient unsubscribed
unknown Event type not recognized

EmailTrackingContext

Each webhook event is parsed into an EmailTrackingContext:

Field Type Description
event_type TrackingEventType Event type (see table above)
message_id str SES MessageId
recipient str Recipient email address
timestamp datetime When the event occurred
esp_name str Always 'ses' for SES events
tags list[str] Tags from the original message
metadata dict[str, Any] Metadata from X-Metadata-* headers
reject_reason str \| None Reason for bounce/reject
description str \| None Diagnostic code (bounces) or delay type
click_url str \| None Clicked URL (click events only)
user_agent str \| None Recipient's user agent (open/click events)
raw_event dict Full SES event JSON

Listening for Events

Subscribe to EmailTrackingEvent via the AMSDAL EventBus:

from amsdal_utils.events import EventListener, NextFn, AsyncNextFn, EventBus
from amsdal_mail import EmailTrackingEvent, EmailTrackingContext

class TrackingListener(EventListener[EmailTrackingContext]):
    def handle(
        self,
        context: EmailTrackingContext,
        next_fn: NextFn[EmailTrackingContext],
    ) -> EmailTrackingContext:
        if context.event_type == 'bounced':
            print(f'Bounce: {context.recipient}{context.reject_reason}')
        elif context.event_type == 'opened':
            print(f'Opened: {context.recipient}')
        elif context.event_type == 'clicked':
            print(f'Clicked: {context.recipient}{context.click_url}')

        return next_fn(context)

    async def ahandle(
        self,
        context: EmailTrackingContext,
        next_fn: AsyncNextFn[EmailTrackingContext],
    ) -> EmailTrackingContext:
        return self.handle(context, next_fn)  # type: ignore[arg-type]

# Register the listener
EventBus.subscribe(EmailTrackingEvent, TrackingListener)

Multi-Recipient Events

SES events like bounces and complaints can include multiple recipients. The webhook parser expands these into individual EmailTrackingContext instances — one per recipient — so your listener always receives a single recipient per event.

Verification

The SES webhook parser verifies incoming requests by cross-validating:

  • x-amz-sns-message-type header matches the Type field in the JSON body
  • x-amz-sns-message-id header matches the MessageId field in the JSON body

Requests that fail verification are rejected with HTTP 403.