An email bounce handling checklist is 7 lines: receive your provider’s bounce and complaint webhooks, verify each request, suppress an address on its first hard bounce, suppress it on any complaint, count soft bounces up to a limit, check your own suppression list before every send, and test with the provider’s simulator. Your database knows only what you record.

The email bounce handling checklist, and what each line is for

Email bounce handling is a loop with 7 parts: the provider reports a bounce or complaint by webhook, your app verifies the request, records the event, marks the address suppressed when the rule says so, checks that mark before every send, shows the user why mail stopped, and proves the loop with simulated events.

Bounce handling is the third of the controls in how to improve email deliverability for an app that sends its own mail, after domain authentication and delivery tracking. It starts where a send has already failed.

  • Receive the provider’s bounce and complaint webhooks at one HTTPS endpoint, because only the provider hears the receiving server’s answer.
  • Verify each request before you read it, because anyone can post a fake bounce to a public URL.
  • Suppress an address on its first hard bounce, because a permanent failure does not fix itself on the next send.
  • Suppress an address on any complaint, because the person has told their mailbox provider they do not want your mail.
  • Count soft bounces and treat a long streak as a hard bounce, because a temporary failure that never clears behaves like a dead address.
  • Check your own suppression list inside the function that sends mail, because that is the last place your code decides to send.
  • Test with the provider’s simulator, because a webhook that never fires fails silently.

The whole control lives in three places: one webhook endpoint, one table, and one check inside the function that sends mail. The verification link, the password reset and the receipt all pass through that function, so one lookup there covers all of them.

Your provider keeps a suppression list of its own, and I don’t think that is enough. It stops the send at the provider after your app has already decided to send, so the app’s screen still says the email went out and the address stays marked as valid in your database. The Amazon SES case under the next heading shows how that looks to a customer.

For the streak I use about three soft bounces on separate days, with no delivery in between; after that I treat the address as a hard bounce.

In the Production Hardening Sprint this control is deliverable 11.3, worded “Process bounces and complaints and suppress further sending where appropriate”, and the reason given for it is “Repeated sending to invalid or complaining recipients can damage sending reputation”.

What is a hard bounce, and how a soft bounce differs

A hard bounce is a permanent delivery failure: the mailbox does not exist, or the receiving server refuses the address for good, and the status code starts with 5. Suppress the address after its first hard bounce. A soft bounce is temporary, its code starts with 4, and a later send may still succeed.

The code classes come from RFC 3463. A 5.XXX.XXX code is a “Permanent Failure”, one that is “not likely to be resolved by resending the message in the current form”. A 4.XXX.XXX code is a “Persistent Transient Failure”, and “If this code accompanies a delivery failure report, sending in the future may be successful”. The detail code X.1.1 is “Bad destination mailbox address”: “The mailbox specified in the address does not exist”.

EventWhat it meansStatus code classWhat happens to the address
Hard bouncea permanent failure: the mailbox does not exist or the server refuses the address5.x.x, with X.1.1 for a missing mailboxsuppress it now
Soft bouncea temporary failure that a later send may get past4.x.xkeep sending, count it, and apply the streak rule
Complaintthe recipient marked the message as spamnone: the message was delivered, and the report arrives through a feedback loopsuppress it now
Blocked or policy rejectionthe receiving server refused the message, not the addressnot one class: SendGrid types it blocked, a temporary delivery denial it calls a soft bounceleave the address alone and fix the sending side

A block says something about your sending rather than the address, so its fix belongs with transactional emails landing in spam, not in the suppression table. Each provider labels these events its own way: Postmark sends TypeCode 1 for a hard bounce and 4096 for a soft bounce, and the provider table further down maps the other three. Mailchimp and other marketing platforms apply these rules for their users, so what follows is for the app that sends its own mail.

What goes wrong without it: sender reputation dropping from bounces

Sender reputation dropping from bounces starts when an app keeps mailing addresses that no longer exist. Amazon SES asks senders to keep the bounce rate below 2% and places an account under review at 5% or more. Suppressing each address on its first hard bounce and on every complaint stops the app mailing the same dead address twice.

