Amazon SES: Handling Bounces, Complaints & Suppression

Blog / Amazon Web Services · June 25, 2021 · Updated June 10, 2026 · 8 min read
Amazon SES: Handling Bounces, Complaints & Suppression

Amazon SES holds every sender to two hard limits: a bounce rate under 5% and a complaint rate under 0.1%. Cross them and AWS places your account under review; push higher (roughly 10% bounces or 0.5% complaints) and your sending can be paused outright. Handling bounces and complaints is therefore not optional housekeeping — it is what keeps your SES account alive and your mail landing in the inbox.

This guide covers the modern (2026) approach: capture bounce and complaint feedback with configuration sets + event destinations publishing to SNS, process the events in a Lambda with Python 3 / boto3, and keep your list clean with the SES account-level suppression list. If you have not set up sending yet, start with our companion walkthrough on how to send and receive email in Django with Amazon SES and come back here to harden it.

Why bounces and complaints decide your SES fate

SES continuously measures your sender reputation and shows it on the Reputation metrics page of the SES console (and via CloudWatch). Two numbers matter most:

  • Bounce rate — the share of messages that could not be delivered. Keep it below 5%. At 5% your account is reviewed; sustained rates near 10% lead to a sending pause.
  • Complaint rate — the share of recipients who hit "report spam". Keep it below 0.1% (very low). At 0.1% you are reviewed; near 0.5% sending can be paused.

A complaint is a spam report: when a recipient marks your mail as junk, the mailbox provider relays that through a feedback loop to SES, which forwards it to you. Both signals roll up into the reputation dashboard, so the goal is simple — stop mailing addresses that bounce hard or complain, fast and automatically.

Bounce types: hard vs soft vs transient

SES classifies every bounce so you know whether to give up on an address or retry later. Treat each type differently:

Bounce type What it means What you should do
Hard (Permanent) The address or domain does not exist, or the server permanently rejected it. It will never succeed. Stop sending immediately. SES auto-adds it to the suppression list; mirror that in your own DB.
Soft (Transient) A temporary problem — mailbox full, server down, message too large, or you were throttled. SES retries for a period. Do not suppress on a single soft bounce; track repeats and suppress only after persistent failures.
Undetermined SES could not classify the failure. Treat cautiously: log it, monitor the address, and suppress if it keeps failing.

Loosely, a "hard bounce" is a Permanent bounce and a "soft bounce" is a Transient one.

How to capture feedback: the modern path

The legacy approach (set_identity_notification_topic, email feedback forwarding) still works but is hard to automate. The recommended 2026 pattern is configuration sets with event destinations — they give you structured, near-real-time JSON for every event stream.

Mechanism How it works When to use
Config set event destination, SNS Per-event JSON pushed to an SNS topic; subscribe a Lambda, HTTPS endpoint, or SQS queue. The production default for bounce/complaint automation.
Event destination, Kinesis Data Firehose Streams events in batches to S3, Redshift, or OpenSearch. High volume and analytics/warehousing.
Event destination, CloudWatch Emits metrics you can alarm on. Alerting when bounce/complaint rates climb.
Email feedback forwarding SES forwards bounces/complaints as plain email to your Return-Path. Simplest, but no automation; turn it off once SNS is wired up.

Step 1 — create a configuration set and point it at SNS

Create an SNS topic first, then attach a configuration set whose event destination publishes bounce and complaint events to it. With boto3 and the SESv2 client:

import boto3

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

# 1. Create a configuration set for this mail stream.
ses.create_configuration_set(ConfigurationSetName="transactional")

# 2. Publish bounce + complaint (and more) events to an SNS topic.
ses.create_configuration_set_event_destination(
    ConfigurationSetName="transactional",
    EventDestinationName="bounce-complaint-sns",
    EventDestination={
        "Enabled": True,
        "MatchingEventTypes": ["BOUNCE", "COMPLAINT", "DELIVERY", "REJECT"],
        "SnsDestination": {
            "TopicArn": "arn:aws:sns:us-east-1:123456789012:ses-events",
        },
    },
)

