Sending to hard-bounced addresses — permanent delivery failures where the domain does not exist or the mailbox has been closed — is one of the fastest ways to destroy sender reputation. ISPs track hard-bounce rates and use them as a primary signal for spam classification. Continued sends to bounced addresses also place you in violation of CAN-SPAM § 5, which requires honoring suppression, and trigger GDPR Art. 21 concerns when the data subject's address is clearly no longer valid. Deliverability services (Postmark, SendGrid, Mailgun) will automatically suspend accounts that exceed a ~2% hard-bounce threshold — recovery typically requires list remediation and a manual warm-up period.
Critical because sending to hard-bounced addresses directly raises bounce rates above ESP suppression thresholds, triggering account suspension and CAN-SPAM compliance exposure with every campaign.
Handle hard bounces with immediate, atomic suppression in the webhook handler:
// POST /api/webhooks/esp — inside bounce handler:
if (event.event === 'bounce' && event.bounce_type === 'hard') {
const email = event.email.toLowerCase().trim()
await db.$transaction(async (tx) => {
await tx.suppression.upsert({
where: { email },
create: { email, reason: 'hard_bounce', suppressed_at: new Date(), source_event_id: event.id },
update: { reason: 'hard_bounce', suppressed_at: new Date() }
})
await tx.contact.updateMany({
where: { email },
data: { status: 'suppressed', suppressed_at: new Date() }
})
})
}
Under CAN-SPAM § 5, suppression must be permanent by default. Any admin override of a hard-bounce suppression must be logged with admin ID, timestamp, and a stated reason — unsupervised re-activation is not compliant.
ID: data-quality-list-hygiene.suppression-bounce.hard-bounce-suppression
Severity: critical
What to look for: Enumerate all webhook handlers that process bounce events. When your ESP reports a hard bounce (permanent delivery failure — unknown user, domain not found, mailbox full permanently), check whether the affected email address is immediately added to a suppression list and never sent to again. Look for a bounce webhook handler that writes to a suppression table — quote the webhook route and the database write call. Check whether the contact's status is set to a terminal suppressed state. Verify that no code path allows a suppressed-due-to-hard-bounce address to be re-activated without an audit log entry. Allowing unsupervised re-activation of hard-bounced addresses does not count as pass.
Pass criteria: Hard bounce events immediately suppress the email address with at least 1 write to the suppression table per bounce. The suppression is permanent by default. Any manual override requires an explicit admin action that is logged with a reason and admin ID.
Fail criteria: Hard bounces are not automatically suppressed, or suppression can be reversed without an audit log entry (allowing re-sends to persistently bouncing addresses).
Skip (N/A) when: Never — any system that sends email must handle hard bounces.
Cross-reference: Check data-quality-list-hygiene.suppression-bounce.suppression-sources — hard bounces must be one of the 4 required sources populating the global suppression list.
Detail on fail: Describe what actually happens. Example: "Hard bounce webhook received but contact status not updated — address remains on active list" or "Hard bounces are logged in events table but no corresponding suppression record created — suppressions table not populated from bounce events" or "Suppression can be reversed via admin UI with no audit trail"
Remediation: Handle hard bounce events with immediate, logged suppression:
// POST /api/webhooks/esp — bounce handler section
if (event.event === 'bounce' && event.bounce_type === 'hard') {
const email = event.email.toLowerCase().trim()
await db.$transaction(async (tx) => {
// 1. Add to suppression list
await tx.suppression.upsert({
where: { email },
create: {
email,
reason: 'hard_bounce',
suppressed_at: new Date(),
source_event_id: event.id
},
update: {
reason: 'hard_bounce',
suppressed_at: new Date()
}
})
// 2. Update contact status
await tx.contact.updateMany({
where: { email },
data: { status: 'suppressed', suppressed_at: new Date() }
})
})
}
For the override audit log, see the Deliverability Engineering Audit which covers suppression override workflows in detail.