Wraps Logo
DocsHome
Guide

Bounce & Complaint Handling

Bounces and complaints are the two metrics AWS uses to decide whether you keep your ability to send. Here is what the events mean, what to do with each one, and how to test it before it matters.

5 min read

Why This Matters

Unlike a hosted email API, SES does not quietly absorb your bad addresses. Your bounce and complaint rates are computed at the account level, and AWS acts on them. These are the published thresholds:

MetricAWS recommendsSending may be paused
Bounce rateUnder 5%Above 10%
Complaint rateUnder 0.1%Above 0.5%

The thresholds are lower than they look

A 0.1% complaint rate is one complaint per thousand delivered emails. Gmail's own bulk sender guidelines are stricter still. By the time you notice a problem in the SES console, the rate is already an account-wide average that takes real volume to pull back down — which is why alerting well below the AWS line matters more than reacting at it.

What Wraps Deploys for You

wraps email init deploys the full event pipeline into your AWS account — an SES configuration set, EventBridge rules, an SQS queue with a dead-letter queue, a Lambda event processor, and a DynamoDB event history table.

Reputation alarms are part of that pipeline, but which ones you get depends on the preset you deploy. The Starter preset ships with alerting turned off; Production and Enterprise add CloudWatch alarms that fire well before the AWS thresholds:

PresetBounce warn / criticalComplaint warn / critical
StarterAlerting disabled — no reputation alarms deployed
Production2% / 4%0.05% / 0.08%
Enterprise1% / 2%0.03% / 0.05%
AWS acts at5% recommended / 10% may pause0.1% recommended / 0.5% may pause

Production and Enterprise also alarm on dead-letter queue depth, so a failing event processor surfaces instead of silently dropping events. Thresholds are overridable per deployment — see Configuration Presets.

Reading a Bounce

The single most common bounce-handling bug is treating every bounce the same. SES tells you exactly how permanent the problem is via bounceType and bounceSubType:

Permanent

Hard bounce — the address does not work and will not start working

Stop sending immediately. Mark the contact dead.

Subtypes: General, NoEmail, Suppressed, OnAccountSuppressionList

Transient

Soft bounce — a temporary condition, or a problem with this message

Do not suppress on the first one. Count and threshold.

Subtypes: General, MailboxFull, MessageTooLarge, ContentRejected, AttachmentRejected

Undetermined

The provider bounced but did not say why

Treat as transient. Watch for repeats on the same address.

Subtypes: Undetermined

Not every Permanent bounce is a new dead address

The subtypes OnAccountSuppressionList and OnTenantSuppressionList mean SES never attempted delivery — the address was already suppressed. AWS does not count these toward your bounce rate, and neither should your internal metrics.

Handling Events in Your App

Wraps delivers events to your endpoint over HTTPS with a shared secret in the X-Wraps-Signature header. Compare it with a constant-time comparison, never ===:

TypeScriptapp/api/webhooks/email/route.ts
import crypto from "crypto";import { type NextRequest, NextResponse } from "next/server";const WEBHOOK_SECRET = process.env.WRAPS_WEBHOOK_SECRET!;export async function POST(request: NextRequest) {  // Verify the webhook signature  const signature = request.headers.get("x-wraps-signature");  if (    !signature ||    !crypto.timingSafeEqual(      Buffer.from(signature),      Buffer.from(WEBHOOK_SECRET),    )  ) {    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });  }  const { event, detail, messageId } = await request.json();  switch (event) {    case "Bounce": {      const { bounceType, bounceSubType, bouncedRecipients } = detail.bounce;      // Permanent = hard bounce. Stop sending to this address, permanently.      if (bounceType === "Permanent") {        // These subtypes mean SES never attempted delivery — the address was        // already suppressed. They do not count toward your bounce rate, and        // they are not new information about the recipient.        const alreadySuppressed =          bounceSubType === "OnAccountSuppressionList" ||          bounceSubType === "OnTenantSuppressionList";        if (!alreadySuppressed) {          for (const r of bouncedRecipients) {            await db.contacts.update({              where: { email: r.emailAddress },              data: { bounced: true, bouncedAt: new Date() },            });          }        }      }      // Transient = soft bounce. Do NOT suppress on the first one — a full      // mailbox today is a deliverable address next week. Count them instead.      if (bounceType === "Transient") {        for (const r of bouncedRecipients) {          await db.contacts.incrementSoftBounce(r.emailAddress);        }      }      break;    }    case "Complaint": {      // Someone hit "mark as spam". Unsubscribe immediately, every time.      for (const r of detail.complaint.complainedRecipients) {        await db.contacts.update({          where: { email: r.emailAddress },          data: { unsubscribed: true, unsubscribedAt: new Date() },        });      }      break;    }    case "Delivery":      // Note: a Delivery event does not mean the message is safe. The      // receiving server can accept a message and bounce it afterward.      break;  }  return NextResponse.json({ received: true });}

Three Things That Surprise People

A Delivery event does not mean the message survived

The receiving mail server can accept a message — firing a Delivery event — and then decide during processing that it bounces, firing a Bounce event for the same message afterward. Treat message status as a log you append to, not a state machine that only moves forward.

One event can cover many recipients — or one each

SES makes no batching or ordering guarantees. A single bounce notification may list several recipients, or you may get one notification per recipient for the same send. Always iterate bouncedRecipients; never assume index 0 is the whole story.

Complaints rarely name the complainer

Most ISPs strip the recipient address from the feedback report, so complainedRecipients is SES's best guess based on the original message. Sending one message per recipient — rather than one message with thirty addresses in BCC — is what makes that guess exact.

Test It Before You Need It

SES runs a mailbox simulator that produces real events on demand. Messages sent to these addresses do not affect your reputation metrics or your daily sending quota, and they work while you are still in the sandbox. You are billed for them as normal sends.

AddressWhat it does
success@simulator.amazonses.comAccepted and delivered — fires a Delivery event
bounce@simulator.amazonses.comRejected with SMTP 550 5.1.1 — fires a Permanent bounce
complaint@simulator.amazonses.comDelivered, then marked as spam — fires a Complaint
suppressionlist@simulator.amazonses.comHard bounce as if the address were on the suppression list
ooto@simulator.amazonses.comDelivered, then returns an out-of-office auto-response
TypeScripttest-bounces.ts
import { WrapsEmail } from "@wraps.dev/email";const email = new WrapsEmail({ region: "us-east-1" });// Each address triggers a specific event. None of them affect your// reputation metrics or your daily sending quota.await email.send({  from: "hello@yourapp.com",  to: "bounce@simulator.amazonses.com", // -> Permanent bounce  subject: "Bounce test",  html: "<p>Testing bounce handling</p>",});// Labels let you correlate the event back to the send that caused itawait email.send({  from: "hello@yourapp.com",  to: "bounce+signup-flow@simulator.amazonses.com",  subject: "Bounce test",  html: "<p>Testing bounce handling</p>",});

Checklist

  • Permanent bounces stop sending to that address permanently
  • Transient bounces increment a counter, not a suppression
  • Complaints unsubscribe immediately, with no threshold
  • OnAccountSuppressionList bounces are excluded from your own metrics
  • Your handler iterates every recipient in the event
  • You have tested all five simulator addresses end to end

Next Steps

Suppression Lists

SES keeps its own list of addresses it refuses to send to. Learn how it interacts with yours.

Suppression Lists
Webhook Setup

Point Wraps at your endpoint and configure retries and signatures.

Webhooks Guide