Back to blog
Deliverability··15 min read·WillItInbox Team

Bounces decoded: hard, soft, block, and how to handle each

What every SMTP bounce code actually means, which ones to retry, and the suppression rules that keep your reputation intact.

BouncesDeliverabilitySMTP

A bounce is a delivery failure with a story. Treat them all the same — retry forever, never suppress — and your reputation collapses in weeks. Read them properly, and they're the single best signal you have about list health and infrastructure problems.

The two-tier code system

Every bounce comes with two codes: the basic SMTP reply (5xx or 4xx) and an enhanced status code (5.1.1, 4.7.1, etc., from RFC 3463). The SMTP code tells you whether to retry. The enhanced code tells you why.

ClassMeaning
2.x.xSuccess
4.x.xPersistent transient failure (retry)
5.x.xPermanent failure (suppress)
Enhanced status code structure: class.subject.detail

Hard bounces (5xx) — suppress immediately

CodeMeaningAction
550 5.1.1Mailbox does not existSuppress permanently
550 5.1.10Recipient address rejectedSuppress
550 5.4.1Recipient address rejected — relay deniedLikely typo
552 5.2.2Mailbox fullSoft-treat: retry once, then suppress if persists
553 5.1.3Bad destination address syntaxSuppress
Common hard bounce codes and what they mean.

Soft bounces (4xx) — retry with backoff

CodeMeaningStrategy
421 4.7.0Service temporarily unavailableStandard retry
450 4.2.1Mailbox temporarily unavailableStandard retry
451 4.7.1GreylistedWait 5–15 min, retry
452 4.2.2Mailbox fullRetry slowly, suppress after 5 days
421 4.7.0Try again later (rate limit)Honor backoff signal
Common soft bounce codes.

Standard retry strategy: 4 attempts over 72 hours with exponential backoff (15min, 1h, 6h, 24h). After that, suppress. Most ESPs do this automatically; if you're hand-rolling, set a time-based ceiling and stop.

Block bounces — the dangerous middle ground

Block bounces look like 5xx but indicate reputation problems, not bad addresses. They mean the receiver is rejecting your IP or domain, not the recipient. Suppressing the address won't help; you have to fix reputation.

CodeMeaningReal fix
550 5.7.1Message blocked due to policyCheck authentication, content, IP reputation
550 5.7.26DMARC policy not alignedFix DMARC alignment
554 5.7.1Spam content detectedAudit content; check SpamAssassin score
421 4.7.0 IP throttledSending too fastSlow down, distribute IPs
550 5.7.605 (Microsoft)DBL hitCheck Spamhaus listing

Where bounces actually go: Return-Path and SPF alignment

None of the categories above help if the bounce never reaches you. Bounces don't go to the visible From address — they go to the Return-Path, the envelope sender (MAIL FROM) from the SMTP transaction. Your ESP usually sets it, often rewriting it to its own bounce domain, and the receiving server records the final value as the Return-Path: header. If that address points at a mailbox nobody parses, you're blind to every failure this post just taught you to classify.

HeaderVisible to recipient?Used for
From:YesDisplay, replies (unless overridden)
Return-Path / MAIL FROMNo (in envelope)Bounce delivery, SPF check, DMARC SPF alignment
Reply-To:Yes (when user replies)Reply routing
Two different addresses, two different jobs.

Point the Return-Path at a dedicated subdomain like em.example.com instead of your main domain. That lets you publish a tightly scoped SPF record for the sending stream, keeps bounce traffic out of corporate mailboxes, and means ESP IP rotations only ever touch the subdomain's record.

DNS record
HostTypeValueTTL
em.example.comTXTv=spf1 include:_spf.your-esp.com -all3600
SPF for a bounce subdomain.

The Return-Path domain is also the domain SPF authenticates — not the From domain. For SPF-based DMARC authentication, the two must align: a bounce subdomain under your organizational domain aligns in the default relaxed mode, while a Return-Path on your ESP's own domain does not align at all. In that second case you're relying on DKIM alignment alone, so a missing or broken DKIM signature takes the whole DMARC result down. The DMARC alignment guide covers strict versus relaxed rules, and the SPF cheat sheet covers the record syntax; the SPF checker will expand the include chain on your envelope domain.

