Wraps Logo
Comparison

SES Bounce Handling: Hand-Rolled vs Wraps

Every other page in this section compares Wraps to a company. This one compares it to the thing people actually choose instead: writing it themselves in an afternoon.

So let's be precise about what that costs. It is 85 lines of SNS signature verification and 67 lines of handler — and a competent developer, or a competent coding agent, writes them correctly. The full code is on this page. Read it before you read our pitch.

The Honest Line Count

These are real counts from the working implementation below, not an estimate. Nothing is padded to make the number look worse.

FileRoleLinesExcl. blanks & comments
sns-verify.tsSNS signature verification8567
route.tsWebhook endpoint + event handling6751
TotalEverything above152118

For comparison, the Wraps version of the same handler is 36 lines, because the signature is a shared secret rather than an asymmetric one. That is a real reduction, and it is also the least interesting thing on this page.

Here Is the Code. It Is Correct.

This is the part most vendor comparison pages skip, or replace with a strawman. This implementation validates the certificate host, builds the canonical string with the exact key sets SNS signs, handles both signature versions, pins the topic ARN, and caches the certificate. It works.

Signature verification — 85 lines

TypeScriptsns-verify.ts
import crypto from "node:crypto";// SNS signs with a cert hosted on an AWS-controlled host. Anything else is// an attacker pointing you at a cert they control.const CERT_HOST = /^sns\.[a-zA-Z0-9-]{3,}\.amazonaws\.com(\.cn)?$/;const certCache = new Map<string, string>();async function fetchCert(url: string): Promise<string> {  const parsed = new URL(url);  if (parsed.protocol !== "https:" || !CERT_HOST.test(parsed.hostname)) {    throw new Error(`Untrusted SigningCertURL host: ${parsed.hostname}`);  }  const cached = certCache.get(url);  if (cached) return cached;  const res = await fetch(url);  if (!res.ok) throw new Error(`Cert fetch failed: ${res.status}`);  const pem = await res.text();  certCache.set(url, pem);  return pem;}// The signed string is a specific key subset, in this exact order, with the// key and value each followed by a newline. Order and membership are not// alphabetical-by-accident — they are fixed by SNS and differ per message type.const SIGNED_KEYS: Record<string, string[]> = {  Notification: [    "Message",    "MessageId",    "Subject",    "Timestamp",    "TopicArn",    "Type",  ],  SubscriptionConfirmation: [    "Message",    "MessageId",    "SubscribeURL",    "Timestamp",    "Token",    "TopicArn",    "Type",  ],  UnsubscribeConfirmation: [    "Message",    "MessageId",    "SubscribeURL",    "Timestamp",    "Token",    "TopicArn",    "Type",  ],};function canonicalString(msg: Record<string, unknown>): string {  const keys = SIGNED_KEYS[msg.Type as string];  if (!keys) throw new Error(`Unknown SNS message type: ${msg.Type}`);  let out = "";  for (const key of keys) {    // Subject is optional; it is omitted from the signed string when absent,    // not included as an empty value.    if (msg[key] === undefined || msg[key] === null) continue;    out += `${key}\n${msg[key]}\n`;  }  return out;}export async function verifySnsMessage(  msg: Record<string, unknown>,  expectedTopicArn: string): Promise<boolean> {  // Pin the topic. A valid signature only proves *some* SNS topic sent this.  if (msg.TopicArn !== expectedTopicArn) return false;  const version = msg.SignatureVersion;  if (version !== "1" && version !== "2") return false;  const algorithm = version === "1" ? "RSA-SHA1" : "RSA-SHA256";  const pem = await fetchCert(String(msg.SigningCertURL));  const verifier = crypto.createVerify(algorithm);  verifier.update(canonicalString(msg), "utf8");  return verifier.verify(pem, String(msg.Signature), "base64");}

The handler — 67 lines

