Mailerflow Start free
Blog › Transactional Email & Developer Tools › Amazon SES

Amazon SES Configuration Sets and Event Publishing: A Practical Guide

Published October 19, 2026 · Updated October 19, 2026 · 8 min read

Illustration for Amazon SES Configuration Sets and Event Publishing: A Practical Guide

Configuration sets are the mechanism that turns Amazon SES from a fire-and-forget SMTP relay into something you can observe. A configuration set is a named group of rules that you attach to a send; event destinations hang off that configuration set and publish SES events — sends, deliveries, bounces, complaints, opens, clicks, rejects — to SNS, Kinesis Data Firehose, EventBridge, or CloudWatch. If you want delivery and engagement data out of SES, you create a configuration set, enable the tracking you care about, attach at least one destination, and pass the configuration set name on every send.

This guide walks through that end to end: prerequisites, CLI and SDK setup, a complete runnable example, error handling, idempotency, and the failure modes that waste the most developer time.

What Configuration Sets Actually Do

A configuration set is a container. It holds two kinds of settings: tracking options (open tracking, click tracking, and whether to include the tracking pixel or link wrapping) and event destinations. Nothing happens automatically — a send is only governed by a configuration set if you name it explicitly, either through the ConfigurationSetName parameter in the API or the X-SES-CONFIGURATION-SET header in raw MIME.

The detail that trips people up: open and click events are only emitted when you enable tracking on the configuration set itself. Delivery, bounce, complaint, and reject events flow to your destination regardless. So a configuration set with no tracking options and an SNS destination will give you a clean delivery pipeline and zero engagement data, and there is no error message telling you why.

Event destinations are additive. One configuration set can publish to SNS and Firehose and EventBridge at the same time, which is useful when you want cheap archival in Firehose and real-time reaction in SNS. Each destination has its own IAM role that SES assumes to publish on your behalf, and that role is the most common source of silent failures.

If you are still deciding where to send from, see the Best Email Marketing Platforms compared side by side, with prices checked daily on each provider's own pricing page.

Prerequisites

  • A verified sending domain or email identity in SES. Domain verification with DKIM is the right choice for production.
  • AWS credentials with at least ses:SendEmail, ses:CreateConfigurationSet, ses:CreateConfigurationSetEventDestination, sns:Publish, sqs:ReceiveMessage, and iam:PassRole.
  • A single region chosen and used consistently. Configuration sets are regional; a set created in us-east-1 does not exist in eu-west-1.
  • AWS CLI v2 and either Python 3.9+ with boto3, or Node 18+ with @aws-sdk/client-sesv2.
  • An SNS topic (or an EventBridge rule, SQS queue, or Firehose stream) to receive events.

One thing to plan for: accounts in the SES sandbox can only send to verified addresses, and moving to production requires a request through the SES console. Approval is not instant and the timing depends on your use case description, so start that request before you need it.

Creating a Configuration Set and Event Destination

Create the configuration set with tracking enabled

aws sesv2 create-configuration-set \
  --configuration-set-name transactional-events \
  --tracking-options EventType=OPEN_AND_CLICK

The equivalent in Python:

import boto3

ses = boto3.client("sesv2", region_name="us-east-1")

ses.create_configuration_set(
    ConfigurationSetName="transactional-events",
    TrackingOptions={"EventType": "OPEN_AND_CLICK"},
)

Attach an SNS event destination

First create the topic and the IAM role SES will assume. The role needs a trust policy allowing ses.amazonaws.com to assume it, and a permission policy granting sns:Publish on your topic.

TOPIC_ARN=$(aws sns create-topic --name ses-events --query TopicArn --output text)
echo "$TOPIC_ARN"
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {"Service": "ses.amazonaws.com"},
      "Action": "sts:AssumeRole"
    }
  ]
}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sns:Publish",
      "Resource": "arn:aws:sns:us-east-1:123456789012:ses-events"
    }
  ]
}

Then attach the destination. This is where you choose which event types matter — publishing all of them is fine, but filtering early saves downstream cost.

ses.create_configuration_set_event_destination(
    ConfigurationSetName="transactional-events",
    EventDestinationName="sns-all-events",
    EventDestination={
        "Enabled": True,
        "MatchingEventTypes": [
            "SEND", "DELIVERY", "BOUNCE", "COMPLAINT",
            "OPEN", "CLICK", "REJECT", "RENDERING_FAILURE",
        ],
        "SNSDestination": {
            "TopicARN": "arn:aws:sns:us-east-1:123456789012:ses-events"
        },
    },
)

If you would rather not manage an SNS topic, the same call accepts KinesisFirehoseDestination, EventBridgeDestination, or CloudWatchDestination. EventBridge is the lightest option when you already route AWS events through rules.

