[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-052638d7-e7b6-4fef-b6e9-ea4e497f1b33":3},{"id":4,"body":5,"uuid":6,"created_at":7,"updated_at":8,"brand":9,"header":10,"short_body":11,"image":12,"published":13,"published_at":14,"tags":15},19,"\u003Cp>Email bounce handling is the practice of detecting, classifying, and reacting to messages your mail server cannot deliver. If you run a SaaS product or ship transactional email from your app, ignoring bounces is one of the fastest ways to wreck your sender reputation, land in spam folders, and stop reaching real users. Good email bounce handling keeps your lists clean, protects your domain reputation, and ensures the password resets, receipts, and alerts your customers depend on actually arrive.\u003C\u002Fp>\n\u003Cp>This guide walks through everything an engineer needs: the difference between a hard bounce and a soft bounce, how to read bounce codes, how to build a suppression list, how to design retry logic, and how to process bounce webhooks with real code. By the end you will have a concrete, production-ready model for handling bounces in any transactional email system.\u003C\u002Fp>\n\u003Ch2>What Is an Email Bounce?\u003C\u002Fh2>\n\u003Cp>An email bounce occurs when a mail server rejects or fails to deliver a message and returns a notification—technically called a \u003Cstrong>Delivery Status Notification (DSN)\u003C\u002Fstrong> or \u003Cstrong>Non-Delivery Report (NDR)\u003C\u002Fstrong>—back to the sender. The bounce contains a status code and a human-readable reason explaining why delivery failed.\u003C\u002Fp>\n\u003Cp>Bounces fall into two broad categories that drive completely different responses:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Hard bounce\u003C\u002Fstrong> — a permanent failure. The address is invalid, the domain does not exist, or the mailbox is gone. You should never send to that address again.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Soft bounce\u003C\u002Fstrong> — a temporary failure. The mailbox is full, the server is down, or the message was greylisted. You can retry later.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Getting this distinction right is the foundation of email bounce handling. Treating a hard bounce as temporary means you keep hammering a dead address and damage your reputation. Treating a soft bounce as permanent means you suppress users who would have received mail just fine.\u003C\u002Fp>\n\u003Ch2>Hard Bounce vs Soft Bounce: The Core Distinction\u003C\u002Fh2>\n\u003Cp>The hard bounce soft bounce split determines whether you suppress an address forever or schedule a retry. Internet mail uses a structured status code defined in \u003Cstrong>RFC 3463\u003C\u002Fstrong> (Enhanced Mail System Status Codes) to communicate this. The code looks like \u003Ccode>class.subject.detail\u003C\u002Fcode>, for example \u003Ccode>5.1.1\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>The first digit—the \u003Cstrong>class\u003C\u002Fstrong>—is the most important signal:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Ccode>2.x.x\u003C\u002Fcode> — Success\u003C\u002Fli>\n\u003Cli>\u003Ccode>4.x.x\u003C\u002Fcode> — Persistent transient failure (soft bounce, retry)\u003C\u002Fli>\n\u003Cli>\u003Ccode>5.x.x\u003C\u002Fcode> — Permanent failure (hard bounce, suppress)\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Comparison Table: Hard Bounce vs Soft Bounce\u003C\u002Fh3>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Attribute\u003C\u002Fth>\n\u003Cth>Hard Bounce\u003C\u002Fth>\n\u003Cth>Soft Bounce\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>Status class\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>5.x.x\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>4.x.x\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Nature\u003C\u002Ftd>\n\u003Ctd>Permanent\u003C\u002Ftd>\n\u003Ctd>Temporary\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Common causes\u003C\u002Ftd>\n\u003Ctd>Invalid address, domain doesn't exist, mailbox closed\u003C\u002Ftd>\n\u003Ctd>Mailbox full, server down, message too large, greylisting\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Correct response\u003C\u002Ftd>\n\u003Ctd>Suppress immediately\u003C\u002Ftd>\n\u003Ctd>Retry with backoff\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Reputation impact\u003C\u002Ftd>\n\u003Ctd>High if repeated\u003C\u002Ftd>\n\u003Ctd>Low to moderate\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Retry?\u003C\u002Ftd>\n\u003Ctd>No\u003C\u002Ftd>\n\u003Ctd>Yes (with limits)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Example code\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>5.1.1\u003C\u002Fcode> (bad mailbox)\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>4.2.2\u003C\u002Fcode> (mailbox full)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch3>Common Hard Bounce Causes\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>The recipient address has a typo or never existed (\u003Ccode>5.1.1\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>The recipient domain has no MX records or does not exist (\u003Ccode>5.1.2\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>The mailbox has been deactivated or the employee left the company.\u003C\u002Fli>\n\u003Cli>The receiving server permanently blocked your domain or IP (\u003Ccode>5.7.1\u003C\u002Fcode>).\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Common Soft Bounce Causes\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>The recipient's mailbox is over quota (\u003Ccode>4.2.2\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>The receiving mail server is temporarily unavailable or overloaded (\u003Ccode>4.4.1\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>The message is too large for the recipient's limits (\u003Ccode>5.3.4\u003C\u002Fcode> is permanent, but some servers return \u003Ccode>4.x.x\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Greylisting\u003C\u002Fstrong> — the server deliberately defers first-time senders and expects a retry within a few minutes.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Understanding Bounce Codes\u003C\u002Fh2>\n\u003Cp>Bounce codes carry both a \u003Cstrong>reply code\u003C\u002Fstrong> (the SMTP three-digit code from RFC 5321) and an \u003Cstrong>enhanced status code\u003C\u002Fstrong> (the \u003Ccode>class.subject.detail\u003C\u002Fcode> from RFC 3463). You will see both in a real bounce.\u003C\u002Fp>\n\u003Cpre>\u003Ccode>550 5.1.1 The email account that you tried to reach does not exist.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Here \u003Ccode>550\u003C\u002Fcode> is the SMTP reply code and \u003Ccode>5.1.1\u003C\u002Fcode> is the enhanced status code. Reading them together gives you a reliable, machine-parseable signal.\u003C\u002Fp>\n\u003Ch3>Reference Table: Frequently Seen Bounce Codes\u003C\u002Fh3>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Enhanced code\u003C\u002Fth>\n\u003Cth>SMTP code\u003C\u002Fth>\n\u003Cth>Meaning\u003C\u002Fth>\n\u003Cth>Type\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>5.1.1\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>550\u003C\u002Ftd>\n\u003Ctd>Bad destination mailbox address\u003C\u002Ftd>\n\u003Ctd>Hard\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>5.1.2\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>550\u003C\u002Ftd>\n\u003Ctd>Bad destination system \u002F domain\u003C\u002Ftd>\n\u003Ctd>Hard\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>5.1.10\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>550\u003C\u002Ftd>\n\u003Ctd>Recipient address null \u002F does not accept mail\u003C\u002Ftd>\n\u003Ctd>Hard\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>5.2.1\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>550\u003C\u002Ftd>\n\u003Ctd>Mailbox disabled\u003C\u002Ftd>\n\u003Ctd>Hard\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>5.7.1\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>550\u002F554\u003C\u002Ftd>\n\u003Ctd>Delivery not authorized \u002F blocked\u003C\u002Ftd>\n\u003Ctd>Hard\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>4.2.2\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>452\u003C\u002Ftd>\n\u003Ctd>Mailbox full \u002F over quota\u003C\u002Ftd>\n\u003Ctd>Soft\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>4.4.1\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>421\u003C\u002Ftd>\n\u003Ctd>Connection \u002F server unavailable\u003C\u002Ftd>\n\u003Ctd>Soft\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>4.4.2\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>421\u003C\u002Ftd>\n\u003Ctd>Connection dropped\u003C\u002Ftd>\n\u003Ctd>Soft\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>4.7.0\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>421\u003C\u002Ftd>\n\u003Ctd>Temporary policy \u002F rate limit\u003C\u002Ftd>\n\u003Ctd>Soft\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Tip for featured snippets:\u003C\u002Fstrong> A bounce code starting with \u003Ccode>5\u003C\u002Fcode> is a \u003Cstrong>hard bounce\u003C\u002Fstrong> (permanent, suppress the address). A code starting with \u003Ccode>4\u003C\u002Fcode> is a \u003Cstrong>soft bounce\u003C\u002Fstrong> (temporary, retry later).\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cp>A practical caveat: not every receiving server follows the spec perfectly. Some return \u003Ccode>5.x.x\u003C\u002Fcode> codes for situations that are really temporary (a server briefly returning \u003Ccode>550\u003C\u002Fcode> under load), and some bury the real reason in the free-text portion. This is why mature email bounce handling combines the status class with text pattern matching and a retry threshold rather than trusting a single field.\u003C\u002Fp>\n\u003Ch2>Suppression Lists: Your First Line of Defense\u003C\u002Fh2>\n\u003Cp>A \u003Cstrong>suppression list\u003C\u002Fstrong> is a database of addresses you must never email again. Every hard bounce should add the address to this list, and your sending pipeline should check it before every send. This single mechanism prevents the most common reputation killer: repeatedly mailing dead addresses.\u003C\u002Fp>\n\u003Ch3>What Belongs on a Suppression List\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>\u003Cstrong>Hard bounces\u003C\u002Fstrong> — invalid or non-existent addresses.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Spam complaints\u003C\u002Fstrong> — recipients who hit \"mark as spam\" (received via Feedback Loops \u002F ARF reports).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Unsubscribes\u003C\u002Fstrong> — anyone who opted out, legally required under CAN-SPAM and GDPR.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Repeated soft bounces\u003C\u002Fstrong> — an address that soft-bounces consistently over many days behaves like a hard bounce and should be suppressed.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>A Minimal Suppression Schema\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-sql\">CREATE TABLE suppressions (\n    id            BIGSERIAL PRIMARY KEY,\n    email         TEXT NOT NULL UNIQUE,\n    reason        TEXT NOT NULL,          -- hard_bounce, complaint, unsubscribe, repeated_soft_bounce\n    bounce_code   TEXT,                   -- e.g. 5.1.1\n    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\nCREATE INDEX idx_suppressions_email ON suppressions (email);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Check it on the hot path before queuing any message:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">def is_suppressed(db, email: str) -&gt; bool:\n    row = db.execute(\n        &quot;SELECT 1 FROM suppressions WHERE email = %s LIMIT 1&quot;,\n        (email.lower().strip(),),\n    ).fetchone()\n    return row is not None\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Always normalize addresses (lowercase, trim) before comparing, or duplicates and casing differences will leak through.\u003C\u002Fp>\n\u003Ch2>Designing Retry Logic for Soft Bounces\u003C\u002Fh2>\n\u003Cp>Soft bounces deserve a second chance—but not an infinite one. The right approach is \u003Cstrong>exponential backoff with a hard cap\u003C\u002Fstrong>. You retry a failing message with progressively longer delays, and after a set number of attempts or total elapsed time, you give up and treat the address as suppressed.\u003C\u002Fp>\n\u003Cp>A common, battle-tested schedule for transactional mail:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Attempt\u003C\u002Fth>\n\u003Cth>Delay after previous\u003C\u002Fth>\n\u003Cth>Total elapsed\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>1\u003C\u002Ftd>\n\u003Ctd>immediate\u003C\u002Ftd>\n\u003Ctd>0\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>2\u003C\u002Ftd>\n\u003Ctd>15 minutes\u003C\u002Ftd>\n\u003Ctd>15 min\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>3\u003C\u002Ftd>\n\u003Ctd>1 hour\u003C\u002Ftd>\n\u003Ctd>~1.25 hr\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>4\u003C\u002Ftd>\n\u003Ctd>4 hours\u003C\u002Ftd>\n\u003Ctd>~5.25 hr\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>5\u003C\u002Ftd>\n\u003Ctd>12 hours\u003C\u002Ftd>\n\u003Ctd>~17 hr\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Stop\u003C\u002Ftd>\n\u003Ctd>—\u003C\u002Ftd>\n\u003Ctd>give up at ~24–72 hr\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Two guardrails matter:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Cap total retry duration.\u003C\u002Fstrong> Transactional email (a password reset, a 2FA code) is worthless after a day. A 24–72 hour window is standard; beyond that, suppress.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Promote persistent soft bouncers to suppressed.\u003C\u002Fstrong> If an address soft-bounces on, say, five separate sends over a week, it is effectively dead. Add it to the suppression list with reason \u003Ccode>repeated_soft_bounce\u003C\u002Fcode>.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cpre>\u003Ccode class=\"language-python\">import math\n\nMAX_ATTEMPTS = 5\nBASE_DELAY_MINUTES = 15\n\ndef next_retry_delay_minutes(attempt: int) -&gt; int | None:\n    &quot;&quot;&quot;Exponential backoff. Returns None when retries are exhausted.&quot;&quot;&quot;\n    if attempt &gt;= MAX_ATTEMPTS:\n        return None\n    # 15, 30, 60, 120, 240 ... minutes\n    return BASE_DELAY_MINUTES * int(math.pow(2, attempt))\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>When \u003Ccode>next_retry_delay_minutes\u003C\u002Fcode> returns \u003Ccode>None\u003C\u002Fcode>, stop retrying and suppress if the pattern warrants it.\u003C\u002Fp>\n\u003Ch2>Reputation Impact: Why Bounces Matter\u003C\u002Fh2>\n\u003Cp>Mailbox providers like Gmail, Outlook, and Yahoo track your bounce rate as a core signal of list hygiene. A high bounce rate tells them you are mailing addresses you do not maintain—a classic spammer behavior. The consequences compound:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Lower inbox placement\u003C\u002Fstrong> — your legitimate mail starts landing in spam.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Throttling\u003C\u002Fstrong> — providers slow down or temporarily reject your messages (\u003Ccode>4.7.0\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Blocklisting\u003C\u002Fstrong> — your domain or IP gets added to a blocklist, hard-bouncing everything.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Google's own sender guidelines, in effect since February 2024, ask bulk senders to \u003Cstrong>keep spam complaint rates below 0.3%\u003C\u002Fstrong> and to maintain proper authentication (SPF, DKIM, DMARC). While Google publishes a complaint-rate target rather than a single official bounce-rate number, the industry consensus from deliverability vendors is to \u003Cstrong>keep your bounce rate under 2%\u003C\u002Fstrong>, and ideally under 1%. Above ~5% you are in danger territory and many platforms will pause your account.\u003C\u002Fp>\n\u003Cp>The math is simple: every hard bounce you could have prevented by maintaining a suppression list is a self-inflicted hit to your reputation. Email bounce handling is not cleanup work—it is reputation insurance.\u003C\u002Fp>\n\u003Ch3>Authentication Reduces Bounces Too\u003C\u002Fh3>\n\u003Cp>Some \u003Ccode>5.7.x\u003C\u002Fcode> bounces are authentication failures, not bad addresses. Configuring \u003Cstrong>SPF\u003C\u002Fstrong>, \u003Cstrong>DKIM\u003C\u002Fstrong>, and \u003Cstrong>DMARC\u003C\u002Fstrong> correctly prevents receiving servers from rejecting your legitimate mail as unauthenticated. Authentication and bounce handling are two halves of deliverability.\u003C\u002Fp>\n\u003Ch2>Processing Bounce Webhooks\u003C\u002Fh2>\n\u003Cp>Modern email APIs do not make you parse raw DSN emails. Instead, they POST a structured JSON event to a \u003Cstrong>webhook\u003C\u002Fstrong> URL whenever a message bounces. Your job is to receive that event, classify it, and update your suppression list and retry queue.\u003C\u002Fp>\n\u003Cp>A typical bounce webhook payload looks like this:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;event&quot;: &quot;bounce&quot;,\n  &quot;message_id&quot;: &quot;a1b2c3d4-0000-1111-2222-333344445555&quot;,\n  &quot;recipient&quot;: &quot;user@example.com&quot;,\n  &quot;bounce_type&quot;: &quot;hard&quot;,\n  &quot;smtp_code&quot;: &quot;550&quot;,\n  &quot;status_code&quot;: &quot;5.1.1&quot;,\n  &quot;diagnostic&quot;: &quot;550 5.1.1 The email account that you tried to reach does not exist.&quot;,\n  &quot;timestamp&quot;: &quot;2026-06-24T10:15:00Z&quot;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Step 1: Verify the Webhook Signature\u003C\u002Fh3>\n\u003Cp>Never trust an unauthenticated webhook. Anyone who discovers your endpoint could otherwise suppress your entire user base. Most providers sign the request body with an HMAC you can verify.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import hashlib\nimport hmac\n\ndef verify_signature(secret: str, payload: bytes, signature: str) -&gt; bool:\n    expected = hmac.new(\n        secret.encode(&quot;utf-8&quot;),\n        payload,\n        hashlib.sha256,\n    ).hexdigest()\n    return hmac.compare_digest(expected, signature)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Use \u003Ccode>hmac.compare_digest\u003C\u002Fcode> rather than \u003Ccode>==\u003C\u002Fcode> to avoid timing attacks.\u003C\u002Fp>\n\u003Ch3>Step 2: Classify the Bounce\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-python\">def classify_bounce(status_code: str, diagnostic: str) -&gt; str:\n    &quot;&quot;&quot;Return 'hard', 'soft', or 'unknown' from an enhanced status code.&quot;&quot;&quot;\n    code = (status_code or &quot;&quot;).strip()\n\n    if code.startswith(&quot;5&quot;):\n        return &quot;hard&quot;\n    if code.startswith(&quot;4&quot;):\n        return &quot;soft&quot;\n\n    # Fall back to text matching when the code is missing or malformed.\n    text = (diagnostic or &quot;&quot;).lower()\n    hard_signals = (&quot;does not exist&quot;, &quot;no such user&quot;, &quot;unknown user&quot;,\n                    &quot;mailbox unavailable&quot;, &quot;user unknown&quot;, &quot;no mailbox&quot;)\n    soft_signals = (&quot;mailbox full&quot;, &quot;over quota&quot;, &quot;try again&quot;,\n                    &quot;temporarily&quot;, &quot;rate limit&quot;, &quot;greylist&quot;)\n\n    if any(s in text for s in hard_signals):\n        return &quot;hard&quot;\n    if any(s in text for s in soft_signals):\n        return &quot;soft&quot;\n    return &quot;unknown&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Step 3: Act on the Classification\u003C\u002Fh3>\n\u003Cp>Here is a complete Flask endpoint that ties verification, classification, suppression, and retry together. All imports are at the top of the file.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import hashlib\nimport hmac\nimport os\n\nfrom flask import Flask, request, abort, jsonify\n\napp = Flask(__name__)\n\nWEBHOOK_SECRET = os.environ[&quot;BOUNCE_WEBHOOK_SECRET&quot;]\nSOFT_BOUNCE_SUPPRESS_THRESHOLD = 5\n\n\ndef verify_signature(secret: str, payload: bytes, signature: str) -&gt; bool:\n    expected = hmac.new(secret.encode(&quot;utf-8&quot;), payload, hashlib.sha256).hexdigest()\n    return hmac.compare_digest(expected, signature)\n\n\ndef classify_bounce(status_code: str, diagnostic: str) -&gt; str:\n    code = (status_code or &quot;&quot;).strip()\n    if code.startswith(&quot;5&quot;):\n        return &quot;hard&quot;\n    if code.startswith(&quot;4&quot;):\n        return &quot;soft&quot;\n    text = (diagnostic or &quot;&quot;).lower()\n    if any(s in text for s in (&quot;does not exist&quot;, &quot;no such user&quot;, &quot;user unknown&quot;)):\n        return &quot;hard&quot;\n    if any(s in text for s in (&quot;mailbox full&quot;, &quot;over quota&quot;, &quot;try again&quot;, &quot;temporarily&quot;)):\n        return &quot;soft&quot;\n    return &quot;unknown&quot;\n\n\ndef suppress(email: str, reason: str, code: str) -&gt; None:\n    # Insert into the suppressions table; ignore if already present.\n    db.execute(\n        &quot;&quot;&quot;INSERT INTO suppressions (email, reason, bounce_code)\n           VALUES (%s, %s, %s)\n           ON CONFLICT (email) DO NOTHING&quot;&quot;&quot;,\n        (email.lower().strip(), reason, code),\n    )\n\n\ndef record_soft_bounce(email: str) -&gt; int:\n    # Increment and return the running soft-bounce count for this address.\n    row = db.execute(\n        &quot;&quot;&quot;INSERT INTO soft_bounce_counts (email, count)\n           VALUES (%s, 1)\n           ON CONFLICT (email) DO UPDATE SET count = soft_bounce_counts.count + 1\n           RETURNING count&quot;&quot;&quot;,\n        (email.lower().strip(),),\n    ).fetchone()\n    return row[0]\n\n\n@app.route(&quot;\u002Fwebhooks\u002Fbounce&quot;, methods=[&quot;POST&quot;])\ndef handle_bounce():\n    signature = request.headers.get(&quot;X-Signature&quot;, &quot;&quot;)\n    if not verify_signature(WEBHOOK_SECRET, request.get_data(), signature):\n        abort(401)\n\n    event = request.get_json(force=True)\n    if event.get(&quot;event&quot;) != &quot;bounce&quot;:\n        return jsonify({&quot;status&quot;: &quot;ignored&quot;}), 200\n\n    email = event[&quot;recipient&quot;]\n    code = event.get(&quot;status_code&quot;, &quot;&quot;)\n    diagnostic = event.get(&quot;diagnostic&quot;, &quot;&quot;)\n    bounce_type = classify_bounce(code, diagnostic)\n\n    if bounce_type == &quot;hard&quot;:\n        suppress(email, &quot;hard_bounce&quot;, code)\n    elif bounce_type == &quot;soft&quot;:\n        count = record_soft_bounce(email)\n        if count &gt;= SOFT_BOUNCE_SUPPRESS_THRESHOLD:\n            suppress(email, &quot;repeated_soft_bounce&quot;, code)\n        # else: leave it for the retry queue to pick up.\n\n    return jsonify({&quot;status&quot;: &quot;processed&quot;, &quot;type&quot;: bounce_type}), 200\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Step 4: Return 200 Fast and Process Async\u003C\u002Fh3>\n\u003Cp>Webhook providers retry if you do not respond quickly. For heavy work (database writes, notifications), acknowledge with \u003Ccode>200\u003C\u002Fcode> immediately and push the event onto a queue for a worker to process. This keeps the endpoint snappy and avoids duplicate deliveries from provider retries. Make your handler \u003Cstrong>idempotent\u003C\u002Fstrong>—use the \u003Ccode>message_id\u003C\u002Fcode> as a dedup key—because providers can and do send the same event more than once.\u003C\u002Fp>\n\u003Ch2>Common Mistakes in Email Bounce Handling\u003C\u002Fh2>\n\u003Cp>Even experienced teams trip over these. Avoid them:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Treating all bounces the same.\u003C\u002Fstrong> Suppressing soft bounces loses real users; retrying hard bounces destroys your reputation. Always classify first.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No suppression list at all.\u003C\u002Fstrong> Re-mailing addresses that already hard-bounced is the single fastest way to get blocklisted.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Trusting the status class blindly.\u003C\u002Fstrong> Some servers misreport. Combine the \u003Ccode>4.x\u002F5.x\u003C\u002Fcode> class with text matching and a retry threshold.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Infinite retries.\u003C\u002Fstrong> Retrying a soft bounce forever wastes resources and signals spammy behavior. Always cap attempts and total duration.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Skipping webhook signature verification.\u003C\u002Fstrong> An unsigned endpoint lets attackers suppress your users or inject fake events.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Non-idempotent handlers.\u003C\u002Fstrong> Providers resend events. Without dedup on \u003Ccode>message_id\u003C\u002Fcode>, you double-count soft bounces and may suppress prematurely.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Ignoring spam complaints.\u003C\u002Fstrong> Complaints hurt reputation more than bounces. Feed Feedback Loop (ARF) reports into the same suppression list.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Forgetting to normalize addresses.\u003C\u002Fstrong> \u003Ccode>User@Example.com\u003C\u002Fcode> and \u003Ccode>user@example.com\u003C\u002Fcode> are the same mailbox; case-sensitive checks let dupes slip through.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No monitoring.\u003C\u002Fstrong> If you do not chart your bounce rate over time, you will not notice a bad import or a broken signup form until your reputation is already damaged.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>FAQ\u003C\u002Fh2>\n\u003Ch3>What is the difference between a hard bounce and a soft bounce?\u003C\u002Fh3>\n\u003Cp>A \u003Cstrong>hard bounce\u003C\u002Fstrong> is a permanent delivery failure (status code \u003Ccode>5.x.x\u003C\u002Fcode>)—the address is invalid or no longer exists, so you should suppress it and never send to it again. A \u003Cstrong>soft bounce\u003C\u002Fstrong> is a temporary failure (status code \u003Ccode>4.x.x\u003C\u002Fcode>)—the mailbox is full or the server is briefly down, so you can retry later. The hard bounce soft bounce distinction is the core of email bounce handling.\u003C\u002Fp>\n\u003Ch3>What is a good email bounce rate?\u003C\u002Fh3>\n\u003Cp>Aim to keep your overall bounce rate \u003Cstrong>under 2%\u003C\u002Fstrong>, and ideally under 1%. Above roughly 5%, many email platforms will throttle or pause your account, and mailbox providers may start filtering your mail to spam. Lower is always better and reflects good list hygiene.\u003C\u002Fp>\n\u003Ch3>How do I read an email bounce code?\u003C\u002Fh3>\n\u003Cp>A bounce includes an SMTP reply code (e.g. \u003Ccode>550\u003C\u002Fcode>) and an enhanced status code in \u003Ccode>class.subject.detail\u003C\u002Fcode> format (e.g. \u003Ccode>5.1.1\u003C\u002Fcode>) from RFC 3463. The first digit is what matters most: \u003Ccode>5\u003C\u002Fcode> means a permanent hard bounce, \u003Ccode>4\u003C\u002Fcode> means a temporary soft bounce, and \u003Ccode>2\u003C\u002Fcode> means success.\u003C\u002Fp>\n\u003Ch3>Should I retry sending after a bounce?\u003C\u002Fh3>\n\u003Cp>Retry only \u003Cstrong>soft bounces\u003C\u002Fstrong> (\u003Ccode>4.x.x\u003C\u002Fcode>), using exponential backoff with a cap—for example 15 minutes, then 1 hour, then 4 hours, giving up after about 24–72 hours. Never retry \u003Cstrong>hard bounces\u003C\u002Fstrong> (\u003Ccode>5.x.x\u003C\u002Fcode>); add those addresses to your suppression list immediately.\u003C\u002Fp>\n\u003Ch3>What is a suppression list and do I need one?\u003C\u002Fh3>\n\u003Cp>A suppression list is a database of addresses you must never email again—hard bounces, spam complainers, and unsubscribes. Yes, you need one. Checking it before every send prevents the reputation damage that comes from re-mailing dead addresses, and it is required for unsubscribe compliance under CAN-SPAM and GDPR.\u003C\u002Fp>\n\u003Ch3>How do bounces affect my sender reputation?\u003C\u002Fh3>\n\u003Cp>Mailbox providers treat high bounce rates as a signal that you do not maintain your lists—classic spammer behavior. The result is lower inbox placement, throttling, and eventually blocklisting. Proactive email bounce handling, combined with SPF, DKIM, and DMARC authentication, protects your reputation.\u003C\u002Fp>\n\u003Ch3>How do I process bounces from a webhook?\u003C\u002Fh3>\n\u003Cp>Receive the provider's JSON event, verify its HMAC signature, classify the bounce as hard or soft from its status code, then suppress hard bounces and queue soft bounces for retry. Respond with \u003Ccode>200\u003C\u002Fcode> quickly, process heavy work asynchronously, and make the handler idempotent using the \u003Ccode>message_id\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Ch3>Can soft bounces ever become hard bounces?\u003C\u002Fh3>\n\u003Cp>Yes. If an address soft-bounces repeatedly over days or weeks—say five or more times—it behaves like a permanently dead mailbox. Promote it to your suppression list with a reason like \u003Ccode>repeated_soft_bounce\u003C\u002Fcode> so you stop wasting sends and protect your reputation.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Email bounce handling is not optional plumbing—it is core to whether your transactional email reaches users at all. The model is straightforward once you internalize it:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Classify\u003C\u002Fstrong> every bounce as hard (\u003Ccode>5.x.x\u003C\u002Fcode>) or soft (\u003Ccode>4.x.x\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Suppress\u003C\u002Fstrong> hard bounces immediately and never mail them again.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Retry\u003C\u002Fstrong> soft bounces with exponential backoff and a hard cap.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Promote\u003C\u002Fstrong> persistent soft bouncers to your suppression list.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Verify\u003C\u002Fstrong> webhooks, make handlers idempotent, and process asynchronously.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Monitor\u003C\u002Fstrong> your bounce rate and keep it under 2%.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Do these consistently and you protect your sender reputation, keep your lists clean, and ensure the password resets, receipts, and alerts your customers rely on actually land in their inboxes.\u003C\u002Fp>\n\u003Ch2>Send Reliable Transactional Email with Postwing\u003C\u002Fh2>\n\u003Cp>Building robust email bounce handling from scratch—DSN parsing, suppression lists, retry queues, signed webhooks—is real engineering work. \u003Cstrong>Postwing\u003C\u002Fstrong> handles it for you. Our transactional email platform automatically classifies hard and soft bounces, maintains your suppression list, manages retry logic, and delivers clean, signed bounce and complaint webhooks straight to your app, with SPF, DKIM, and DMARC handled out of the box.\u003C\u002Fp>\n\u003Cp>Postwing is built for developers and SaaS teams, with a simple API, transparent deliverability metrics, and \u003Cstrong>USDC (crypto) payments on Base\u003C\u002Fstrong>—no credit card required, no surprise billing. Spend your time shipping features, not parsing bounce codes.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Start sending with Postwing →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>","052638d7-e7b6-4fef-b6e9-ea4e497f1b33","2026-06-24T18:28:18.065422+03:00","2026-07-15T23:56:23.854116+03:00","postwing","How to Handle Email Bounces","Master email bounce handling: hard vs soft bounces, bounce codes, suppression lists, retry logic, and webhook processing with practical code examples.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_052638d7-e7b6-4fef-b6e9-ea4e497f1b33\u002Fhandle-email-bounces.png",true,"2026-07-30T09:00:00+03:00",[16,17],"email bounce handling","hard bounce soft bounce"]