# Bounce & Complaint Handling

Source: https://wraps.dev/docs/guides/bounce-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:

| Metric | AWS recommends | Sending may be paused |
| --- | --- | --- |
| Bounce rate | Under 5% | Above 10% |
| Complaint rate | Under 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:

| Preset | Bounce warn / critical | Complaint warn / critical |
| --- | --- | --- |
| Starter | Alerting disabled — no reputation alarms deployed |
| Production | 2% / 4% | 0.05% / 0.08% |
| Enterprise | 1% / 2% | 0.03% / 0.05% |
| AWS acts at | 5% recommended / 10% may pause | 0.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](https://wraps.dev/docs/guides/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 `===`:

app/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");  const expected = Buffer.from(WEBHOOK_SECRET);  const sent = signature ? Buffer.from(signature) : null;  // timingSafeEqual throws when the buffers differ in length, so compare  // lengths first — otherwise a wrong-length header throws instead of failing.  if (    !sent ||    sent.length !== expected.length ||    !crypto.timingSafeEqual(sent, expected)  ) {    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.

| Address | What it does |
| --- | --- |
| `success@simulator.amazonses.com` | Accepted and delivered — fires a Delivery event |
| `bounce@simulator.amazonses.com` | Rejected with SMTP 550 5.1.1 — fires a Permanent bounce |
| `complaint@simulator.amazonses.com` | Delivered, then marked as spam — fires a Complaint |
| `suppressionlist@simulator.amazonses.com` | Hard bounce as if the address were on the suppression list |
| `ooto@simulator.amazonses.com` | Delivered, then returns an out-of-office auto-response |

test-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](https://wraps.dev/docs/guides/suppression-lists)

Webhook Setup

Point Wraps at your endpoint and configure retries and signatures.

[Webhooks Guide](https://wraps.dev/docs/guides/webhooks)

Domain Reputation

Keeping the rates on this page low in the first place, and the runbook for when one starts climbing.

[Reputation Guide](https://wraps.dev/docs/guides/reputation)