TypeScriptapp/api/ses-events/route.ts
import { type NextRequest, NextResponse } from "next/server";import { verifySnsMessage } from "./sns-verify";const TOPIC_ARN = process.env.SES_EVENTS_TOPIC_ARN!;export async function POST(request: NextRequest) {  // SNS sends text/plain, so request.json() is not guaranteed to work.  const raw = await request.text();  let msg: Record<string, unknown>;  try {    msg = JSON.parse(raw);  } catch {    return new NextResponse("Bad JSON", { status: 400 });  }  if (!(await verifySnsMessage(msg, TOPIC_ARN))) {    return new NextResponse("Invalid signature", { status: 403 });  }  // A new subscription is dead until you GET the SubscribeURL. Miss this and  // the endpoint silently receives nothing.  if (msg.Type === "SubscriptionConfirmation") {    await fetch(String(msg.SubscribeURL));    return new NextResponse("OK", { status: 200 });  }  if (msg.Type !== "Notification") {    return new NextResponse("OK", { status: 200 });  }  // The SES event is a JSON string nested inside the SNS envelope.  const event = JSON.parse(String(msg.Message));  switch (event.eventType ?? event.notificationType) {    case "Bounce": {      const { bounceType, bouncedRecipients } = event.bounce;      for (const r of bouncedRecipients) {        if (bounceType === "Permanent") {          await db.contacts.update({            where: { email: r.emailAddress },            data: { bounced: true, bouncedAt: new Date() },          });        }      }      break;    }    case "Complaint": {      for (const r of event.complaint.complainedRecipients) {        await db.contacts.update({          where: { email: r.emailAddress },          data: { unsubscribed: true, unsubscribedAt: new Date() },        });      }      break;    }    default:      break;  }  return new NextResponse("OK", { status: 200 });}

One caveat, in fairness to both sides. Node's crypto.verify() checks the signature against the certificate you hand it — it does not validate that certificate's chain of trust. The host check above is what carries that weight, which is why it matters more than it looks. AWS's own guidance is to confirm the chain as well, and the official aws-js-sns-message-validator is the safer default if you go this route. We are pointing you at our competitor's better tool because the alternative is pretending this code is worse than it is.

What the 152 Lines Don't Cover

The argument for Wraps was never "your signature verification will be wrong." It's that signature verification is the part of this problem with a clean, well-documented answer — and it's roughly the only part. Everything below has no snippet to copy.

The code assumes infrastructure that doesn't exist yet

SES_EVENTS_TOPIC_ARN implies an SNS topic, a configuration set with an event destination pointed at it, an HTTPS subscription, and an IAM policy letting SES publish. That's console clicks you'll forget, or another 60-odd lines of Terraform you now maintain. The handler is the visible tip of the work.

When your endpoint is down, the events are gone

SNS retries an HTTPS endpoint on its own schedule and then stops. There is no queue in front of your handler and no dead-letter queue behind it, so a deploy window or a database blip means bounces that silently never happened. You find out weeks later when your bounce rate is 6% and your contact list is full of dead addresses you were told about and dropped.

Nothing here tells you your reputation is sliding

AWS recommends a bounce rate under 5% and may pause sending above 10%; for complaints it's 0.1% and 0.5%. The handler records events but watches nothing. By the time you notice in the SES console, the number is an account-wide average that takes real volume to pull back down. Wraps deploys alarms at 2%/4% bounce and 0.05%/0.08% complaint — but on the Production and Enterprise presets only, so this gap is one you can also have with Wraps if you deploy Starter.

The event model has sharp edges the happy path hides

All of these are documented AWS behavior, and all of them break code that looks correct:

  • A Delivery event can be followed by a Bounce for the same message — status is a log, not a state machine.
  • Permanent bounces with subtype OnAccountSuppressionList mean SES never tried. They don't count toward your bounce rate, and the handler above treats them as fresh news.
  • Transient bounces need a counter and a threshold, not a suppression. The handler above ignores them entirely.
  • SES gives no ordering or batching guarantees. One notification may cover many recipients, or many notifications may cover one.

The Same Handler, on Wraps

Wraps delivers events through EventBridge to your endpoint with a shared secret header, so there is no envelope to unwrap, no certificate to fetch, and no subscription handshake. The queue, dead-letter queue, alarms, and event history sit behind it — all deployed into your AWS account by one command.

Hand-rolled — 152 lines across 2 files

TypeScriptsns-verify.ts + route.ts
import crypto from "node:crypto";// SNS signs with a cert hosted on an AWS-controlled host. Anything else is// an attacker pointing you at a cert they control.const CERT_HOST = /^sns\.[a-zA-Z0-9-]{3,}\.amazonaws\.com(\.cn)?$/;const certCache = new Map<string, string>();async function fetchCert(url: string): Promise<string> {  const parsed = new URL(url);  if (parsed.protocol !== "https:" || !CERT_HOST.test(parsed.hostname)) {    throw new Error(`Untrusted SigningCertURL host: ${parsed.hostname}`);  }  const cached = certCache.get(url);  if (cached) return cached;  const res = await fetch(url);  if (!res.ok) throw new Error(`Cert fetch failed: ${res.status}`);  const pem = await res.text();  certCache.set(url, pem);  return pem;}// The signed string is a specific key subset, in this exact order, with the// key and value each followed by a newline. Order and membership are not// alphabetical-by-accident — they are fixed by SNS and differ per message type.const SIGNED_KEYS: Record<string, string[]> = {  Notification: [    "Message",    "MessageId",    "Subject",    "Timestamp",    "TopicArn",    "Type",  ],  SubscriptionConfirmation: [    "Message",    "MessageId",    "SubscribeURL",    "Timestamp",    "Token",    "TopicArn",    "Type",  ],  UnsubscribeConfirmation: [    "Message",    "MessageId",    "SubscribeURL",    "Timestamp",    "Token",    "TopicArn",    "Type",  ],};function canonicalString(msg: Record<string, unknown>): string {  const keys = SIGNED_KEYS[msg.Type as string];  if (!keys) throw new Error(`Unknown SNS message type: ${msg.Type}`);  let out = "";  for (const key of keys) {    // Subject is optional; it is omitted from the signed string when absent,    // not included as an empty value.    if (msg[key] === undefined || msg[key] === null) continue;    out += `${key}\n${msg[key]}\n`;  }  return out;}export async function verifySnsMessage(  msg: Record<string, unknown>,  expectedTopicArn: string): Promise<boolean> {  // Pin the topic. A valid signature only proves *some* SNS topic sent this.  if (msg.TopicArn !== expectedTopicArn) return false;  const version = msg.SignatureVersion;  if (version !== "1" && version !== "2") return false;  const algorithm = version === "1" ? "RSA-SHA1" : "RSA-SHA256";  const pem = await fetchCert(String(msg.SigningCertURL));  const verifier = crypto.createVerify(algorithm);  verifier.update(canonicalString(msg), "utf8");  return verifier.verify(pem, String(msg.Signature), "base64");}// ...plus the 67-line route handler shown above.

With Wraps — 36 lines

TypeScriptapp/api/webhooks/email/route.ts
import crypto from "node:crypto";import { type NextRequest, NextResponse } from "next/server";const SECRET = process.env.WRAPS_WEBHOOK_SECRET!;export async function POST(request: NextRequest) {  const signature = request.headers.get("x-wraps-signature");  if (    !signature ||    !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(SECRET))  ) {    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });  }  const { event, detail } = await request.json();  if (event === "Bounce" && detail.bounce.bounceType === "Permanent") {    for (const r of detail.bounce.bouncedRecipients) {      await db.contacts.update({        where: { email: r.emailAddress },        data: { bounced: true, bouncedAt: new Date() },      });    }  }  if (event === "Complaint") {    for (const r of detail.complaint.complainedRecipients) {      await db.contacts.update({        where: { email: r.emailAddress },        data: { unsubscribed: true, unsubscribedAt: new Date() },      });    }  }  return NextResponse.json({ received: true });}