Then send through that configuration set so the events are emitted — pass ConfigurationSetName="transactional" on every send_email call. Subscribe a Lambda function directly to the SNS topic (no confirmation handshake needed). If you instead point SNS at an HTTPS endpoint, SES/SNS first POSTs a SubscriptionConfirmation message — you must visit its SubscribeURL once to activate the subscription.

Step 2 — know the event JSON

When an event destination delivers to SNS, the SES event JSON arrives as the Message field of the SNS envelope. A bounce event looks like this:

{
  "eventType": "Bounce",
  "bounce": {
    "bounceType": "Permanent",
    "bounceSubType": "General",
    "bouncedRecipients": [
      {
        "emailAddress": "bounce@simulator.amazonses.com",
        "action": "failed",
        "status": "5.1.1",
        "diagnosticCode": "smtp; 550 5.1.1 user unknown"
      }
    ],
    "timestamp": "2026-06-10T07:58:38.130Z",
    "feedbackId": "0102018f3a...-000000",
    "reportingMTA": "dsn; a6-178.smtp-out.us-east-1.amazonses.com"
  },
  "mail": {
    "timestamp": "2026-06-10T07:58:37.000Z",
    "source": "noreply@yourdomain.com",
    "messageId": "0102018f3a...",
    "destination": ["bounce@simulator.amazonses.com"],
    "tags": { "ses:configuration-set": ["transactional"] }
  }
}

A complaint event is similar but carries complainedRecipients and a complaintFeedbackType:

{
  "eventType": "Complaint",
  "complaint": {
    "complainedRecipients": [
      { "emailAddress": "complaint@simulator.amazonses.com" }
    ],
    "complaintFeedbackType": "abuse",
    "timestamp": "2026-06-10T07:58:39.000Z",
    "feedbackId": "0102018f3a...",
    "userAgent": "Amazon SES Mailbox Simulator"
  },
  "mail": {
    "timestamp": "2026-06-10T07:58:37.000Z",
    "source": "noreply@yourdomain.com",
    "messageId": "0102018f3a...",
    "destination": ["complaint@simulator.amazonses.com"]
  }
}

Tip: test end-to-end with the SES mailbox simulator addresses bounce@simulator.amazonses.com and complaint@simulator.amazonses.com — they trigger events without hurting your real reputation.

Step 3 — process events in a Lambda

Subscribe this Python 3 function to the SNS topic. It parses each event, suppresses hard bounces and complaints, and merely logs soft bounces. This is the same event-driven pattern we use elsewhere — see using AWS Lambda with S3 and DynamoDB for the broader trigger model.

import json
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def lambda_handler(event, context):
    """Handle SES bounce/complaint events delivered via SNS."""
    for record in event["Records"]:
        message = json.loads(record["Sns"]["Message"])
        event_type = message.get("eventType")

        if event_type == "Bounce":
            bounce = message["bounce"]
            if bounce["bounceType"] == "Permanent":           # hard bounce
                for r in bounce["bouncedRecipients"]:
                    suppress(r["emailAddress"], reason="hard_bounce")
            else:                                             # transient / undetermined
                logger.info("Soft bounce, not suppressing: %s",
                            bounce["bouncedRecipients"])

        elif event_type == "Complaint":
            for r in message["complaint"]["complainedRecipients"]:
                suppress(r["emailAddress"], reason="complaint")

    return {"statusCode": 200}


def suppress(email, reason):
    """Flag the address in your own DB and stop emailing it."""
    logger.info("Suppressing %s (%s)", email, reason)
    # e.g. UPDATE subscriber SET status = 'suppressed' WHERE email = %s

The SES account-level suppression list

SES keeps an account-level suppression list and, by default, automatically adds any address that hard-bounces or complains. While an address is on it, SES silently drops messages to it — protecting your reputation even if a stale address slips into a send. You manage it with the SESv2 API:

import boto3

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

# Manually add an address (Reason: "BOUNCE" or "COMPLAINT").
ses.put_suppressed_destination(
    EmailAddress="bademail@example.com",
    Reason="BOUNCE",
)

# Check whether one address is suppressed.
ses.get_suppressed_destination(EmailAddress="bademail@example.com")

