Ops-Free is paused for now. You can look around and use the free tools, but sign-in and sending are turned off.

← All posts

Blog

How to handle Amazon SES bounces and complaints with SNS

October 6, 2026 · 9 min read

Amazon SES will happily send to an address that bounced yesterday, and keep doing it until your bounce rate gets the account reviewed. Handling bounces and complaints means getting SES to tell you what happened and acting on it. Here is the whole setup and what each piece is for.

Why this matters

Mailbox providers and AWS both watch two numbers: the share of your mail that bounces and the share that recipients mark as spam. AWS puts an account under review when the bounce rate reaches about 5% or the complaint rate about 0.1%, and can pause sending if it keeps climbing. Check AWS's current thresholds, but the lesson is the same: you need to know about every bounce and complaint and stop mailing those addresses.

How the pieces fit

  1. A configuration set with an event destination: tells SES to publish events for mail sent with that set.
  2. An SNS topic that SES publishes those events to.
  3. An HTTPS subscription: SNS posts each event to an endpoint of yours.
  4. Your endpoint, which verifies the message, reads the event and updates a suppression list.
  5. A check before every send against that list.

1. Create the topic and let SES publish to it

Create an SNS topic in the same region as your SES identity. Then attach a policy that lets SES publish to it, or the event destination in the next step will silently receive nothing:

SNS topic access policy
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "ses.amazonaws.com" },
    "Action": "sns:Publish",
    "Resource": "arn:aws:sns:us-east-1:111122223333:ses-events",
    "Condition": { "StringEquals": { "AWS:SourceAccount": "111122223333" } }
  }]
}

2. Create the configuration set

TypeScript, AWS SDK v3
import { SESv2Client, CreateConfigurationSetCommand,
  CreateConfigurationSetEventDestinationCommand } from "@aws-sdk/client-sesv2";

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

await ses.send(new CreateConfigurationSetCommand({ ConfigurationSetName: "app-events" }));

await ses.send(new CreateConfigurationSetEventDestinationCommand({
  ConfigurationSetName: "app-events",
  EventDestinationName: "to-sns",
  EventDestination: {
    Enabled: true,
    MatchingEventTypes: ["BOUNCE", "COMPLAINT", "DELIVERY"],
    SnsDestination: { TopicArn: "arn:aws:sns:us-east-1:111122223333:ses-events" },
  },
}));

Send BOUNCE, COMPLAINT and DELIVERY at a minimum. Delivery events let you confirm that mail arrived, not only that it failed.

3. Send with the configuration set

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

await new SESv2Client({ region: "us-east-1" }).send(new SendEmailCommand({
  FromEmailAddress: "hello@example.com",
  Destination: { ToAddresses: ["user@example.org"] },
  Content: { Simple: { Subject: { Data: "Hi" }, Body: { Text: { Data: "Hello" } } } },
  ConfigurationSetName: "app-events", // without this, no events are published
}));

The most common mistake

A configuration set only applies to messages that name it. Forget ConfigurationSetName on one code path and its bounces vanish. You can set a default configuration set on the identity to guard against this.

4. Subscribe your endpoint

Subscribe an HTTPS URL to the topic. SNS first sends a SubscriptionConfirmation message to that URL, and the subscription does nothing until you confirm it, either by calling ConfirmSubscription with the token or by fetching the SubscribeURL it contains. Only confirm messages that pass the check in the next step.

5. Verify every message

Your endpoint is a public URL, so anyone can post to it. SNS signs each message, and you must check the signature before trusting anything in it:

  • Require that SigningCertURL is HTTPS and its host is an official SNS host such as sns.us-east-1.amazonaws.com. Never fetch an arbitrary URL from the message.
  • Rebuild the string to sign from the fields AWS specifies for that message type, in the specified order, and check Signature against the certificate using the algorithm named by SignatureVersion.
  • Check that the topic ARN is the one you expect.

This is the part most home-built webhooks skip. Without it, anyone who finds the URL can suppress your customers or fake bounces.

6. Read the event

SNS wraps the SES event as a JSON string in the Message field. After you parse it you get something like:

Bounce event (shortened)
{
  "eventType": "Bounce",
  "mail": { "messageId": "0100018c-...", "destination": ["user@example.org"] },
  "bounce": {
    "bounceType": "Permanent",
    "bounceSubType": "General",
    "bouncedRecipients": [{ "emailAddress": "user@example.org" }]
  }
}
  • Permanent bounce: the address doesn't exist or can't receive mail. Suppress it.
  • Transient bounce (full mailbox, temporary failure): don't suppress on the first one. Retrying later can work.
  • Complaint: the recipient marked it as spam. Suppress it immediately, and never email them again.

Match each event to your own record of the email with mail.messageId, so you can show a user what happened to a message.

7. Suppress before you send

Store the suppressed addresses (lowercase them) and look each one up before every send, in every code path that sends. SES also keeps an account-level suppression list that it can fill automatically for bounces and complaints, which is a useful safety net. A list of your own gives you the history and lets you handle it per customer.

What else you'll want

  • Make the endpoint idempotent: SNS can deliver the same message more than once.
  • Return a 2xx quickly and do the heavy work afterwards, or SNS will retry.
  • Alert when your bounce or complaint rate rises, before AWS notices.
  • Get production access first if you haven't: the sandbox guide covers it.