Amazon SES Soft Bounces: Sub-types and Retry Logic

Amazon SES Soft Bounces: Sub-types and Retry Logic

Why Soft Bounces Are Misunderstood

Most guides to Amazon SES bounce handling spend two paragraphs on soft bounces, note that they are "temporary", and move on. That is a mistake. Poorly handled soft bounces quietly damage list hygiene, waste sending quota, and, in the case of certain sub-types, signal to receiving mail servers that your infrastructure is not well maintained. Understanding what Amazon SES does automatically, what it leaves entirely to you, and how to read every field in the SNS payload is the difference between a robust sending pipeline and one that slowly erodes your sender reputation without triggering a single hard-bounce alert.

Hard Bounces vs Soft Bounces: What SES Counts

The distinction matters immediately because of how Amazon SES calculates your bounce rate. Hard bounces are persistent delivery failures: the recipient address does not exist, the domain is invalid, or the receiving server has permanently rejected your mail. Soft bounces are temporary failures, such as a full mailbox, a throttled connection, or a content filter that may be revisited on retry.

Only hard bounces count towards your bounce rate metric as reported in the SES console and returned by the GetSendStatistics API. Soft bounces that SES eventually stops retrying do appear in SNS notifications, but they do not move your visible bounce rate. This creates a blind spot: you can accumulate significant soft-bounce volume on a particular domain or address segment without your top-level metric showing any sign of stress.

AWS recommends maintaining a hard-bounce rate below 2% for best results. A rate at or above 5% triggers an account review, and a rate at or above 10% may cause SES to pause your account's ability to send email entirely. Because soft bounces are invisible in that figure, teams often ignore them, which is precisely when they become dangerous.

How SES Retries Soft Bounces Internally

When a soft bounce occurs, Amazon SES retries delivery automatically. The retry window lasts up to 12 hours. During that window there is no fixed cap on the number of attempts; SES continues trying as long as the receiving server is responsive and returning temporary error codes. If the message is successfully delivered during any retry, you receive a delivery notification through SNS. If SES exhausts the retry window without success, it fires a single SNS notification with a bounceType of Transient, indicating that SES has given up and that the responsibility now passes to your application.

This is the moment most developers misread. The SNS notification arrives after SES has already completed its retry cycle. It is not an invitation to immediately re-queue the message using the same sending path; SES has already tried that for up to 12 hours. What the notification tells you is that a problem persists beyond a routine transient window, and your application logic must decide what happens next. The SES retry mechanism for soft bounces cannot be disabled; it is a fixed platform behaviour.

The SNS Bounce Payload: Reading the Key Fields

Bounce, complaint, and delivery notifications from SES are published to Amazon SNS topics in JSON format. The top-level object contains a notificationType string (with the value "Bounce" for bounce events), a mail object containing metadata about the original message, and a bounce object that carries the classification data you need. Your application code must parse both the mail and bounce objects, because SES does not guarantee ordering or batching and may include multiple recipients in a single notification.

Within the bounce object, three fields drive all downstream logic. The bounceType field is either Permanent, Transient, or Undetermined. The bounceSubType field narrows the classification within that type. The diagnosticCode field, when present, contains the raw SMTP status string returned by the receiving mail transfer agent. A minimal soft-bounce payload for a full mailbox looks like this:

{ "notificationType": "Bounce", "bounce": { "bounceType": "Transient", "bounceSubType": "MailboxFull", "bouncedRecipients": [{ "emailAddress": "user@example.com", "status": "4.2.2", "diagnosticCode": "smtp; 452 4.2.2 Mailbox full" }], "timestamp": "2025-09-01T10:23:45.000Z", "feedbackId": "..." }, "mail": { "messageId": "...", "destination": ["user@example.com"] } }

Your Lambda handler, SQS consumer, or HTTPS endpoint must always inspect both bounceType and bounceSubType together. Relying on bounceType alone loses the actionable detail that sub-types provide, and different sub-types require entirely different responses from your application.

Every Transient BounceSubType Explained

MailboxFull

This is the most common soft-bounce sub-type. The receiving server accepted the connection and identified the recipient account but refused the message because the mailbox storage quota was exceeded. The SMTP 4xx response code, typically 452 4.2.2, confirms this. The recipient address itself is valid. In most cases the user will eventually clear space and future delivery attempts will succeed. However, if an address returns MailboxFull repeatedly over several weeks it is often a sign that the account is dormant or abandoned, and escalation becomes appropriate.

MessageTooLarge

The receiving server accepted the connection but rejected the specific message because it exceeded the server's size limit. This is fundamentally different from MailboxFull: the address is valid and deliverable for other messages, but this particular message will never be accepted without modification. Retrying the same message body to the same address is pointless. The correct action is to fix the message, typically by reducing attachment size or stripping large inline assets, and then resend. This is one of the sub-types that requires content intervention before any retry is worthwhile.