Sending email with the configuration set

ses.send_email(
    FromEmailAddress="noreply@example.com",
    Destination={"ToAddresses": ["customer@example.net"]},
    ConfigurationSetName="transactional-events",
    EmailTags=[{"Name": "order_id", "Value": "ord_8842"}],
    Content={
        "Simple": {
            "Subject": {"Data": "Your order shipped"},
            "Body": {"Text": {"Data": "Tracking: 1Z999AA10123456784"}},
        }
    },
)

With raw MIME, the configuration set travels as a header instead:

X-SES-CONFIGURATION-SET: transactional-events
X-SES-MESSAGE-TAGS: order_id=ord_8842

Node.js v3 SDK, same idea:

import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";

const ses = new SESv2Client({ region: "us-east-1" });

await ses.send(new SendEmailCommand({
  FromEmailAddress: "noreply@example.com",
  Destination: { ToAddresses: ["customer@example.net"] },
  ConfigurationSetName: "transactional-events",
  EmailTags: [{ Name: "order_id", Value: "ord_8842" }],
  Content: {
    Simple: {
      Subject: { Data: "Your order shipped" },
      Body: { Text: { Data: "Tracking: 1Z999AA10123456784" } },
    },
  },
}));

The configuration set must exist in the same region as the send, or the call fails with ConfigurationSetDoesNotExist.

Consuming the events

An SNS-to-HTTPS subscriber receives a JSON envelope whose Message field is a string containing the SES event. Parse it, then branch on eventType:

import json

EVENT_TYPES = {
    "Delivery": "delivery",
    "Bounce": "bounce",
    "Complaint": "complaint",
    "Open": "open",
    "Click": "click",
}

def parse_ses_event(sns_message: dict) -> dict:
    event = json.loads(sns_message["Message"])
    return {
        "type": EVENT_TYPES.get(event["eventType"], event["eventType"].lower()),
        "message_id": event["mail"]["messageId"],
        "tags": event["mail"].get("tags", {}),
        "timestamp": event["mail"]["timestamp"],
        "detail": event.get(event["eventType"].lower(), {}),
    }

A click event's detail carries the clicked URL; a bounce event carries bounceType and bouncedRecipients. Correlating events back to your own records is what message tags are for — set order_id, user_id, or your internal message ID as a tag at send time, and it comes back in mail.tags on every event for that message.

Two accuracy caveats worth internalizing. Open events are approximate: mailbox providers proxy or prefetch images, and Apple Mail Privacy Protection can register opens the recipient never made. Click events are far more reliable because they require an actual interaction. Treat opens as a directional signal, not a metric to bill on.

Handling Errors, Retries, and Idempotency

SES throttling returns a 400 with Throttling in the error code, or a 429. Both are retryable. So are transient 5xx responses. MessageRejected and MailFromDomainNotVerified are not — retrying them just burns quota.

import random, time

RETRYABLE = {"Throttling", "TooManyRequestsException", "ServiceUnavailable"}

def send_with_backoff(fn, max_attempts=5):
    for attempt in range(max_attempts):
        try:
            return fn()
        except Exception as exc:
            code = getattr(exc, "response", {}).get("Error", {}).get("Code", "")
            status = getattr(exc, "response", {}).get("ResponseMetadata", {}).get("HTTPStatusCode")
            retryable = code in RETRYABLE or (status is not None and status >= 500)
            if not retryable or attempt == max_attempts - 1:
                raise
            delay = min(2 ** attempt, 30) + random.uniform(0, 1)
            time.sleep(delay)

On the consumer side, SNS and SQS deliver at least once. Your handler will see duplicates, and it must be idempotent. Deduplicate on a composite key of messageId, eventType, and a coarse timestamp bucket:

import redis

r = redis.Redis()

def already_processed(event: dict, ttl_seconds: int = 604800) -> bool:
    key = f"ses:{event['message_id']}:{event['type']}:{event['timestamp'][:16]}"
    return not r.set(key, "1", nx=True, ex=ttl_seconds)

One more layer matters: SES does not deduplicate sends. If your retry logic resends after a timeout where the first send actually succeeded, the recipient gets two emails. Carry your own idempotency key as a message tag and check it downstream before acting on the event. If maintaining that layer is more overhead than you want, Mailerflow's transactional email API accepts idempotency keys natively and exposes signed webhooks with retries and delivery logs, which removes the queue plumbing entirely.

Testing Before You Go to Production

SES provides mailbox simulator addresses that trigger specific events without harming your sending reputation:

Address Event triggered
success@simulator.amazonses.com Delivery
bounce@simulator.amazonses.com Bounce
complaint@simulator.amazonses.com Complaint

Send to each with your configuration set attached and confirm the events land in your SNS topic or SQS queue. Then test your idempotency helper by feeding the same event payload through twice and asserting the second call is a no-op. Test backoff by mocking the client to raise Throttling on the first two attempts. Finally, check the event destination's delivery metrics in CloudWatch — if SES is publishing but your subscriber is failing, the metrics will show the gap.

Note that simulator addresses only produce the event they map to; sending to the bounce simulator will not generate an open event.

A Complete Working Example

import json, boto3

REGION = "us-east-1"
CONFIG_SET = "transactional-events"
ses = boto3.client("sesv2", region_name=REGION)
sns = boto3.client("sns", region_name=REGION)
sqs = boto3.client("sqs", region_name=REGION)

# 1. Configuration set with tracking
try:
    ses.create_configuration_set(
        ConfigurationSetName=CONFIG_SET,
        TrackingOptions={"EventType": "OPEN_AND_CLICK"},
    )
except ses.exceptions.AlreadyExistsException:
    pass

# 2. Topic plus a queue subscribed to it
topic_arn = sns.create_topic(Name="ses-events")["TopicArn"]
queue_url = sqs.create_queue(QueueName="ses-events-q")["QueueUrl"]
queue_arn = sqs.get_queue_attributes(
    QueueUrl=queue_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]

sns.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn)

# 3. Event destination (assumes a role with sns:Publish already exists)
ses.create_configuration_set_event_destination(
    ConfigurationSetName=CONFIG_SET,
    EventDestinationName="sns-all-events",
    EventDestination={
        "Enabled": True,
        "MatchingEventTypes": ["DELIVERY", "BOUNCE", "COMPLAINT", "OPEN", "CLICK"],
        "SNSDestination": {"TopicARN": topic_arn},
    },
)

# 4. Send with a correlation tag
ses.send_email(
    FromEmailAddress="noreply@example.com",
    Destination={"ToAddresses": ["success@simulator.amazonses.com"]},
    ConfigurationSetName=CONFIG_SET,
    EmailTags=[{"Name": "order_id", "Value": "ord_8842"}],
    Content={
        "Simple": {
            "Subject": {"Data": "Test send"},
            "Body": {"Text": {"Data": "Hello from SES."}},
        }
    },
)

# 5. Poll and print parsed events
for msg in sqs.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=10).get("Messages", []):
    body = json.loads(msg["Body"])
    print(parse_ses_event(body))
    sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"])

Troubleshooting Common Problems

Symptom Likely cause
No open events Tracking not enabled on the configuration set
No events at all Destination in a different region, IAM role missing sns:Publish, or SNS subscription unconfirmed
Duplicate events At-least-once delivery; dedupe key missing
ConfigurationSetDoesNotExist Name or region mismatch between create and send
Events arrive but messageId is unknown Tags not set on the send

When to Use SES Events vs. a Managed API

SES is genuinely strong when you already run on AWS, want pay-as-you-go pricing with no upfront commitment, and need fine-grained control over where events land. As of 2026-10-19, the AWS SES pricing page lists three plans — Essentials, Pro, and Enterprise — at $0.16, $0.22, and $0.23 per 1,000 emails respectively for 0–10M emails per month, with Pro at $105 and Enterprise at $500 per account per region per month. Tiered rates apply marginally, and volume tiers apply independently to outbound and inbound. Check the official pricing page before you commit, since rates and plan rules change.

The trade-off is that you own the pipeline. SNS topics, IAM roles, dedupe stores, backoff logic, and monitoring are all yours to build and maintain. If your team would rather not run that infrastructure, a managed transactional API such as Mailerflow handles signed webhooks with retries and delivery logs out of the box, and its developer docs and API cover scoped keys, idempotency, and an OpenAPI spec. Neither path is wrong — it depends on whether AWS-native routing is a requirement or a convenience.

FAQ

Do configuration sets cost extra? No. They are a feature of SES. You pay for the emails you send and for the SNS, SQS, Firehose, or CloudWatch resources you route events into.

Can I use one configuration set for multiple domains? Yes, but tracking settings and event routing apply to every send that names it, across all your identities.

Why am I not seeing open events? Tracking must be enabled on the configuration set, and even then opens are approximate because of image proxying and Apple Mail Privacy Protection.

Can I have more than one event destination? Yes. A single configuration set can publish to SNS, Firehose, EventBridge, and CloudWatch simultaneously.

If you would rather skip the queue and IAM plumbing, send your first transactional email with the Mailerflow REST API — scoped keys, idempotency, and an OpenAPI spec, free to start.