The rest of Amazon SES’s enforcement FAQ is just as plain. At a bounce rate of 10% or greater, SES “might pause your account’s ability to send additional email”. For complaints, the target is below 0.1%, a rate of 0.1% or greater puts the account under review, and 0.5% or greater might pause sending. Its bounce rate “includes only hard bounces to domains you haven’t verified”, and it is measured over a “representative volume”, not a fixed period. Gmail sets a line of its own: Google’s email sender guidelines tell all senders to “Keep spam rates reported in Postmaster Tools below 0.3%”.

What you seeWhat is happeningThe checklist line that prevents it
Welcome emails to new sign-ups bouncea sign-up form with no confirmation collects typos and bot sign-ups; Supabase’s SMTP guide calls bots signing users up “A common source of abuse” that can hurt a sending domain’s reputationsuppress on the first hard bounce, and confirm addresses at sign-up
The provider puts the account under review, or sending stopsthe bounce or complaint rate has crossed the provider’s line, as in the SES figures abovesuppress on hard bounces and complaints, and check the list before every send
Customers say password resets never arrivethe domain’s reputation has probably fallen far enough that mail to real addresses suffers tooall seven lines, proved by the test below
Complaints keep coming from the same peoplea complaint ignored becomes the next complaint, and Gmail’s spam-rate line above is the one at risksuppress on any complaint

Take a founder’s app that sends its welcome and password reset emails through Amazon SES, on an account that uses the account-level suppression list by default for both bounces and complaints. A customer signs up with a mistyped address, and the welcome email hard bounces. SES adds the address to its account-level suppression list, and when the customer later asks for a password reset, “SES accepts the message, but doesn’t send it”. The app recorded nothing from the bounce, so its screen tells the customer to check their inbox, and support sees an ordinary account. The provider’s list protected the sender. Only the app’s own record of the bounce would have let the account page or support tell the customer to fix the address.

A quiet webhook proves only that nothing bounced. Whether mail reaches the inbox is a separate question, and the way to answer it is how to test email deliverability.

How to handle email bounces automatically on Resend, Postmark, Amazon SES and SendGrid

Each provider’s behavior below comes from its own documentation, read in October 2026; the table is a reading of four sets of docs, not a test of four integrations. Event names are spelled the way each vendor spells them.

ProviderBounce eventComplaint eventHow the webhook is authenticatedWhat the provider suppresses by itselfHow to simulate
Resendemail.bounced for a permanent rejection; email.delivery_delayed for a temporary issueemail.complaineda signature in the svix-id, svix-timestamp and svix-signature headers, checked against the raw bodyadds an address after a hard bounce or spam complaint (suppression.added), for the whole team, on transactional and Broadcast sends; a skipped send fires email.suppressedbounced@resend.dev (an SMTP 550 5.1.1), complained@resend.dev, suppressed@resend.dev; test emails count against the sending quota
Postmarkthe Bounce webhook: TypeCode 1 hard, 4096 soft, with Inactive and CanActivate fieldsthe separate Spam Complaint webhookHTTP Basic Authentication and IP allowlisting, with no signatureInactive says whether a bounce deactivated the address (the docs say to check it on a soft bounce); a complaint deactivates the addresshardbounce@, softbounce@ and blocked@bounce-testing.postmarkapp.com; no complaint address, so post the complaint webhook’s sample request
Amazon SESa bounce notification through an Amazon SNS topic (in JSON), email feedback forwarding or event publishinga complaint notification, by the same routesthe SNS message signature, verified before processingthe account-level suppression list, on by default for bounces and complaints on accounts started after November 25, 2019; of bounces, only hard bounces are added; a later send to a listed address is accepted but not deliveredbounce@, complaint@, ooto@ and suppressionlist@simulator.amazonses.com; a simulator bounce is not put on the suppression list
SendGridthe Event Webhook’s bounce event, typed bounce (permanent) or blocked (temporary); dropped for a send it refusedspamreportSigned Event Webhook, OAuth 2.0, or bothdropped reasons include “Bounced Address” and “Spam Reporting Address”Test Your Integration posts a JSON array of example events; an address that produces a real test bounce: not stated in SendGrid’s docs

