D-04 · BOUNCES · WEBHOOKS
Bounce classification via webhooks: hard, soft, block
Every vendor names bounce events differently. This scenario maps hard, soft, and block outcomes to suppression and retry policy that survives migration.
Symptom
After switching API vendors, hard bounces re-enter the mailing list. The new webhook emits “dropped,” “deferred,” and “bounced” where the old one emitted “hard” and “soft,” and the mapping layer treats drops as temporary. Invalid addresses get retried for weeks, bounce rates climb, and the new IP warmup stalls. Meanwhile genuine soft bounces — full mailboxes, greylisted retries — get permanently suppressed by an overzealous rule, deleting reachable users.
The operational symptom is dashboard disagreement: the vendor shows 4% bounces while the app shows 1%, because each side classifies “blocked for policy” and “previously unsubscribed” differently. Nobody trusts either number.
Cause
Bounce reality has three buckets, but vendors expose five or more event names. Hard bounces (unknown user, invalid domain) must suppress immediately and permanently. Soft bounces (mailbox full, temporary DNS failure, message too large) deserve bounded retries with backoff. Policy blocks (spam content flags, IP throttling, recipient-server rejections) need content or reputation fixes, not blind retries. Mapping vendor-specific codes — SendGrid’s bounce/dropped/deferred, Mailgun’s failed sub-reasons, Postmark’s bounce types — onto these three buckets is where migrations break.
Enhanced status codes (5.x.x permanent vs 4.x.x transient) are the ground truth, but many integrations parse only the top-level event name. Diagnostic strings like “user unknown” vs “mailbox full” carry the real signal and get discarded.
Fix
Build a classification table keyed on status code class plus diagnostic patterns, not vendor event names. Hard (5.1.x unknown user, 5.1.2 bad domain): suppress on first occurrence, sync to every vendor’s suppression list. Soft (4.2.2 full, 4.4.x transient): retry with exponential backoff over 24–72 hours, then suppress after N consecutive failures (three is a sane default). Block/policy: alert a human, pause the stream, fix content or reputation — never auto-retry bulk into a block.
Dual-run webhooks during migration: ingest old and new event streams into one normalized table for two weeks, reconcile counts daily, and only cut over when classifications agree within tolerance. Store raw payloads for 45 days for forensic replay.
Prevention
Validate addresses before first send (see D-18), enforce list sunset rules so soft-bounce accumulation cannot grow unbounded, and version the classification table in source control with tests per vendor fixture. Monitor bounce-rate SLAs per stream — transactional above 2% or bulk above 5% pages someone — and review “block” events in weekly deliverability standups as product signals, not noise.