ContentRejected

The receiving server's content-filtering layer refused the message. This can be caused by a spam trigger in the message body, a blocked URL pattern, a problematic HTML structure, or a policy on the receiving domain that rejects certain content categories. Like MessageTooLarge, retrying the same content to the same address serves no purpose and may accelerate rate-limiting or reputation damage on that domain. The message content must be reviewed and corrected before a retry is attempted. If ContentRejected is occurring across multiple recipients on the same domain, it is a strong signal that your message template needs structural review.

AttachmentRejected

A variant of content rejection where the specific trigger is an attachment rather than the message body. Common causes include file types that the receiving domain blocks by policy (executables, certain archive formats, or macro-enabled Office documents), attachments that exceed a per-file size limit, or files that triggered a malware scanner. As with ContentRejected, the message must be changed before any retry. A practical engineering solution is to move the attachment to a hosted link and resend without the file attached.

Undetermined

SES uses the Undetermined bounceSubType when the bounce response from the receiving server is ambiguous or does not map cleanly to any named category. The receiving server may have returned a non-standard SMTP response, an unexpected status code, or a diagnostic message that SES cannot classify. The diagnosticCode field is especially important here: read it directly to understand what the remote server actually said. Treat Undetermined bounces as soft bounces for initial handling, apply a short retry hold, and watch for recurrence. If an address consistently produces Undetermined bounces, apply the same escalation logic you would use for a persistent MailboxFull.

Correct Sender Action Per Sub-type

For MailboxFull and Undetermined, hold the address for a cooling-off period of 24 to 72 hours before queuing a retry, and maintain a counter of consecutive failures. For MessageTooLarge, do not retry at all until the message itself has been fixed; the address is not at fault. For ContentRejected and AttachmentRejected, quarantine the message for manual review, correct the content issue, then resend. Do not suppress the address unless the same content delivers successfully to other recipients on the same domain, which would indicate a domain-level block. If an address is returning ContentRejected consistently while other addresses on that domain receive successfully, begin treating that address as domain-blocked and consider escalating to permanent suppression.

Building a Persistent Soft-Bounce Counter

SES does not maintain a per-address soft-bounce history for you. That responsibility falls entirely to the sender. The recommended pattern is to increment a soft-bounce counter each time a final Transient notification arrives for a given address, and to reset that counter only when a successful Delivery notification is received for the same address.

A widely applied policy is to escalate after three to five consecutive soft-bounce notifications with no intervening delivery. The appropriate threshold depends on your sending frequency. If you send to a list weekly, five consecutive failures represents more than a month of unsuccessful attempts, which is a strong signal of abandonment. If you send daily, consider escalating after three consecutive failures. Upon escalation, treat the address as if it had hard-bounced: add it to your application-level suppression list and cease sending until the address is explicitly re-confirmed. You can also add it to the SES account-level suppression list via the PutSuppressedDestination API, which prevents SES from attempting delivery regardless of what your application queues.

For MessageTooLarge, ContentRejected, and AttachmentRejected, the counter-based threshold is less relevant because the issue is content-driven rather than address-driven. Track these separately and route them to a review queue rather than an address suppression workflow.

Edge Cases: Intermittent Soft Bounces on Active Addresses

The counter approach must account for intermittency. A real mailbox belonging to an active user may fill temporarily and generate a MailboxFull notification, then clear and resume receiving successfully. If your logic suppresses an address after any single soft-bounce notification, you will incorrectly remove genuine subscribers. This is why the reset-on-delivery logic matters: when a Delivery notification arrives for an address with a non-zero soft-bounce counter, reset the counter to zero. Only consecutive, unbroken failures should trigger escalation.

Similarly, a large email platform experiencing a temporary outage may return MailboxFull or Undetermined for thousands of addresses at the same time. If you see a spike in a particular sub-type concentrated on a single receiving domain within a narrow time window, treat it as an infrastructure event rather than an address quality issue. Apply a blanket retry hold for that domain rather than incrementing individual counters, and wait for the spike to resolve before resuming normal processing. This nuance is difficult to detect by looking at aggregate bounce rate in the SES console alone; per-domain, per-sub-type breakdowns in your own data store or in a dedicated monitoring tool are needed to catch it quickly.

Connecting the SNS Pipeline to Your Suppression List

The canonical architecture for processing SES bounce notifications is a Lambda function subscribed to an SNS topic, with an SQS queue as a buffer between SNS and Lambda to handle backpressure. When the Lambda receives a notification, it parses the JSON, extracts bounceType, bounceSubType, and the bouncedRecipients array, and applies the correct action against a suppression store. A DynamoDB table or a relational database both work well for this purpose.

A minimal Python handler illustrates the structure:

import json, boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('soft_bounce_counters')

def handler(event, context):
    for record in event['Records']:
        sns_body = json.loads(record['body'])
        message = json.loads(sns_body['Message'])
        if message.get('notificationType') != 'Bounce':
            return
        bounce = message['bounce']
        b_type = bounce.get('bounceType')
        b_sub = bounce.get('bounceSubType')
        for recipient in bounce.get('bouncedRecipients', []):
            email = recipient['emailAddress']
            if b_type == 'Permanent':
                suppress_permanently(email)
            elif b_type == 'Transient':
                if b_sub in ('MessageTooLarge', 'ContentRejected', 'AttachmentRejected'):
                    route_to_content_review(email, b_sub)
                else:
                    increment_soft_counter(email, table)

The increment_soft_counter function reads the current count for that address, increments it, and checks it against your configured threshold. If the threshold is breached, it calls suppress_permanently, which writes to your local suppression store and optionally calls the SES PutSuppressedDestination API to add the address to your account-level suppression list. Your code must also handle the Delivery notification type to reset counters: subscribe the same Lambda function, or a separate one, to delivery events and zero out the counter whenever a successful delivery is confirmed.

One important engineering note: your parser should be tolerant of unknown fields. SES reserves the right to add new fields to the notification schema, so use defensive property access rather than assuming a fixed structure.

Monitoring Soft-Bounce Trends Over Time

The SES console reputation dashboard and the GetSendStatistics API show your hard-bounce rate but do not break soft bounces down by sub-type, by recipient domain, or over time in a queryable form. CloudWatch alarms on the SES bounce rate metric are essential for hard-bounce protection, but they provide no visibility into soft-bounce volume or composition. This gap is significant: a rising tide of MailboxFull events on a key domain may indicate a list hygiene problem, a domain-side block, or a content change that is triggering deferrals, and none of those signals surface through standard SES metrics alone.

To monitor soft-bounce trends properly, your SNS consumer must write structured events to a persistent store that supports aggregation by sub-type, by sending identity, by recipient domain, and by time window. Querying this data weekly is the minimum; daily queries are better if you are sending at volume. Look specifically for increases in the ratio of ContentRejected to total soft bounces, which may indicate a content or reputation problem on a specific domain; for any address generating more than two consecutive MailboxFull notifications within a single week; and for Undetermined bounces concentrated on a single MX host, which can indicate an emerging block.

How SES Monitor Fills the Gap

Building and maintaining a custom soft-bounce analytics pipeline is feasible but expensive in engineering time. A dedicated tool such as SES Monitor surfaces soft-bounce sub-type breakdowns, trends by sending identity and recipient domain, and configurable alerts at thresholds that you define, without requiring you to build and maintain your own data warehouse on top of SNS events. Because soft bounces are excluded from the SES console's bounce rate metric, the console alone cannot alert you when a particular sub-type is accumulating. Continuous monitoring that distinguishes MailboxFull from ContentRejected from Undetermined, and that tracks these over time, is what allows you to intervene before a pattern of soft bounces begins affecting deliverability.

Soft-Bounce Policy: A Seven-Step Checklist

1. Configure SNS notifications for all bounce types on every sending identity. Without this, no downstream handling is possible.

2. Parse both bounceType and bounceSubType in every handler. Never act on bounceType alone.

3. Route content-driven sub-types (MessageTooLarge, ContentRejected, AttachmentRejected) to a review queue. Do not suppress the address; fix the content.

4. For MailboxFull and Undetermined, maintain a per-address consecutive-failure counter. Reset it to zero on any successful delivery event.

5. Escalate to permanent suppression after three to five consecutive Transient notifications with no intervening delivery. Write the address to both your application suppression list and the SES account-level suppression list via the PutSuppressedDestination API.

6. Watch for spike patterns that indicate domain-level events rather than individual address problems. Apply a domain-wide retry hold during infrastructure incidents rather than incrementing individual counters.

7. Monitor soft-bounce sub-type composition over time. The SES console reports hard-bounce rate only; everything else requires your own pipeline or a dedicated monitoring tool. Track ContentRejected and Undetermined ratios weekly and investigate any sustained upward trend before it compounds into a deliverability problem.

Never find out from AWS again
SES Monitor alerts you the moment bounces and complaints start arriving.
Start monitoring

Keep reading

All articles →
7 Aug 2026

Amazon SES Email Validation: Cut Bounces at Source

12 min read
4 Aug 2026

Amazon SES Sending Limits: Sandbox, Quotas and Scaling

13 min read
1 Aug 2026

Amazon SES DMARC: p=none to p=reject Safely

13 min read

Start protecting your SES reputation today

Connect your AWS SES account in a couple of minutes and get bounce and complaint alerts before a problem becomes a suspension.

2-minute setup · No contracts · Cancel anytime