Three differences shape the handler. Postmark is the odd one out on authentication: Postmark’s bounce webhook docs say to use Basic Authentication and IP allowlisting “rather than a signature”, because “Postmark doesn’t sign webhooks”. Resend’s webhook event types give a temporary failure its own event, while SendGrid’s Event Webhook reference keeps both kinds under bounce and tells them apart by type. And on the Amazon SES account-level suppression list, a suppressed send is accepted, so it looks like a success to your code; SES reports it only afterwards, as a bounce notification with the subtype OnAccountSuppressionList, which is how the reset in the case above went missing in an app that recorded no events.

The webhook handler, step by step

The handler is the same shape on every provider. Only the authentication step and the event map change.

  1. 01 Expose one HTTPS endpoint per provider, so each request is checked the way its own provider protects it.
  2. 02 Authenticate the request before parsing it: against the raw body where the provider signs it, and by the Basic Authentication credentials on Postmark. On Amazon SES, SNS also sends a confirmation message to the endpoint after a Subscribe call, and that message is verified too.
  3. 03 Store the raw event under a unique key and ignore a key you already stored, because providers retry. Postmark retries on any non-2xx response or when your endpoint times out, and names the X-PM-Webhook-Trace-Id header, when available, as the better key.
  4. 04 Map the provider type to four categories: hard bounce, soft bounce, complaint and other.
  5. 05 Apply the rule. A hard bounce or a complaint suppresses the address now; a soft bounce adds to its count under the streak rule.
  6. 06 Return a 2xx response fast and do slow work afterwards, so the provider does not time out and send the event again.
  7. 07 Alert when the endpoint itself fails. Inside the app a dead webhook is silent: no bounce arrives, and the app looks healthy. Resend does email your team when an endpoint starts failing.

Step 7 is my own rule, not a provider’s. The signature rules come from each provider: Resend’s webhook verification guide says to use “the raw request body when verifying webhooks”, and AWS says you “must verify the signature before processing any Amazon SNS messages” in verifying Amazon SNS message signatures. The general rules for any provider sit with webhook security.

// Provider-neutral skeleton. verifyProviderRequest, toEvents, eventKey and
// mapEvent are placeholders you write per provider from the table above.
export async function handleEmailWebhook(req: Request): Promise<Response> {
  const raw = await req.text(); // the raw body: verify before parsing
  if (!(await verifyProviderRequest(req, raw))) {
    return new Response('unauthorized', { status: 401 });
  }
  for (const event of toEvents(JSON.parse(raw))) { // one event or an array
    const key = eventKey(req, event); // the provider's delivery or event id
    if (!(await db.webhookEvents.insertIfNew({ key, event }))) continue;
    const { category, email } = mapEvent(event); // hard_bounce | soft_bounce | complaint | other
    const address = email.trim().toLowerCase();
    if (category === 'hard_bounce' || category === 'complaint') {
      await db.suppressions.upsert({ address, reason: category, eventKey: key });
    } else if (category === 'soft_bounce') {
      await db.softBounces.record(address, key); // the streak rule reads these rows
    }
  }
  return new Response('ok', { status: 200 });
}

The send path this handler protects, with the provider choice and the delivery record, is covered in transactional email best practices. If your app runs on Supabase Auth, its emails leave through the SMTP provider you configure once custom SMTP is on, and Resend, AWS SES, Postmark and Twilio SendGrid are all on Supabase’s non-exhaustive list of services that work with it. So the same provider’s webhook should report their bounces too. Turning on custom SMTP for Supabase Auth is its own task.

What is a suppression list: what goes on it, and when an address comes off

A suppression list is the set of addresses an app must not send to, each with a reason and a date. In practice there are two: the one your email provider keeps for the account and the one in your own database. They have to agree, and only yours is checked before your code decides to send.

The table in your database needs only a few columns. This design is my own.