Two operational details worth knowing. Most ESPs use VERP (Variable Envelope Return Path), which encodes the recipient into the bounce address — [email protected] — so every bounce maps to exactly one subscriber even when the DSN body is mangled. And bounce messages themselves are sent with an empty Return-Path (<>, the null sender) so a bounce can never trigger another bounce. Never use the null sender for your own outbound mail.

Sample DSN excerpttext
Action: failed
Status: 5.1.1
Diagnostic-Code: smtp; 550 5.1.1 The email account that you tried to reach does not exist.
Final-Recipient: rfc822; missing [at] example.com

Route this mailbox into the handler below, not a human inbox. The Status: field is where the enhanced code your parser should key on actually lives.

Designing a bounce handler

What every bounce processor should do

  1. 01

    Parse the SMTP code AND enhanced code

    Don't rely on regex over English failure text — it's inconsistent across receivers. The numeric codes are standardized.

  2. 02

    Categorize: hard, soft, block

    Hard → suppress. Soft → retry queue. Block → alert and investigate.

  3. 03

    Suppress on hard, with timestamp and reason

    Store why a recipient was suppressed. Future debugging is impossible without this.

  4. 04

    Backoff and ceiling for soft

    Standard: 4 retries over 72 hours. After ceiling, suppress with a different reason code ("prolonged soft").

  5. 05

    Alert on spike

    Sudden bounce rate change is the earliest warning of an infrastructure problem. Alert on >2x baseline within an hour.

The bounce rate ceiling

Major receivers throttle senders whose bounce rate exceeds about 5% over a rolling 24-hour window. Healthy lists run under 2%. If you're above 5%, you're either sending to a bought list or your suppression isn't working — both reputation-fatal.

Additional guidance: Email Webhooks: Handling Bounces, Complaints, and Events Programmatically

An email bounce webhook is an HTTP POST your email provider sends to your server the moment a message bounces, gets delivered, or generates a complaint — carrying structured JSON you can act on in code. The critical automation: hard bounces and spam complaints should trigger an instant write to your suppression list, so the address never gets mailed again. Done right, webhooks keep your sender reputation clean automatically; done wrong (or ignored), bounce rates climb until Gmail and Yahoo start rejecting you.

This post is the implementation guide: what the events look like, how to build the endpoint, and which automations actually matter.

The event taxonomy: what your provider will send you

Every major email API (SendGrid, Postmark, SES, Mailgun, Resend, and the rest) emits roughly the same event set. Names vary; semantics don't:

EventMeaningReputation impactYour response
queued / acceptedProvider accepted the messageNoneLog for latency tracking
deliveredReceiving server accepted it (250 OK)Positive signalUpdate message state
deferredTemporary failure (4xx), provider will retryNeutral unless chronicMonitor; investigate if persistent
bounced (hard, 5xx)Permanent failure — dead address, rejectedSevereSuppress immediately
bounced (soft, 4xx exhausted)Temporary failures gave upMild–moderateSuppress after N occurrences
complainedRecipient hit "Report spam"Most severeSuppress instantly, never re-add
unsubscribedOpt-out via link or one-clickNeutral (it's compliance)Sync to suppression/preferences
opened / clickedTracking pixel/link firedWeak signalAnalytics only — treat as noisy, especially since Apple Mail Privacy Protection pre-loads pixels

Two events carry nearly all the reputation risk: hard bounces and complaints. Gmail's spam-complaint threshold sits around 0.1% (Yahoo tolerates roughly 0.3%) — sustained rates above that, and bulk mail starts getting permanently rejected (550 5.7.26 at Gmail, 550 5.7.515 at Microsoft, both enforced since 2025). Everything else in your webhook pipeline exists to serve those two suppressions quickly. The full breakdown of bounce types and which ones deserve suppression is in bounce categories and handling.

Payload anatomy: what a bounce webhook actually looks like

Payloads differ per provider, but a typical hard-bounce event:

json
{
  "event_id": "evt_01K4XQ7F9Z2N3P8R",
  "event_type": "bounced",
  "bounce_class": "hard",
  "timestamp": "2026-08-14T09:31:07Z",
  "message": {
    "id": "msg_01K4XM2T...",
    "from": "[email protected]",
    "to": "[email protected]",
    "subject": "Your receipt for order #8412",
    "tags": ["receipt", "prod"]
  },
  "smtp": {
    "response_code": 550,
    "enhanced_code": "5.1.1",
    "response": "550 5.1.1 The email account that you tried to reach does not exist."
  },
  "metadata": {
    "user_id": "77123",
    "order_id": "8412"
  }
}

The fields that matter operationally:

  • `event_id` — your idempotency key for processing (more below).
  • `bounce_class` or the enhanced code (5.1.1 = mailbox doesn't exist, 5.7.26 = authentication rejection) — this drives the suppress-vs-retry decision. 5.x.x permanent → suppress. 4.x.x → it's a deferral in disguise.
  • `to` — the address to suppress. Normalize it (lowercase, trim) before writing.
  • `metadata` — whatever you attached at send time. This is why you tag sends with user/order IDs: a complaint event can then trigger "email this user's account team" or "suppress this user across all streams," not just one address.
  • `tags` — stream identification. A complaint on tags: ["newsletter"] suppresses from marketing; whether it suppresses from transactional is a policy decision you make deliberately (usually: complaints suppress marketing, but transactional continues — password resets are expected mail).

For how these codes map to real-world rejection text, see SMTP error codes explained. If you're choosing the sending layer that emits these events, SMTP vs API for email sending covers why webhooks are an API-native advantage. You can inspect live event shapes in the WillItInbox API docs, which expose delivery, bounce, and complaint events across 100+ endpoints.

Endpoint requirements: fast 2xx, then process async

The single most common webhook bug: doing the work inside the HTTP handler. Your endpoint must respond with a 2xx within the provider's timeout (often 5–15 seconds) or the provider marks the delivery failed and retries — multiplying your traffic and confusing your dedupe logic.

The pattern that survives production:

  1. Receive — verify signature (below), return 200 OK fast.
  2. Enqueue — push the raw payload onto a queue (SQS, Pub/Sub, Redis stream, whatever you run).
  3. Process — a worker consumes the queue: dedupe, classify, suppress, alert, store.

Minimal pseudocode handler:

python
def webhook_endpoint(request):
    # 1. Verify before anything else
    if not verify_signature(request.body, request.headers["X-Signature"], WEBHOOK_SECRET):
        return 401

    payload = parse_json(request.body)

    # 2. Fast ack — no business logic here
    queue.publish("email-events", payload)
    return 200

def process_event(payload):
    # 3. Idempotency: skip duplicates
    if already_processed(payload["event_id"]):
        return
    mark_processed(payload["event_id"], ttl_days=30)

    event = payload["event_type"]
    addr  = normalize(payload["message"]["to"])

    # 4. The automations that protect reputation
    if event == "bounced" and payload["bounce_class"] == "hard":
        suppression.add(addr, reason="hard_bounce", scope="all_streams")
        metrics.incr("bounce.hard")
    elif event == "bounced":  # soft
        strikes.incr(addr)
        if strikes.get(addr) >= 3:
            suppression.add(addr, reason="soft_bounce_repeated", scope="all_streams")
    elif event == "complained":
        suppression.add(addr, reason="complaint", scope="marketing")  # permanent
        alert_if_complaint_rate_spike()
    elif event == "unsubscribed":
        preferences.opt_out(addr, topics=payload.get("topics"))

    store_event(payload)  # audit trail

Notice what's not in the HTTP handler: databases, suppression writes, alerting. All behind the queue, where a slow database can't turn into webhook timeouts.

Signature verification: never skip this

Your webhook endpoint is a public URL that writes to your suppression list. Without verification, anyone who finds the URL can POST fake complained events and poison your list — a real attack pattern, not a hypothetical.

Every serious provider signs webhook payloads, typically HMAC-SHA256 of the raw body with a per-endpoint secret, delivered in a header:

X-Webhook-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015...

Verification rules:

  • Hash the raw request body, not a re-serialized parse — JSON key reordering breaks signatures.
  • Compare with a constant-time function (hmac.compare_digest, not ==) to avoid timing attacks.
  • Enforce timestamp freshness if the provider includes a timestamp header: reject events older than ~5 minutes to kill replay attacks.
  • Rotate secrets the same way you rotate API keys, especially after anyone with access leaves.

If your provider also publishes static egress IPs for webhooks, IP allowlisting is a worthwhile second layer — but it complements the signature check, never replaces it.

Idempotent processing: duplicates will happen

Providers deliver webhooks at-least-once. Timeouts, retries, and queue redeliveries on your own side all produce duplicates. Your processing must be idempotent: handling the same event_id twice must produce the same state as once.

The mechanics are simple: keep a processed-events store (Redis set with a 30-day TTL works fine — events older than that don't matter operationally), check-and-set atomically before processing. Also make the effects idempotent where possible: suppression.add() should be an upsert, not an insert that throws on duplicates and fails the whole batch.

One subtlety: dedupe on event_id, not on (recipient, event_type). A recipient can legitimately bounce from two different messages minutes apart, and you want both events in your audit trail even though only one suppression write results.

Provider-side retry semantics (know what you're tuning against)

When your endpoint fails — timeout, 5xx, connection refused — providers retry with backoff. Typical behavior across the industry: retries over 24–72 hours with exponential gaps, then the event is dropped (some providers move it to a "failed events" list you can re-pull via API).

Practical implications:

  • A brief outage is recoverable. Don't page anyone for a 10-minute webhook downtime; the provider's retries will backfill.
  • A long outage loses events. Beyond the retry window, you need the provider's event-polling API to reconcile. Build a nightly reconciliation job that pulls the previous day's events and diffs against what you processed. This catches both dropped webhooks and silent endpoint misconfigurations.
  • Return the right status codes. 2xx = processed. 5xx = retry. Never return 4xx for transient problems — most providers treat 4xx as "permanently rejected, stop retrying" and you'll silently lose events. Reserve 4xx for actual signature failures (401) and malformed payloads (400).
  • Mind your inbound rate limits if the endpoint sits behind an API gateway or WAF. A provider retry storm after an outage can trip rate limiters and extend the outage. The same throttling and rate-limit thinking you apply to sending applies to receiving events.

The automation that actually matters: instant suppression writes

Everything above is plumbing. Here's the water: hard bounce or complaint → suppression-list entry, in seconds, before your next send.

Why the urgency? Bounce and complaint rates are computed over rolling windows. If a list segment starts bouncing at 6% and your suppression lag is 24 hours, every campaign in that window re-hits the dead addresses, compounding the signal that got you flagged. Providers like Gmail don't grade on effort; they grade on observed behavior. A suppression write that happens in 10 seconds instead of 10 hours can be the difference between a warning-level bump and a 550 5.7.26 rejection wall.

Implementation checklist:

  1. Central suppression store — one source of truth, keyed by normalized address, with reason and timestamp fields.
  2. Checked at send time — every send path (API, SMTP relay, CRM, support desk) must consult it. A suppression list your newsletter tool ignores is decoration.
  3. Synced across tools — this is where teams bleed. The ESP suppresses the address, but your marketing automation platform, your CRM sequences, and your homegrown billing mailer all keep sending. Sync suppression outward via API or scheduled export; the email suppression lists guide covers the cross-tool sync patterns in detail.
  4. Complaints are forever — never re-add a complainer, even if they re-subscribe. Too risky. Hard bounces can be re-added only after the address is re-verified.

The pre-send complement: suppression handles addresses that go bad; validation keeps bad addresses from entering in the first place. Running new or stale segments through bulk email validation — 12 layers including SMTP handshake, catch-all detection, and spam-trap signals — removes most hard bounces before they can become webhook events at all. Prevention plus suppression is the whole game.

Alerting thresholds: catch the spike before the providers do

Your webhook stream is also your early-warning system. Wire alerts on rolling rates, not absolutes:

SignalWarningPage someone
Hard bounce rate>1% over 1h>2% over 1h
Complaint rate>0.05% over 24h>0.1% (Gmail's threshold)
Deferral rate to one provider>10% over 1h>30% — you're being throttled
550 5.7.26 / 5.7.515 rejectionsAny sustained occurrenceImmediately — auth is broken

The action on a page-worthy spike is uncomfortable but correct: pause the affected stream, diagnose, then resume. Sending through a bounce spike to "finish the campaign" trades a short delay for weeks of reputation repair. Because your events carry tags, you can pause surgically — kill the onboarding drip, keep receipts flowing.

Store every event, even the boring delivered ones, for at least 90 days. When deliverability degrades gradually (and it usually degrades gradually), the historical event data is how you find the week it started and what changed.

Before any of this touches production traffic, exercise the whole event flow in the email sandbox: trigger sends, bounces, and complaints against a safe environment and watch your endpoint verify, dedupe, and suppress — so the first complaint your pipeline ever handles isn't a real one at 2 a.m.

Frequently asked questions

Last updated August 2, 2026.

Sources reviewed

Factual review: June 13, 2026 by WillItInbox Editorial.

Keep reading