Monitoring Amazon SES

SES bounce rate creeping toward account review

Nobody watches the SES reputation dashboard daily. The number that matters is a rolling average, so a bad import on Tuesday keeps pushing the average up all week while each individual day looks survivable. By the time an email from AWS arrives saying the account is under review, the damage is a fortnight old and the fix — cleaning the list — takes longer than the review does.

CloudWatch metrics Python and Node.js Detect before AWS does
The short answer

AWS publishes two thresholds: a bounce rate at or above 5% puts an account under review, and 10% risks a sending pause. For complaints the numbers are 0.1% and 0.5%. They are computed over a rolling window, not per day.

Both are available as CloudWatch metrics (AWS/SES, Reputation.BounceRate and Reputation.ComplaintRate), so a scheduled script can alert you at a threshold you choose rather than the one AWS enforces.

The problem in plain words

The rates are not shown to you at send time. Nothing in the API response tells you that this send pushed you over a line. The console has a reputation page, but it shows the current value rather than the trajectory, and current values that sit just under the threshold look fine right up until they do not.

What makes it dangerous is the lag. Bounce rate is an average over recent sending, so one bad batch keeps affecting the number long after you stopped sending it. If you only look when something feels wrong, you are looking at a number that already includes the damage.

Why it happens

List quality decays quietly. Addresses go dead constantly: people leave companies, domains lapse, mailboxes fill. A list that bounced at 1% a year ago can bounce at 6% today with nobody changing anything.

Imports skip validation. The most common cause of a sudden spike is a bulk import of addresses that were never confirmed — a purchased list, a CSV from an old system, a form with no double opt-in.

Complaints are a different signal entirely. A complaint means someone pressed 'this is spam'. The threshold is 40 times stricter than the bounce one, because receivers treat it as much stronger evidence. A campaign that bounces cleanly can still be fatal on complaints.

How to fix it

Read the actual numbers

The rates live in CloudWatch, not in the SES API:

aws cloudwatch get-metric-statistics \\
  --namespace AWS/SES --metric-name Reputation.BounceRate \\
  --start-time "$(date -u -v-14d '+%Y-%m-%dT%H:%M:%SZ')" \\
  --end-time "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \\
  --period 86400 --statistics Maximum

The value is a fraction, so 0.05 is 5%.

Alert at your threshold, not AWS's

Alerting at 5% means alerting when you are already under review. Set your own line well below it — 2% for bounces and 0.05% for complaints gives room to act. The script takes both as arguments so the numbers live in your config, not buried in code.

Watch the direction, not just the level

A flat 3% is a list-quality problem to schedule. A 1% that became 3% in four days is an incident happening right now. The script reports the trend across the window so the two are distinguishable, because they need different responses.

Fix the cause, not the number

When it spikes, find the send that caused it. Attach a configuration set per campaign type so bounces are attributable, stop the offending list, and validate addresses before the next import. Removing addresses from the suppression list does not lower the rate — it raises it, because the retries bounce again.

How to check it worked

Run the detector after a cleanup. The rate falls slowly, because it is an average over recent sending — expect days, not minutes:

python ses_reputation_watch.py --days 14 --max-bounce 0.02 --max-complaint 0.0005
# exits 0 when both are under your thresholds, non-zero otherwise

Run it on a schedule and page on the non-zero exit.

The full code

The script pulls both reputation metrics from CloudWatch over a window you choose, compares them against your own thresholds rather than the enforcement ones, and reports whether each is rising or steady. It exits non-zero on breach so it can drive an alert.

ses_reputation_watch.py
"""Alert on SES bounce and complaint rates before AWS acts on them.

AWS reviews at 5% bounces / 0.1% complaints and can pause sending at 10% / 0.5%.
Alerting at those numbers means alerting when it is already too late, so the
thresholds here default well below and are configurable.
"""
import argparse
import datetime as dt
import logging
import sys

import boto3

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("ses_reputation_watch")

AWS_REVIEW_BOUNCE = 0.05
AWS_REVIEW_COMPLAINT = 0.001