ColumnTypeWhy it is there
addresstext, lower-cased (or a hash of it, if your privacy policy prefers)the key every send looks up
reasonone of hard bounce, complaint, manual, unsubscribedecides when and how the address may come off
provider_event_idtextties the row to the event or message the provider reported
first_event_attimestampwhen the problem started
last_event_attimestampthe latest event for this address
streamtransactional or marketingkeeps a marketing unsubscribe from blocking a password reset

Amazon SES stores listed addresses with their case, and while sending treats different cases as the same address, its API calls “require an exact case match”. So compare the two lists on a lower-cased copy, and call the SES suppression API with the exact case SES stored.

The check itself is one lookup inside the send function. If the address is on the list, the function skips the send, writes a log line with the reason, and returns a result the caller can show, such as “not sent: address suppressed”. Keep a hard-bounced address as a suppressed row rather than deleting it: the row is what stops the next send and lets the account page explain why mail stopped.

Keeping the two lists in step takes different work per provider. Resend sends suppression.added and suppression.removed webhooks, and Resend’s suppressions page gives, as one use for them, “to sync your suppressions with your own database”. Amazon SES lists its account-level list through the SES API v2 and the console. Postmark keeps a suppression list per message stream, and SendGrid reports its refusals as dropped events.

When an address comes off is a rule you set. Here is how I set them:

ReasonComes off whenWho can do it
Hard bouncethe user corrects the address or confirms it againthe user, through the confirmation flow
Complaintthe person explicitly asks to receive mail againsupport, on that request; Postmark’s webhook docs say it “will not let you reactivate it”, and its bounce handling guide says the only way back is “to email support with an explanation”
Soft-bounce streakabout 30 days after the last soft bouncethe app, on a schedule
Manual blocka named person removes itthe person named on the row, or an admin

Never bulk-clear a suppression list to fix deliverability numbers. Resend warns that an address removed before the reason is addressed “will be automatically re-suppressed”, and that one that bounces or draws a complaint again will “damage your sender reputation”. On SES, addresses “remain there until you remove them”.

Keep transactional and marketing mail apart as well. Supabase’s SMTP guide says “Don’t mix Auth emails with marketing emails” and to use separate services for the two, and the stream column does the same job inside your own table.

Complaints, feedback loops and what Gmail shows you

A complaint reaches you through the mailbox provider’s feedback loop, which your email provider relays as a complaint event. Yahoo’s Complaint Feedback Loop “only supports DKIM-signed email and is a domain based service”, and for a message signed with a DKIM key enrolled in the program, Yahoo sends the enrolled address a report in ARF “so that the sender can suppress that recipient from further campaigns”.

Gmail does not work that way. Amazon SES says “Gmail doesn’t provide complaint data to SES”, and Resend names Gmail and Google Workspace as the most notable providers that do not return a complained event. Gmail’s view is the spam rate in Postmaster Tools, which needs the sending domain added and verified with a TXT or CNAME record, and whose data “might be missing if the total number of messages for a given day is too low”. A small app may see no rate at all, and then it leans on its own complaint events and support tickets.

So watch that rate where it shows. Keep one-click unsubscribe on marketing and subscribed messages: Google requires it of senders of more than 5,000 messages a day to Gmail accounts. And treat every complaint event you do receive as final. Domain authentication is the precondition for the Yahoo loop and for the rest of this, and it is set up in SPF, DKIM and DMARC setup.

What the user sees when their address is suppressed

A suppressed address needs a plain message where the user can act on it. The account page shows something like “Email to this address is not getting through. Update it or confirm it again.”, with a button for each. Postmark’s own bounce handling guide makes the same two points, under “Display a prominent notification to the user when they log in” and “Let users reactivate delivery”.

The sign-in and reset screens are different. They must not reveal whether an address exists or is suppressed, following OWASP’s Forgot Password Cheat Sheet: “Return a consistent message for both existent and non-existent accounts”. So the message appears only to a signed-in user or in the support view, where support can see the reason and the event time.

