Handling Hard Bounces with Amazon SES and SNS
Configure an SES configuration set, SNS topic, and HTTPS/Lambda subscriber to parse bounce JSON, suppress Permanent recipients, and stay under SES bounce-rate thresholds.
If you send through Amazon SES, hard bounces are not just wasted messages — they are the metric most likely to get your account paused. SES does not push bounces to a webhook; it publishes them to SNS, and unless you subscribe to that topic and process the notifications yourself, you have no idea which addresses are dead. This guide wires the full path: SES → SNS → subscriber → suppression store.
Root cause: SES enforces a bounce-rate threshold you must police yourself
SES tracks your account's bounce rate on a rolling basis and acts on it automatically: cross roughly 5% and your account is flagged for review with a warning; cross roughly 10% and SES can pause your sending entirely. These numbers are SES-specific — they are how AWS protects the shared IP reputation of the SES platform — and they are not negotiable through support if you keep bouncing.
The catch is that SES will happily accept a SendEmail call for an address that bounced an hour ago. SES does maintain an account-level suppression list, but it only auto-adds addresses after it sees the bounce, and only if the feature is enabled — and it does nothing to stop you re-sending to addresses you discovered are bad through some other channel. The durable fix is to consume the bounce notifications, classify Permanent recipients, and add them to a suppression store that your sender checks before every call. That suppression store and the hard-vs-soft classification logic are covered in bounce and complaint handling; this page is the SES-specific plumbing that feeds it.
Exact fix: configuration set → SNS topic → subscriber
SES emits bounce events only when the sending identity (or the API call) is attached to a configuration set that has an event destination pointing at an SNS topic. The wiring, in order:
- Create an SNS topic, e.g.
ses-bounces. - Create a configuration set and add an event destination of type SNS, subscribed to the
Bounce(andComplaint) event types, targeting that topic. - Send with the configuration set attached — via the
X-SES-CONFIGURATION-SETheader or theConfigurationSetNameparameter on the SES v2 API. - Subscribe an HTTPS endpoint or a Lambda to the topic and process the JSON.
# 1. Create the SNS topic (AWS CLI)
aws sns create-topic --name ses-bounces
# 2. Create the configuration set and point a Bounce/Complaint destination at SNS.
# SES v2: the event destination must list the event types explicitly.
aws sesv2 create-configuration-set --configuration-set-name txn-mail
aws sesv2 create-configuration-set-event-destination \
--configuration-set-name txn-mail \
--event-destination-name to-sns \
--event-destination '{
"Enabled": true,
"MatchingEventTypes": ["BOUNCE","COMPLAINT"],
"SnsDestination": {"TopicArn": "arn:aws:sns:us-east-1:123456789012:ses-bounces"}
}'
When you send, attach the configuration set or no events are published at all — the single most common reason "SES bounces aren't arriving":
import boto3
ses = boto3.client("sesv2", region_name="us-east-1")
ses.send_email(
FromEmailAddress="noreply@example.com",
Destination={"ToAddresses": ["customer@example.com"]},
# Without this, SES sends the mail but emits NO bounce/complaint events to SNS:
ConfigurationSetName="txn-mail",
Content={"Simple": {
"Subject": {"Data": "Your receipt"},
"Body": {"Html": {"Data": "<p>Thanks for your order.</p>"}},
}},
)
The SNS message envelope and the SES bounce JSON inside it
SNS wraps your payload. An HTTPS subscriber first receives a SubscriptionConfirmation (you must GET its SubscribeURL once to confirm), and thereafter receives Notification envelopes whose Message field is a JSON string you must parse a second time. Inside is the SES bounce object:
{
"notificationType": "Bounce",
"bounce": {
"bounceType": "Permanent",
"bounceSubType": "General",
"bouncedRecipients": [
{ "emailAddress": "customer@example.com",
"status": "5.1.1",
"diagnosticCode": "smtp; 550 5.1.1 user unknown" }
],
"timestamp": "2026-06-19T12:00:00.000Z",
"feedbackId": "0102018f...-000000"
},
"mail": {
"messageId": "0102018f...-abc",
"source": "noreply@example.com",
"destination": ["customer@example.com"]
}
}
The two fields that drive the decision are bounce.bounceType (Permanent = suppress, Transient = retry/soft, Undetermined = treat conservatively as permanent) and bounce.bouncedRecipients[].emailAddress. A single bounce notification can name multiple recipients, so always iterate the array.
import json
import urllib.request
def handle_sns_notification(raw_body: str, store) -> None:
envelope = json.loads(raw_body) # outer SNS envelope
# SNS sends a confirmation the first time; confirm it once, then return.
if envelope.get("Type") == "SubscriptionConfirmation":
urllib.request.urlopen(envelope["SubscribeURL"]).read() # one-time GET
return
if envelope.get("Type") != "Notification":
return
# IMPORTANT: SES bounce JSON is a STRING inside Message — parse it again.
msg = json.loads(envelope["Message"])
if msg.get("notificationType") == "Bounce":
b = msg["bounce"]
for r in b["bouncedRecipients"]:
addr = r["emailAddress"]
if b["bounceType"] in ("Permanent", "Undetermined"):
# Permanent = dead address. Add to YOUR suppression store immediately
# so the next send_email() for this address is skipped.
store.suppress(addr, reason="hard_bounce", source="ses",
detail=f'{r.get("status","")} {r.get("diagnosticCode","")}')
# bounceType == "Transient": let your queue retry with backoff instead.
elif msg.get("notificationType") == "Complaint":
for r in msg["complaint"]["complainedRecipients"]:
# Complaints never retry — suppress and stop non-essential mail.
store.suppress(r["emailAddress"], reason="complaint", source="ses")
In a real HTTPS subscriber you must also verify the SNS message signature (SigningCertURL + Signature) before trusting the body — otherwise anyone who finds your endpoint can forge a bounce and suppress your customers.
Verifying the SNS signature correctly
SNS signs each message with an RSA private key and gives you a SigningCertURL pointing at the matching X.509 certificate, plus a Signature and a SignatureVersion. Verification has three parts that are each a common failure point:
- Validate the
SigningCertURLhost before fetching it. This is the security-critical step most hand-rolled implementations skip. The URL must be an Amazon-controlled host — confirm it parses tohttpsand the host ends in.amazonaws.com(region-appropriate, e.g.sns.us-east-1.amazonaws.com). Without this check, an attacker sends a forged message pointingSigningCertURLat a certificate they control, and your verifier "succeeds" against the wrong key. - Build the canonical string in the exact field order SNS specifies, which differs between a
Notificationand aSubscriptionConfirmation, then verify the base64Signatureagainst the cert's public key using SHA1 (SignatureVersion1) or SHA256 (SignatureVersion2). - Cache the fetched certificate keyed by URL so you are not making an outbound HTTPS call on every notification.
Do not write this canonical-string logic by hand — use the official AWS SNS message-validation library for your language, which encodes the field order and the cert-host check for you:
# Verify the SNS signature with the maintained library, not hand-rolled canonicalization.
# pip install sns-message-validator
from sns_message_validator import SNSMessageValidator
_validator = SNSMessageValidator() # enforces the *.amazonaws.com SigningCertURL host check
def verify_and_handle(raw_body: str, store) -> None:
envelope = json.loads(raw_body)
# Raises if the signature is invalid OR the SigningCertURL host is not Amazon's.
_validator.validate_message(message=envelope)
handle_sns_notification(raw_body, store) # only reached for authentic SNS messages
If you must verify manually, the non-negotiable rule is the cert-host allowlist: reject any SigningCertURL whose host is not an sns.<region>.amazonaws.com address before you fetch it.
Also enable the SES account-level suppression list
Belt and suspenders: turn on SES's own account-level suppression so SES itself refuses to send to addresses it has recorded as permanent bounces or complaints, independent of your store.
# SES will now block send_email() to addresses on its own suppression list,
# returning a MessageRejected error instead of attempting delivery.
aws sesv2 put-account-suppression-attributes \
--suppressed-reasons BOUNCE COMPLAINT
This protects you during the window before your handler processes a notification, but it is per-region and SES-only — your application-level store is still what protects a second sending channel.
Variant: SNS → SQS → worker for durability
An HTTPS subscriber that is down or slow can drop notifications: SNS retries on a schedule and then gives up. For at-least-once durability, subscribe an SQS queue to the topic and run a worker that pulls from SQS, so a deploy or outage cannot lose bounces.
import json, boto3
sqs = boto3.client("sqs", region_name="us-east-1")
QUEUE_URL = "https://sqs.us-east-1.amazonaws.com/123456789012/ses-bounces-q"
def drain_bounce_queue(store) -> None:
resp = sqs.receive_message(QueueUrl=QUEUE_URL, MaxNumberOfMessages=10,
WaitTimeSeconds=20) # long-poll
for m in resp.get("Messages", []):
# SQS body is the SNS envelope; reuse the same handler.
handle_sns_notification(m["Body"], store)
# Delete only AFTER successful processing → at-least-once, never lost.
sqs.delete_message(QueueUrl=QUEUE_URL, ReceiptHandle=m["ReceiptHandle"])
Choose by trust model: account-level SES suppression is automatic and zero-maintenance but SES-only and region-scoped; a custom store driven by SNS→SQS is portable across providers and lets you attach your own retry and reporting. Most teams run both.
The durability win of the SQS variant is concrete. With a bare HTTPS subscriber, SNS retries a failed POST on a backoff schedule and then gives up — if your endpoint is down through a deploy window longer than the retry horizon, those bounces are gone, and the only recovery is a slow reconciliation poll of the SES suppression list. With SQS in the path, SNS only has to reach the queue (a highly available AWS service), and your worker can be down for hours: messages wait in the queue, and when the worker comes back it drains the backlog. Wire it with the queue's redrive policy so a message that repeatedly fails processing lands in a dead-letter queue rather than blocking the main queue:
# Subscribe the SQS queue to the SES bounce topic, and set a redrive policy so
# poison notifications move to a DLQ after 5 failed receives instead of looping forever.
aws sns subscribe --topic-arn arn:aws:sns:us-east-1:123456789012:ses-bounces \
--protocol sqs \
--notification-endpoint arn:aws:sqs:us-east-1:123456789012:ses-bounces-q
# RedrivePolicy on the queue (set via set-queue-attributes) points failures at the DLQ:
# {"deadLetterTargetArn":"arn:aws:sqs:us-east-1:123456789012:ses-bounces-dlq","maxReceiveCount":"5"}
One subtlety with SES → SNS → SQS: by default SQS receives the full SNS envelope as the message body (the same Type/Message JSON the HTTPS path sees), which is why drain_bounce_queue above reuses handle_sns_notification. If you instead enable raw message delivery on the subscription, SQS receives the inner SES JSON directly with no SNS wrapper — convenient, but then you lose the SNS signature fields, so only enable it when the SQS queue itself is your trust boundary (private, IAM-restricted) rather than verifying signatures.
Pipeline integration
Because SQS gives at-least-once delivery, the same bounce can arrive twice — deduplicate on mail.messageId (or bounce.feedbackId) before acting, and make store.suppress idempotent. This is exactly the idempotent-consumer pattern described in building an idempotent webhook consumer. Keep the SNS/SQS worker stateless so it scales horizontally, and push your daily bounce rate to CloudWatch with an alarm well below the 5% SES review line.
Validation & deployment checklist
- Configuration set created with an SNS event destination for
BOUNCEandCOMPLAINT. - Every
send_emailcall attaches the configuration set (header orConfigurationSetName). - HTTPS subscriber confirms the one-time
SubscriptionConfirmation. - Handler double-parses: outer SNS envelope, then the
MessageJSON string. - SNS message signature is verified before the body is trusted.
PermanentandUndeterminedrecipients are suppressed;Transientare routed to retry.- SES account-level suppression enabled for
BOUNCEandCOMPLAINT. - (Durability) SNS → SQS → worker with delete-after-process and dedup on
messageId. - CloudWatch alarm fires below the 5% bounce-rate review threshold.
Related
- Bounce and Complaint Handling — the suppression store and hard-vs-soft classification this handler feeds
- Processing Feedback Loops and Spam Complaints — the complaint side of the same SNS topic
- Building an Idempotent Webhook Consumer — dedup notifications that SQS may deliver more than once
← Back to Bounce and Complaint Handling