# List everything currently suppressed (paginate for big lists).
paginator = ses.get_paginator("list_suppressed_destinations")
for page in paginator.paginate():
    for entry in page["SuppressedDestinationSummaries"]:
        print(entry["EmailAddress"], entry["Reason"])

# Remove an address — e.g. the user re-confirmed they want mail.
ses.delete_suppressed_destination(EmailAddress="bademail@example.com")

Two important points:

  • Account vs. your own list. The SES list protects SES; you still need your application-level suppression (a status column on your subscriber table) so the rest of your stack — billing reminders, password resets, marketing — also stops targeting that person. The Lambda above writes to both.
  • Configure the auto-add behaviour. Use put_account_suppression_attributes (account-wide) or put_configuration_set_suppression_options (per stream) to choose which reasons — BOUNCE, COMPLAINT, or both — auto-populate the list. Whatever you do, never keep sending to a suppressed address.

Deliverability best practices

Reacting to bounces is half the job; the other half is not generating them:

  • Authenticate your domain. Set up DKIM, SPF, and DMARC so mailbox providers trust you — covered in the SES send/receive setup guide.
  • Use double opt-in and confirm new addresses, so invalid or trap addresses never enter your list.
  • Honour unsubscribes instantly and add a one-click unsubscribe (RFC 8058 List-Unsubscribe + List-Unsubscribe-Post headers) — now required by Gmail and Yahoo for bulk senders.
  • Practise list hygiene. Periodically remove long-inactive and repeatedly soft-bouncing addresses.
  • Separate configuration sets per stream (transactional vs. marketing) so a noisy campaign cannot drag down transactional reputation.
  • Dedicated vs. shared IPs. Shared IPs are fine for low/spiky volume; a dedicated IP (warmed up gradually) gives you full control once you send consistently high volume.
  • Use IAM roles, never hardcoded keys. Run your sender and Lambda under an IAM role scoped to ses:SendEmail, ses:PutSuppressedDestination, and friends — never embed access keys in code. See our AWS IAM roles and policies guide.

Getting SES right in production

Bounce and complaint handling is one of those things that looks trivial in a demo and bites in production — a single forgotten suppression check can tank a reputation built over months. MicroPyramid has spent 12+ years and 50+ projects building on AWS, wiring SES feedback loops, suppression automation, and deliverability monitoring into real applications. If you want a second set of eyes on your email pipeline or your wider cloud setup, our AWS consulting services and cloud migration services cover exactly this kind of work.

Frequently Asked Questions

What bounce and complaint rates are safe on Amazon SES?

Keep your bounce rate under 5% and your complaint rate under 0.1%. At 5% bounces or 0.1% complaints your account is placed under review; sustained rates near 10% bounces or 0.5% complaints can get your sending paused. SES shows both on the Reputation metrics page.

How do I get notified of bounces and complaints?

Create a configuration set with an event destination that publishes BOUNCE and COMPLAINT events to an SNS topic, then subscribe a Lambda (or HTTPS/SQS endpoint) to that topic. Send your mail through that configuration set so the events are emitted.

What is the SES account-level suppression list?

It is a list SES maintains for your account that, by default, auto-adds any address that hard-bounces or complains. While an address is on it, SES silently drops mail to it. Manage it with the SESv2 API — put_suppressed_destination, get_suppressed_destination, list_suppressed_destinations, and delete_suppressed_destination.

What is the difference between a hard and a soft bounce?

A hard (Permanent) bounce means the address will never accept mail — stop sending immediately. A soft (Transient) bounce is temporary (full mailbox, throttling, server down); SES retries, so do not suppress on a single soft bounce — only after repeated failures.

Why was my SES sending paused?

Almost always because your bounce or complaint rate crossed a threshold and AWS placed your account under review or enforcement. Fix the root cause (clean your list, stop mailing suppressed addresses, authenticate your domain), then reply to the AWS review with your remediation steps.

How should I handle a spam complaint?

Treat a complaint like a hard bounce: immediately stop all mail to that address — add it to both the SES suppression list and your own subscriber table. Complaints weigh far more heavily than bounces, so even a small number needs prompt action.

Share this article