A changed address goes through confirmation before the app trusts it, which is the flow in how to send verification email and password resets. For admins I’d add a weekly count of new suppressions by reason. A jump in hard bounces there can point to the bot sign-up pattern from the table under “What goes wrong without it”.

Verifying an address before you send: what the best email verification tools check, and where they help

Email verification tools check an address before the first send: whether its syntax passes, whether the domain has MX records, and whether the mail server accepts the mailbox, with flags for disposable and accept-all domains. They help with an old or imported list. For an app’s own sign-ups, a confirmation email does that job.

This section is documented analysis of one vendor’s published field list, the Email Verifier in Hunter’s API documentation; no tool was tested for it. The middle column below is Hunter’s definition. The right-hand column is my interpretation, and it rests on one point: no check here proves that a person reads the mailbox or wants your mail.

CheckWhat it provesWhat it cannot prove
regexpthe address passes the vendor’s regular expressionthat the mailbox exists
gibberishthe address looks automatically generatedthat an unflagged address belongs to a person
disposablethe address is from a disposable email servicehow long a non-disposable address will be used
webmailthe address is from a webmail service such as Gmailanything about the person behind it
mx_recordsMX records exist on the domainthat this mailbox exists on it
smtp_serverthe checker connected to the mail serverthat the server will accept your mail later
smtp_checkthe address does not bouncethat anyone reads it
accept_allthe server accepts every address, so SMTP checks can give false positiveswhether this particular mailbox exists
blockthe server stopped the checkanything about the address

A bulk email verification tool runs the same kind of checks on an uploaded list instead of one address at a time. Whether you need one depends on where the addresses came from. I go by these:

SituationDo you need a tool?What to do instead
Your app’s own sign-upsnosend a confirmation email, and add a typo hint to the form
An old or imported list you have not mailed in monthsyes, once, before the first sendverify the list, then add the failures to your suppression table
A bought listno: do not send to itGoogle’s guidelines tell anyone who manages mailing lists to “send email only to people who want to get messages from you”

If you do need one, choose on criteria rather than a ranking: how it reports accept-all domains, whether and when it deletes your upload, where it processes the data, whether it offers a real-time API, a bulk upload or both, and the price per thousand checks, read from the vendor’s own page on the day you buy. ZeroBounce, Bouncer and Hunter are three vendors in this category; none of them was tested for this page, and none is ranked. The best free bulk email verifier a search turns up is still an upload form for your customers’ addresses, so read the free tier’s limits and the privacy terms before you upload a list.

How to verify it: a test bounce webhook suppresses future sends

Bounce handling is verified by simulating 3 provider events in a test environment: a hard bounce, a complaint and a soft bounce. The first two must leave the address in your own suppression table, and the next send to it must be skipped and logged. The soft bounce must not suppress anything.

Run it in a test or staging environment pointed at the provider’s simulator or sample events, never at real recipients. A new Amazon SES account starts in the sandbox, and that is no obstacle here: “You can use the mailbox simulator even if your account is in the Amazon SES sandbox”.

  1. 01 Send from the app to the provider hard-bounce test address: bounced@resend.dev, bounce@simulator.amazonses.com or hardbounce@bounce-testing.postmarkapp.com. SendGrid documents no such address, so on SendGrid record this step as not run, save the webhook, click Test Your Integration, and run steps 2 to 4 on the address of a bounce event in the example array, if it holds one. Evidence: the message id of the send.
  2. 02 Confirm that the webhook arrived and passed authentication. Evidence: the stored raw event and its key.
  3. 03 Confirm that the address is in your suppression table with the reason hard bounce. On Amazon SES this is the only list it will be on, because a simulator bounce is not added to the SES list. Evidence: the row.
  4. 04 Trigger another send to the same address from the app and confirm it is skipped and logged, with no call to the provider. Evidence: the skip log line, and no outgoing provider request in the app request log.
  5. 05 Repeat steps 1 to 4 with the complaint address: complained@resend.dev or complaint@simulator.amazonses.com. On Postmark, post the sample request from the spam complaint webhook docs; on SendGrid, use a spamreport event from the same example array, if it holds one. Evidence: a second row, with the reason complaint.
  6. 06 Send to a soft-bounce address such as softbounce@bounce-testing.postmarkapp.com. Where a provider documents none, feed a stored email.delivery_delayed or blocked payload to your event map in a unit test. Pass: nothing is suppressed, and the soft-bounce count for the address goes up. Evidence: the count.
  7. 07 Have one event delivered a second time: the Replay button on the webhook message on Resend, or the stored request posted again with its original headers on Postmark. Pass: the second request passed authentication and nothing is counted twice. Evidence: two authenticated receipts, one row, one count. A second request that your authentication refused tests nothing here; in that case, feed the same verified event to the store step twice in a unit test.