Worth naming: the hand-rolled version's signature check is asymmetric, and ours is a shared secret. SNS proves the message came from AWS using a certificate you can verify. Wraps proves it came from your EventBridge destination using a secret both sides hold. Both are sound over HTTPS, and the shared secret is what makes the handler short — but if asymmetric verification is a hard requirement in your threat model, that's a real reason to prefer the SNS path, and we'd rather you know it now.

Line by Line, Then Everything Else

The part the code covers

CapabilityHand-rolledWraps
SNS signature verification
85 lines, correct
EventBridge + shared secret
Subscription confirmation
One fetch call
No subscription handshake at all
Parse bounce/complaint payloads
Straightforward JSON
Same payload, flattened

The part the code doesn't cover

CapabilityHand-rolledWraps
SNS topic, subscription, IAM policy
Console clicks or ~60 lines of IaC
Deployed by wraps email init
Retry + dead-letter queue on handler failure
SNS retries, then drops silently
SQS + DLQ; depth alarm on Production/Enterprise
Reputation alarms below AWS thresholds
Build CloudWatch alarms yourself
Production/Enterprise presets only — off on Starter
Queryable event history
Your DB, your schema, your retention
DynamoDB in your account + events API
Suppression list visibility
Raw SESv2 API calls
SDK, dashboard, and MCP tool
Open/click tracking over HTTPS
Config set + ACM + CloudFront
Deployed with the stack
Clean teardown
Manual resource deletion
wraps email destroy

= built-in or included, = possible but requires manual setup, = not available

Still Your Job, Even on Wraps

Wraps does not make bounce handling disappear. These stay yours either way:

  • Deciding your soft-bounce threshold and writing the counter behind it
  • Keeping your own unsubscribe list in sync with the SES suppression list
  • Making your handler idempotent — events can arrive more than once
  • Requesting SES production access and getting through the review

The bounce handling guide covers each of these, and applies whether or not you use Wraps.

When to Hand-Roll It

There is a real case for it, and it isn't a consolation prize.

  • You only need bounce and complaint events, and you already have an SNS topic wired up
  • You have an existing queue, retry, and alerting stack that new events can plug into
  • Your team already owns CloudWatch alarms and a metrics pipeline you trust
  • You want zero additional vendors in the path, and are willing to own the operational surface
  • The 152 lines are genuinely the whole job for your use case — some apps are that simple

When to Use Wraps

  • You want the surrounding infrastructure — queue, DLQ, alarms, history — without assembling it
  • You want to be warned at a 2% bounce rate rather than find out at 5%
  • You want per-message event history you can query without designing a schema for it
  • You would rather your team's next 40 hours go to product than to email plumbing
  • You still want to own everything: it all deploys into your AWS account and can be torn down

Try It Against Your Own Numbers

$ npx @wraps.dev/cli email init

Everything deploys into your AWS account, namespaced wraps-email-*, and wraps email destroy removes exactly what was created. If you decide the 152 lines were the better trade, you can leave without unpicking anything.

Also Compare

The code is the easy part

Deploy the queue, the dead-letter queue, the alarms, and the event history in one command — into your own AWS account.

Last updated: August 2026. Line counts measured with wc -l on the code shown above. SES bounce types, subtypes, reputation thresholds, and mailbox simulator behavior verified against the Amazon SES Developer Guide. SNS signing behavior verified against the Amazon SNS Developer Guide.

If anything here is wrong — especially the code — tell us at support@wraps.dev and we'll fix it on this page.