def fetch(cw, metric, days):
    end = dt.datetime.now(dt.timezone.utc)
    points = cw.get_metric_statistics(
        Namespace="AWS/SES",
        MetricName=metric,
        StartTime=end - dt.timedelta(days=days),
        EndTime=end,
        Period=86400,
        Statistics=["Maximum"],
    )["Datapoints"]
    return [p["Maximum"] for p in sorted(points, key=lambda p: p["Timestamp"])]


def judge(series, threshold, label):
    """Pure decision function over a daily series.

    Reports level AND direction, because a flat 3% is a list to clean next sprint
    while a 1% that became 3% this week is an incident happening now.
    """
    if not series:
        return [f"{label}: no data (has the account sent anything?)"]
    latest = series[-1]
    problems = []
    if latest >= threshold:
        problems.append(f"{label}: {latest:.3%} is at or over your {threshold:.3%} threshold")
    if len(series) >= 4:
        earlier = sum(series[:len(series) // 2]) / (len(series) // 2)
        recent = sum(series[len(series) // 2:]) / (len(series) - len(series) // 2)
        if earlier > 0 and recent > earlier * 1.5:
            problems.append(
                f"{label}: rising fast, {earlier:.3%} -> {recent:.3%} across the window")
    return problems


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--region", default="us-east-1")
    ap.add_argument("--days", type=int, default=14)
    ap.add_argument("--max-bounce", type=float, default=0.02)
    ap.add_argument("--max-complaint", type=float, default=0.0005)
    args = ap.parse_args()

    cw = boto3.client("cloudwatch", region_name=args.region)
    problems = []
    for metric, threshold, label in (
        ("Reputation.BounceRate", args.max_bounce, "bounce rate"),
        ("Reputation.ComplaintRate", args.max_complaint, "complaint rate"),
    ):
        series = fetch(cw, metric, args.days)
        if series:
            log.info("%s: latest %.3%%, peak %.3%% over %d days",
                     label, series[-1] * 100, max(series) * 100, args.days)
        problems += judge(series, threshold, label)

    for p in problems:
        log.error(p)
    if problems:
        log.error("AWS reviews at %.0f%% bounces / %.1f%% complaints",
                  AWS_REVIEW_BOUNCE * 100, AWS_REVIEW_COMPLAINT * 100)
    return 1 if problems else 0


if __name__ == "__main__":
    sys.exit(main())
ses-reputation-watch.mjs
/**
 * Alert on SES bounce and complaint rates before AWS acts on them.
 *
 * AWS reviews at 5% bounces / 0.1% complaints and can pause sending at 10% / 0.5%.
 * Alerting at those numbers means alerting when it is already too late, so the
 * thresholds here default well below and are configurable.
 */
import { CloudWatchClient, GetMetricStatisticsCommand } from '@aws-sdk/client-cloudwatch';

const AWS_REVIEW_BOUNCE = 0.05;
const AWS_REVIEW_COMPLAINT = 0.001;

async function fetchSeries(cw, MetricName, days) {
  const EndTime = new Date();
  const StartTime = new Date(EndTime.getTime() - days * 86400_000);
  const out = await cw.send(new GetMetricStatisticsCommand({
    Namespace: 'AWS/SES', MetricName, StartTime, EndTime,
    Period: 86400, Statistics: ['Maximum'],
  }));
  return (out.Datapoints ?? [])
    .sort((a, b) => a.Timestamp - b.Timestamp)
    .map((p) => p.Maximum);
}

/**
 * Pure decision function over a daily series.
 *
 * Reports level AND direction, because a flat 3% is a list to clean next sprint
 * while a 1% that became 3% this week is an incident happening now.
 */
export function judge(series, threshold, label) {
  if (!series.length) return [`${label}: no data (has the account sent anything?)`];
  const pct = (n) => `${(n * 100).toFixed(3)}%`;
  const latest = series.at(-1);
  const problems = [];
  if (latest >= threshold) {
    problems.push(`${label}: ${pct(latest)} is at or over your ${pct(threshold)} threshold`);
  }
  if (series.length >= 4) {
    const half = Math.floor(series.length / 2);
    const mean = (a) => a.reduce((x, y) => x + y, 0) / a.length;
    const earlier = mean(series.slice(0, half));
    const recent = mean(series.slice(half));
    if (earlier > 0 && recent > earlier * 1.5) {
      problems.push(`${label}: rising fast, ${pct(earlier)} -> ${pct(recent)} across the window`);
    }
  }
  return problems;
}

async function main() {
  const region = process.env.AWS_REGION ?? 'us-east-1';
  const days = Number(process.env.DAYS ?? 14);
  const maxBounce = Number(process.env.MAX_BOUNCE ?? 0.02);
  const maxComplaint = Number(process.env.MAX_COMPLAINT ?? 0.0005);
  const cw = new CloudWatchClient({ region });

  const problems = [];
  for (const [metric, threshold, label] of [
    ['Reputation.BounceRate', maxBounce, 'bounce rate'],
    ['Reputation.ComplaintRate', maxComplaint, 'complaint rate'],
  ]) {
    problems.push(...judge(await fetchSeries(cw, metric, days), threshold, label));
  }
  problems.forEach((p) => console.error(p));
  if (problems.length) {
    console.error(`AWS reviews at ${AWS_REVIEW_BOUNCE * 100}% bounces / ${AWS_REVIEW_COMPLAINT * 100}% complaints`);
  }
  process.exit(problems.length ? 1 : 0);
}

if (import.meta.url === `file://${process.argv[1]}`) main();

Add a test

The judgement is separated from CloudWatch so both halves of it — the level and the trend — can be tested against fixed series without an AWS account.

test_ses_reputation_watch.py
from ses_reputation_watch import judge


def test_empty_series_is_reported_not_ignored():
    assert judge([], 0.02, "bounce rate")


def test_under_threshold_and_flat_is_quiet():
    assert judge([0.01, 0.01, 0.011, 0.01], 0.02, "bounce rate") == []


def test_over_threshold_is_reported():
    problems = judge([0.01, 0.01, 0.01, 0.03], 0.02, "bounce rate")
    assert any("threshold" in p for p in problems)


def test_a_sharp_rise_is_caught_below_the_threshold():
    """0.9% is under a 2% threshold, but tripling in a week is the real signal."""
    problems = judge([0.003, 0.003, 0.009, 0.009], 0.02, "bounce rate")
    assert any("rising fast" in p for p in problems)


def test_short_series_does_not_claim_a_trend():
    assert judge([0.001, 0.001], 0.02, "bounce rate") == []
ses-reputation-watch.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { judge } from './ses-reputation-watch.mjs';

test('an empty series is reported, not ignored', () => {
  assert.ok(judge([], 0.02, 'bounce rate').length);
});

test('under threshold and flat is quiet', () => {
  assert.deepEqual(judge([0.01, 0.01, 0.011, 0.01], 0.02, 'bounce rate'), []);
});

test('over threshold is reported', () => {
  const p = judge([0.01, 0.01, 0.01, 0.03], 0.02, 'bounce rate');
  assert.ok(p.some((x) => x.includes('threshold')));
});

test('a sharp rise is caught below the threshold', () => {
  const p = judge([0.003, 0.003, 0.009, 0.009], 0.02, 'bounce rate');
  assert.ok(p.some((x) => x.includes('rising fast')));
});

FAQ

What bounce rate does AWS actually act on?

AWS places an account under review at a bounce rate of 5% and may pause sending at 10%. For complaints the numbers are 0.1% and 0.5%. They are rolling averages over recent sending, not per-day figures, so a single bad batch affects the number for days afterwards.

Why alert at 2% when the limit is 5%?

Because the rate lags. By the time it reads 5% the sends that caused it are days old and cleaning the list takes longer than the review. A threshold at 2% gives you the room to find and stop the cause.

Does removing addresses from the suppression list lower my bounce rate?

No, it raises it. Those addresses bounced before; retrying them produces fresh bounces that count again. Suppression is what protects the rate — the fix is a cleaner list, not a shorter suppression list.

Bounces are fine but complaints are high. Is that different?

Very. A complaint is someone pressing 'this is spam', and the threshold is 40 times stricter. High complaints with low bounces usually means the addresses are real but the mail is unwanted: check consent, sending frequency, and whether unsubscribe actually works.

Can I get this per campaign rather than per account?

Yes. Send through a configuration set per campaign type and the events are attributable to it, so you can see which send moved the number. Without one, the account-level rate is all you get.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.