The SendGrid order in step 1 matters, because in its docs’ words “If you click Test Integration before clicking Save, signature verification will not be tested”. Its example array “will not include real data from your mail send”, and what the array holds is not stated in SendGrid’s docs. Resend says “You can replay both failed and succeeded webhook messages”, so step 7 works there even after a clean first delivery. The addresses come from Resend’s test email addresses, the Amazon SES mailbox simulator and Postmark’s fake bounces; Resend’s test emails count against your sending quota, and Postmark’s fake hard bounce also lands on that stream’s suppression list.

Then run one check against live data: compare the provider’s suppression list with yours and explain every difference. Keep the three event keys, the skipped-send log lines and the date of the run as the record.

In the sprint, deliverable 11.3 is verified this way: “Simulate provider events and verify suppression and subsequent send behavior”.

Where the sprint does this

Bounce and complaint handling is deliverable 11.3 of the Production Hardening Sprint, with its wording and its verify line as quoted above. Beside it sit deliverable 11.1, sending-domain authentication, verified this way: “Inspect DNS and received message headers for the configured authentication results”; and deliverable 11.2, transactional delivery tracking, verified this way: “Trigger each critical message type and confirm provider records and expected recipient delivery”. Deliverable 13.1, the production readiness report, delivers the result for every scope item, the work completed, and its verification evidence, and is verified this way: “Account for all 123 IDs; keep failures visible until resolved and explain genuine non-applicable items”. Hosting, paid tools, and API usage remain in your accounts. The wording for all three email items sits next to deliverable 11.3 in the published scope.

Common questions about bounced email and suppression lists

How do I calculate the email bounce rate?

Divide the bounced messages by the messages sent over the same period and multiply by 100, counting each stream on its own: 12 bounces in 2,000 sends is 0.6 percent.

Amazon SES works its figure out differently, from hard bounces to unverified domains only and over a representative volume of your mail rather than a set period, so its number can differ from yours.

Do emails bounce back if they don’t exist?

Usually yes. The receiving server answers with a permanent failure, such as code X.1.1 for a mailbox that does not exist, and your provider reports it as a hard bounce; Resend’s bounced@resend.dev test address produces an SMTP 550 5.1.1 of exactly that kind.

The exception is a server that accepts every address. Hunter’s API documentation warns that accept-all servers can give false positives on SMTP checks.

What does “email blocked due to suppression list” mean?

It means the provider skipped the send because the address is on its suppression list after an earlier hard bounce or complaint. Resend’s list works by “intentionally skipping sending to any addresses on your list”, and Amazon SES accepts such a message without sending it.

Fix the reason before you take the address off the list, or Resend will add it back on its own.

Why do some emails go to suppression list ses?

Amazon SES adds an address to the account-level suppression list after a hard bounce or a complaint, for the reasons your account’s settings name, and does so by default on accounts that started using SES after November 25, 2019. Soft bounces are never added, and a Gmail spam report adds nothing either, because Gmail sends SES no complaint data.

Why am I getting bounced emails that I didn’t send?

Because someone else sent mail with your address as the sender, and the failure reports for it come back to you. They go to the address in the message’s return path, which RFC 5321 says exists “to designate the address to which messages indicating non-delivery or other mail system failures are to be sent”.

Stopping other people from sending as your domain is a separate job: how to stop email spoofing.