Fix your AWS SES bounce and complaint rate before AWS pauses your account
An engineer's runbook for fixing AWS SES email deliverability issues before AWS places your account under review or pauses your sending — exact thresholds, the CLI to check them, a triage playbook for the first hour after a review notice, and the bounce and complaint handling pipelines so it doesn't happen again.

Kai Tanaka
If you're reading this, one of three things is true:
- AWS just sent you an email telling you your account is under review because of bounces or complaints. You're trying to figure out what happens next and how fast.
- AWS has already paused your sending. Production is broken. You opened a support case and you don't know what to put in it.
- Your reputation metrics are creeping in the wrong direction and you want to fix it before either of the above happens.
This post is the runbook for all three. It covers the exact thresholds AWS uses, how to check where you actually stand, a triage playbook for the first hour after a review notice, the bounce- and complaint-handling pipelines you need so this never happens again, and the template for what to write back to AWS support.
It assumes you're already in production SES (out of the sandbox) and you have IAM credentials with sesv2:* permissions on the account in question.
The thresholds AWS actually uses
There are two metrics AWS watches: bounce rate and complaint rate. Each has three states.
| Metric | Healthy | Under review | Sending pause |
|---|---|---|---|
| Bounce rate | < 5% (AWS recommends < 2%) | ≥ 5% | ≥ 10% |
| Complaint rate | < 0.1% | ≥ 0.1% | ≥ 0.5% |
You will see contradictory numbers on the open web — particularly a re:Post knowledge base article that says the complaint-rate pause threshold is 0.01%. The authoritative source is the SES Reputation metrics messages page in the SES Developer Guide, which states the review threshold is 0.1% and the pause threshold is 0.5%. Use those.
A few things to know about how these numbers behave in practice:
The rate is calculated over a representative volume, not a fixed time window. AWS does not say "your bounce rate over the last 24 hours". It picks a volume of email that "represents your typical sending practices" — different for every account, and it shifts as your sending patterns change. The practical implication: a low-volume sender who sends one bad batch can see their reported bounce rate stay elevated for days, because the representative window is large relative to their daily send. You cannot wait for "tomorrow" for a bad bounce rate to wash out.
The account-level suppression list does not count. Messages dropped to addresses on your own account-level suppression list don't go into Reputation.BounceRate or Reputation.ComplaintRate. They're counted in the absolute Bounce / Complaint metrics, but those are not the metrics AWS enforces against. This is the lever the rest of the post is built around.
The global suppression list does count. SES maintains an internal global suppression list across all customers. If you send to an address on it, that's a bounce that counts against your reputation. You can override it for specific addresses with your own account-level list, but you can't see or query the global one.
First: find out where you actually stand
Before you do anything else, get the real numbers. The console hides the precision you want.
# Current bounce rate, complaint rate, and account status
aws sesv2 get-account --query 'SendQuota'
aws sesv2 get-account --query 'EnforcementStatus'
# Or pull the raw CloudWatch metrics
aws cloudwatch get-metric-statistics \
--namespace AWS/SES \
--metric-name Reputation.BounceRate \
--start-time $(date -u -v-7d +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 3600 \
--statistics Maximum
aws cloudwatch get-metric-statistics \
--namespace AWS/SES \
--metric-name Reputation.ComplaintRate \
--start-time $(date -u -v-7d +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 3600 \
--statistics MaximumEnforcementStatus returns one of: HEALTHY, PROBATION, SHUTDOWN. If it's PROBATION or SHUTDOWN, the next steps matter a lot.
If you have multiple configuration sets, also pull the per-config-set reputation metrics. A single noisy product line can drag the whole account into review while the rest of your sending is fine:
aws sesv2 get-configuration-set-event-destinations \
--configuration-set-name <your-config-set>The first-hour triage playbook
You got the review email. Production is still sending (or has just been paused). What to do, in order.
1. Stop sending to your riskiest segments
The first thing AWS reads when you reply to a support case is whether you took action. If you keep firing the same payload that caused the problem, the reply gets ignored.
If you have any segment of your sending that you suspect is the source — re-engagement, scraped lists, anything older than 12 months, anything you imported from another provider — pause it from your application. Not at the SES layer, at the queue/job layer. You want the volume of messages going into SES to drop measurably, ideally within the next 30 minutes.
This is the single most underrated step. AWS support cases that say "we are investigating" but show the same outbound volume get dismissed.
2. Enable account-level suppression for both reasons
If your SES account was created after November 25, 2019, this is already on. If older, or if you're not sure, run it. It's idempotent.
aws sesv2 put-account-suppression-attributes \
--suppressed-reasons BOUNCE COMPLAINTFrom this point on, any address that bounces (permanent) or complains gets added to your account suppression list automatically. SES will refuse to send to it. Messages dropped this way do not count against your reputation. That's the entire point.
Verify:
aws sesv2 get-account --query 'SuppressionAttributes'You should see "SuppressedReasons": ["BOUNCE", "COMPLAINT"].
3. Bulk-suppress everything you already know is bad
You almost certainly have data in your own database showing bounces and complaints from the past — bounce notification emails, SNS notifications you logged, support tickets, unsubscribes. Pull all of them and load them into the SES suppression list. Even addresses that bounced six months ago should go in, because if any of them are still in your send list they're future bounces waiting to happen.
For under 100 addresses, you can use PutSuppressedDestination in a loop:
while read email; do
aws sesv2 put-suppressed-destination \
--email-address "$email" \
--reason BOUNCE
done < known-bounces.txtFor more than 100 addresses, use the bulk import path — newline-delimited JSON to S3 plus a CreateImportJob:
# bounces.json (one record per line, newline-delimited JSON)
{"emailAddress":"a@example.com","emailIdentity":{"action":"PUT","reason":"BOUNCE"}}
{"emailAddress":"b@example.com","emailIdentity":{"action":"PUT","reason":"BOUNCE"}}
aws s3 cp bounces.json s3://your-bucket/bounces.json
aws sesv2 create-import-job \
--import-destination 'SuppressionListDestination={SuppressionListImportAction=PUT}' \
--import-data-source 'S3Url=s3://your-bucket/bounces.json,DataFormat=JSON'Cap per import: 100,000 addresses. Concurrent jobs: 20.
4. Audit your bounce stream for the last 30–90 days
If you're already capturing bounce/complaint notifications via SNS (you should be — see the next section), aggregate them. You're looking for two things:
- Concentration. Are 80% of your bounces coming from one configuration set, one campaign, one signup source? That's your culprit.
- Type breakdown. SES classifies bounces into
Permanent,Transient, andUndetermined. OnlyPermanentbounces are unambiguous deliverability failures. A spike inPermanent.GeneralorPermanent.NoEmailmeans you have invalid addresses. A spike inTransient.MailboxFullis recoverable. A spike inPermanent.Suppressedmeans you're hitting the global suppression list — you need to stop sending to those addresses regardless.
If you don't have an SNS pipeline yet, the bounce headers in the bounce notification emails AWS sends to your account contact will tell you the same thing. Slower, but it's what you have.
Why your rate spiked (in rough order of frequency)
I'm not going to give you a generic "best practices" list. Here's what actually causes bounce and complaint rates to spike, ranked by how often I see each one as the root cause:
- You sent to an old list. Email addresses decay at roughly 25% per year. A list collected three years ago will have 50%+ invalid addresses. Re-engaging old lists is the #1 reason SES accounts get reviewed.
- You imported a list from a previous provider. SendGrid/Mailgun/Postmark suppression lists do not come with you. If you migrated and didn't import suppressions, the addresses they were silently filtering for you are now bouncing on SES.
- No double opt-in. Someone typed
jonh@gmial.cominto your signup form. That's a hard bounce on every email you ever send them. - You're not honoring unsubscribes fast enough. If "unsubscribe" takes more than one click, users hit the "spam" button instead. That's a complaint, not an unsubscribe. Complaints are 50× worse than unsubscribes for your reputation.
- You mixed transactional and marketing on the same identity. Marketing complaints poison transactional deliverability. Send them on separate configuration sets at minimum, separate identities ideally.
- You changed your From address or domain. Mailbox providers treat new sending identities with suspicion. A new domain with no warmup sending at production volume looks exactly like a spammer to Gmail.
- Spam-trap hits. If an address used to be real and was repurposed by Gmail/Microsoft as a spam trap, sending to it generates an invisible complaint. You only find out about spam-trap hits via DMARC aggregate reports or sudden, unexplained reputation drops.
Almost every account I've seen go into review fits one of (1)–(4).
Build a bounce-handling pipeline that actually works
Account-level suppression is the safety net. The pipeline below is what stops the problem at the application layer, before the safety net has to catch it.
Architecture
SES (SendEmail)
│
▼ (configuration set with event destination)
SNS topic
│
▼
SQS queue (for buffering + retry)
│
▼
Lambda (parse + suppress + record)
│
▼
Your application DB (suppression table)The SQS step is non-negotiable. Without it, a Lambda failure during a bounce burst loses the events forever.
SNS notification payload (what you actually parse)
{
"notificationType": "Bounce",
"bounce": {
"bounceType": "Permanent",
"bounceSubType": "General",
"bouncedRecipients": [
{
"emailAddress": "recipient@example.com",
"action": "failed",
"status": "5.1.1",
"diagnosticCode": "smtp; 550 5.1.1 The email account that you tried to reach does not exist."
}
],
"timestamp": "2026-05-13T10:00:00.000Z",
"feedbackId": "01000189..."
},
"mail": { "messageId": "...", "source": "you@example.com" }
}The decision logic:
| bounceType | What to do |
|---|---|
| Permanent | Add the address to your application suppression table immediately. Also call PutSuppressedDestination so SES drops future sends before they count against you. |
| Transient | Track count and last-bounce time. After N transient bounces in M days, suppress. (Reasonable starting point: 3 in 14.) |
| Undetermined | Track for visibility but don't auto-suppress. Review weekly. |
Within Permanent, the subtype matters:
General— invalid address. Suppress, no retry.NoEmail— the address has never existed. Suppress, but also audit your signup flow — these shouldn't be making it into your list.Suppressed— the address was on the SES global suppression list. Suppress and stop trying.OnAccountSuppressionList— your own list caught it. No further action needed, but if you're seeing these in volume your application is not checking suppressions before sending. Fix that.
Don't skip the application-level suppression table
The SES account suppression list is a safety net at the API boundary. By the time SES is dropping your message, you've already wasted the work of templating it, looking up the recipient, and making the API call. Your application should refuse to send before it ever gets to SES.
A minimal schema:
CREATE TABLE email_suppressions (
email TEXT PRIMARY KEY,
reason TEXT NOT NULL, -- 'bounce' | 'complaint' | 'manual'
bounce_subtype TEXT, -- nullable
suppressed_at TIMESTAMP NOT NULL DEFAULT NOW(),
source TEXT -- which campaign/config set caused it
);
CREATE INDEX idx_suppressions_suppressed_at ON email_suppressions (suppressed_at);Check this table before every send. It's a single index lookup and it saves you from your own mistakes.
Build a complaint-handling pipeline (same shape, different urgency)
Complaints are about 5× more reputation-damaging than bounces at the same rate (which is why the threshold is so much lower — 0.1% vs 5%). The pipeline is the same shape as bounces, but:
- Suppress on the first complaint, always. There is no "transient" complaint. If a recipient clicked "spam", you do not get a second chance with them.
- Investigate every complaint individually for the first 100. Look at which campaign, which template, which segment. Complaint patterns tell you exactly which content is over the line.
- Audit your unsubscribe flow. Most complaint spikes are users who couldn't find or didn't trust your unsubscribe link.
A complaint notification looks like:
{
"notificationType": "Complaint",
"complaint": {
"complainedRecipients": [{"emailAddress": "recipient@example.com"}],
"timestamp": "2026-05-13T10:00:00.000Z",
"feedbackId": "01000189...",
"complaintFeedbackType": "abuse"
}
}Note: only ISPs that participate in feedback loops report complaints back to SES. Gmail famously does not. That means your true complaint rate is higher than what SES shows — possibly by a factor of 2–3. Treat the reported number as a floor, not a measurement.
What to send to AWS support when responding to a review
If your account is under review or paused, the only thing that changes the outcome is the reply you put into the support case. AWS support does not respond well to "please reinstate us, we'll be more careful". They respond to specific, technical evidence of what changed.
A working template:
Hi,
Thank you for flagging the elevated [bounce / complaint] rate on our account. We have completed our investigation and taken the following corrective actions.
Root cause. [One specific sentence. Example: "On 2026-05-09 we imported a list of 18,400 addresses from a previous provider without migrating their suppression list, which caused a spike in permanent bounces against addresses that provider had previously suppressed."]
What we changed:
We added all 14,200 previously suppressed addresses from the prior provider to our SES account-level suppression list via
CreateImportJobon 2026-05-12. Import job ID:[id].We enabled account-level suppression for both
BOUNCEandCOMPLAINTreasons viaPutAccountSuppressionAttributes.We deployed an SNS → SQS → Lambda pipeline on 2026-05-12 that adds every permanent bounce and every complaint to both the SES suppression list and our application suppression table, blocking future sends before they reach the SES API.
We added a CloudWatch alarm at 3% bounce rate and 0.05% complaint rate that pages our on-call.
We paused our re-engagement campaign and will not resume it until we have implemented double opt-in confirmation.
Current state. Bounce rate over the last 24 hours of sending: [X%]. Complaint rate: [Y%]. We sent [N] messages over that period.
Prevention. [One concrete sentence on the process change. Example: "All list imports now go through a mandatory review checklist that includes suppression-list migration; the checklist is enforced by a pre-merge GitHub Action on our customer-data repository."]
Please reinstate sending when convenient.
Three things make this work:
- A specific root cause. Not "we had quality issues". A sentence that names a date, a number, and a specific operational mistake.
- Verifiable evidence. Job IDs, alarm names, dates. AWS reviewers can confirm these from their side. Hand-waving cannot be confirmed.
- A prevention mechanism that is not "we will be more careful". A code change, a checklist enforced by automation, a runbook.
The number of accounts I've seen reinstated within hours of sending a reply that looks like the above is high. The number reinstated after sending "please help, we will do better" is approximately zero.
What to monitor so this doesn't happen again
AWS recommends CloudWatch alarms at 5% bounce / 0.1% complaint. Don't use those. Those are the AWS review thresholds. By the time you're alarmed, you're already in the danger zone.
Set your own alarms at roughly half the review threshold so you have warning:
# Bounce rate alarm at 3%
aws cloudwatch put-metric-alarm \
--alarm-name "ses-bounce-rate-warning" \
--alarm-description "SES bounce rate approaching review threshold" \
--namespace AWS/SES \
--metric-name Reputation.BounceRate \
--statistic Maximum \
--period 3600 \
--evaluation-periods 2 \
--threshold 0.03 \
--comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:us-east-1:123456789012:ses-alarms
# Complaint rate alarm at 0.05%
aws cloudwatch put-metric-alarm \
--alarm-name "ses-complaint-rate-warning" \
--alarm-description "SES complaint rate approaching review threshold" \
--namespace AWS/SES \
--metric-name Reputation.ComplaintRate \
--statistic Maximum \
--period 3600 \
--evaluation-periods 2 \
--threshold 0.0005 \
--comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:us-east-1:123456789012:ses-alarmsTwo evaluation periods at 1 hour each gives you a 2-hour smoothing window — enough to avoid spurious pages from a single bad batch, short enough to catch a trend.
Per configuration set, set the same alarms with the ses:configuration-set dimension. A noisy product line is usually a configuration-set-level problem, and you want it visible before it pollutes the account number.
What this looks like with SendOps
SendOps is a control plane for AWS SES. We don't send your email — your AWS account does — but we configure everything around it so that the steps in this post are not something you have to remember and execute by hand at 11pm.
Concretely, on the account-reputation problem:
- The bounce/complaint pipeline above is set up automatically. When you connect a SendOps account to your AWS account, we provision the SNS topics, SQS queues, and Lambda functions that wire SES events into a structured event store, and we surface bounce subtypes, source attribution, and per-campaign rates in a dashboard you can actually skim.
- Suppressions are managed at both layers. Account-level SES suppression and your own application suppression are kept in sync, with bulk import for migrations from other providers.
- Alarms are set up at half-threshold by default, on the account and per configuration set, with notifications to whatever you connect (Slack, email, PagerDuty webhook).
- When AWS opens a review case, the SendOps timeline view shows you exactly which sending pattern preceded the event, with the bounce/complaint volume by subtype — which is what you need to write the support reply above.
You can do all of this yourself with the CLI commands in this post and a weekend. That weekend is in fact the right investment if you only send from one AWS account. If you send from several, or you onboard new ones regularly, that's the part SendOps is built to remove.
Further reading
- Sending review process FAQs — the canonical AWS document on what review and pause mean operationally.
- Reputation metrics messages — the document with the authoritative threshold numbers.
- Account-level suppression list — full reference for
PutSuppressedDestination, bulk imports, and configuration-set overrides. - SES bounce notification contents — the full schema for the SNS payloads above.
If you're staring at a review email right now: do the four numbered steps under "first-hour triage" first, then write the support reply. Everything else can wait until tomorrow.