Handle Bounce and Complaint Webhooks: Protect Sender Reputation
Process bounces before providers throttle you.
By JustEmails Platform Team
Three weeks ago I watched a SaaS app get its sending suspended. They'd been emailing the same dead addresses for months — 2,400 hard bounces re-mailed weekly because nobody built the webhook consumer. SendGrid gave them warnings. They ignored them. Now they're on a 30-day cooldown trying to rebuild reputation from scratch. Brutal.
Here's the thing: every email tutorial shows you how to send mail. Configure SMTP, call the API, done. But sending is half the job. The other half — the part that keeps your domain off blacklists — is processing the events that come back. Bounces. Complaints. Unsubscribes. Your provider fires webhooks for all of these. Ignore them and you look like a spammer. Because, well, that's what spammers do.
This is the event-handling half. We'll build a webhook consumer that verifies signatures, classifies bounce types, writes to a suppression table, and implements retry policies that don't destroy your reputation. Code in Node and Python.
What We're Building
By the end of this, you'll have a working webhook endpoint that:
- Verifies HMAC signatures (so attackers can't inject fake bounces)
- Classifies events into hard bounce, soft bounce, and complaint
- Writes suppressions to a database table
- Checks the suppression list before every send
- Handles soft-bounce retries with backoff
This isn't email-provider specific — the patterns work with JustEmails, SendGrid, Postmark, Mailgun, Amazon SES, whatever. The payload shapes differ, but the logic is the same.
Prerequisites
- Node.js 18+ or Python 3.9+
- A transactional email provider with webhooks enabled
- A database (PostgreSQL shown, but any SQL store works)
- Basic understanding of SMTP vs API sending
- Your provider's webhook signing secret
Step 1: Set Up the Suppression Table
Before processing events, you need somewhere to store them. Here's a minimal schema:
CREATE TABLE email_suppressions (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
reason VARCHAR(50) NOT NULL, -- 'hard_bounce', 'soft_bounce', 'complaint'
bounce_type VARCHAR(100), -- provider-specific code
provider_event_id VARCHAR(255),
soft_bounce_count INT DEFAULT 0,
first_seen_at TIMESTAMP DEFAULT NOW(),
last_seen_at TIMESTAMP DEFAULT NOW(),
suppressed BOOLEAN DEFAULT TRUE,
UNIQUE(email)
);
CREATE INDEX idx_suppressions_email ON email_suppressions(email);
CREATE INDEX idx_suppressions_suppressed ON email_suppressions(suppressed) WHERE suppressed = TRUE;
The suppressed boolean is your source of truth. Before every email send, you'll query this table. If suppressed = TRUE, don't send. Simple as that.
Why track soft_bounce_count? Soft bounces aren't permanent — mailbox full, server temporarily down. But if someone soft-bounces five times over two weeks, treat it as permanent. The count lets you implement that policy.
Step 2: Build the Webhook Endpoint (Node.js)
Here's an Express endpoint that handles the full flow:
// webhook-handler.js
const express = require('express');
const crypto = require('crypto');
const { Pool } = require('pg');
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// IMPORTANT: raw body for signature verification
app.use('/webhooks/email', express.raw({ type: 'application/json' }));
const WEBHOOK_SECRET = process.env.EMAIL_WEBHOOK_SECRET;
function verifySignature(payload, signature) {
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
function classifyEvent(event) {
// Normalize across providers - adjust field names for yours
const eventType = event.event || event.type || event.RecordType;
if (['bounce', 'hard_bounce', 'HardBounce'].includes(eventType)) {
return 'hard_bounce';
}
if (['soft_bounce', 'SoftBounce', 'deferred'].includes(eventType)) {
return 'soft_bounce';
}
if (['complaint', 'spam_complaint', 'SpamComplaint'].includes(eventType)) {
return 'complaint';
}
return null;
}
app.post('/webhooks/email', async (req, res) => {
const signature = req.headers['x-webhook-signature'];
if (!signature || !verifySignature(req.body, signature)) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
const events = JSON.parse(req.body);
const eventList = Array.isArray(events) ? events : [events];
for (const event of eventList) {
const email = event.email || event.recipient || event.Email;
const classification = classifyEvent(event);
if (!email || !classification) continue;
try {
if (classification === 'hard_bounce' || classification === 'complaint') {
// Permanent suppression
await pool.query(`
INSERT INTO email_suppressions (email, reason, bounce_type, provider_event_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (email) DO UPDATE SET
reason = $2,
last_seen_at = NOW(),
suppressed = TRUE
`, [email, classification, event.bounce_type || null, event.id || null]);
} else if (classification === 'soft_bounce') {
// Increment counter, suppress after threshold
const result = await pool.query(`
INSERT INTO email_suppressions (email, reason, soft_bounce_count, bounce_type)
VALUES ($1, 'soft_bounce', 1, $2)
ON CONFLICT (email) DO UPDATE SET
soft_bounce_count = email_suppressions.soft_bounce_count + 1,
last_seen_at = NOW(),
suppressed = CASE
WHEN email_suppressions.soft_bounce_count >= 4 THEN TRUE
ELSE email_suppressions.suppressed
END
RETURNING soft_bounce_count
`, [email, event.bounce_type || null]);
if (result.rows[0]?.soft_bounce_count >= 5) {
console.log(`Soft bounce threshold reached for ${email}`);
}
}
} catch (err) {
console.error(`Failed to process event for ${email}:`, err.message);
// Don't fail the whole webhook - log and continue
}
}
res.status(200).json({ processed: eventList.length });
});
app.listen(3000, () => console.log('Webhook server running on :3000'));
A few things to note.
Raw body parsing is critical. Parse JSON before signature verification and you'll get mismatches from whitespace and key ordering differences. I wasted an entire afternoon on this once. Felt like an idiot when I figured it out. Express's express.json() middleware parses automatically — that's why we use express.raw() instead.
Use timingSafeEqual. Regular string comparison is vulnerable to timing attacks. Someone could probe your endpoint to reverse-engineer the secret. The crypto version compares in constant time. If you're building developer tools that need secure defaults, DevOS has good patterns for this.
Don't fail the whole request on one bad event. Providers batch multiple events into single webhook calls. If event #3 of 10 has a malformed email address, process the other 9 and log the error. Return 200 so the provider doesn't retry the whole batch.
Step 3: Build the Webhook Endpoint (Python)
Same logic, Flask flavor:
# webhook_handler.py
import hmac
import hashlib
import json
import os
from flask import Flask, request, jsonify
import psycopg2
from psycopg2.extras import execute_values
app = Flask(__name__)
pool = psycopg2.pool.SimpleConnectionPool(1, 10, os.environ['DATABASE_URL'])
WEBHOOK_SECRET = os.environ['EMAIL_WEBHOOK_SECRET'].encode()
def verify_signature(payload: bytes, signature: str) -> bool:
expected = hmac.new(WEBHOOK_SECRET, payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
def classify_event(event: dict) -> str | None:
event_type = event.get('event') or event.get('type') or event.get('RecordType')
if event_type in ('bounce', 'hard_bounce', 'HardBounce'):
return 'hard_bounce'
if event_type in ('soft_bounce', 'SoftBounce', 'deferred'):
return 'soft_bounce'
if event_type in ('complaint', 'spam_complaint', 'SpamComplaint'):
return 'complaint'
return None
@app.route('/webhooks/email', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Webhook-Signature', '')
raw_body = request.get_data()
if not verify_signature(raw_body, signature):
return jsonify({'error': 'Invalid signature'}), 401
events = json.loads(raw_body)
if not isinstance(events, list):
events = [events]
conn = pool.getconn()
try:
cur = conn.cursor()
for event in events:
email = event.get('email') or event.get('recipient') or event.get('Email')
classification = classify_event(event)
if not email or not classification:
continue
if classification in ('hard_bounce', 'complaint'):
cur.execute("""
INSERT INTO email_suppressions (email, reason, bounce_type, provider_event_id)
VALUES (%s, %s, %s, %s)
ON CONFLICT (email) DO UPDATE SET
reason = %s,
last_seen_at = NOW(),
suppressed = TRUE
""", (email, classification, event.get('bounce_type'),
event.get('id'), classification))
elif classification == 'soft_bounce':
cur.execute("""
INSERT INTO email_suppressions (email, reason, soft_bounce_count, bounce_type)
VALUES (%s, 'soft_bounce', 1, %s)
ON CONFLICT (email) DO UPDATE SET
soft_bounce_count = email_suppressions.soft_bounce_count + 1,
last_seen_at = NOW(),
suppressed = CASE
WHEN email_suppressions.soft_bounce_count >= 4 THEN TRUE
ELSE email_suppressions.suppressed
END
""", (email, event.get('bounce_type')))
conn.commit()
finally:
pool.putconn(conn)
return jsonify({'processed': len(events)}), 200
if __name__ == '__main__':
app.run(port=3000)
The Python version uses hmac.compare_digest for constant-time comparison — same security consideration as Node's timingSafeEqual.
Step 4: Check Suppressions Before Sending
This is where it all pays off. Before every email send, query the suppression table:
// check-suppression.js
async function canSendTo(email) {
const result = await pool.query(
'SELECT suppressed FROM email_suppressions WHERE email = $1',
[email.toLowerCase()]
);
// No record = never bounced = safe to send
if (result.rows.length === 0) return true;
return !result.rows[0].suppressed;
}
async function sendEmail(to, subject, body) {
if (!await canSendTo(to)) {
console.log(`Skipping suppressed address: ${to}`);
return { skipped: true, reason: 'suppressed' };
}
// Your actual send logic here
return await emailProvider.send({ to, subject, body });
}
Do this check synchronously in the send path. Don't batch it. Don't cache it for too long. The suppression table is your source of truth, and it changes with every webhook. I've seen teams cache suppression lists for an hour and rack up thousands of bounces in that window. Don't be that team.
Some people add a Redis layer for high-volume sending — a SET of suppressed addresses that gets rebuilt every few minutes. Honestly? That's overkill for most apps. If you're sending under 50K emails/day, a direct PostgreSQL query with a proper index handles it fine. The index lookup is sub-millisecond. Track these query performance metrics with JustAnalytics to catch degradation early.
Step 5: Handle Soft Bounce Retries
Soft bounces deserve special treatment. A "mailbox full" today might accept mail tomorrow. Here's a retry policy that doesn't annoy recipients or providers:
// retry-policy.js
const RETRY_DELAYS = [
4 * 60 * 60 * 1000, // 4 hours
24 * 60 * 60 * 1000, // 1 day
72 * 60 * 60 * 1000, // 3 days
];
async function scheduleRetry(email, attemptNumber, originalPayload) {
if (attemptNumber >= RETRY_DELAYS.length) {
// Convert to hard suppression
await pool.query(`
UPDATE email_suppressions
SET suppressed = TRUE, reason = 'soft_bounce_exhausted'
WHERE email = $1
`, [email]);
return;
}
const delay = RETRY_DELAYS[attemptNumber];
// Queue for retry - use your job system (Bull, Celery, SQS, etc.)
await jobQueue.add('retry-email', {
email,
attemptNumber: attemptNumber + 1,
payload: originalPayload
}, { delay });
}
The spacing matters. Retry too fast and you look desperate (or broken). Retry too slow and the user forgets they signed up. Four hours, one day, three days. That's what I use. After three failures over about four days, give up. The address probably isn't coming back.
Common Errors and Fixes
Signature verification always fails
Error: Invalid webhook signature
Three usual suspects: (1) you're parsing JSON before verification, (2) trailing newlines in the secret, (3) the header name is wrong. Check your provider's docs — some use X-Signature, others use X-Webhook-Signature, others use X-Provider-Hmac-Sha256. Case sensitivity varies too.
Duplicate suppression entries
Error: duplicate key value violates unique constraint "email_suppressions_email_key"
Your ON CONFLICT clause isn't matching. Make sure the UNIQUE(email) constraint exists and you're using ON CONFLICT (email). Also normalize case — User@Example.com and user@example.com should be the same row. Use LOWER(email) in your insert and a functional index.
Webhooks timing out
Your database query is too slow, or you're doing too much work synchronously. Process the webhook payload fast (under 5 seconds), return 200, and do heavy lifting async. Most providers will retry timed-out webhooks, causing duplicate processing.
Missing bounce events
Your webhook URL isn't registered, or it's registered for the wrong event types. Check your provider's dashboard. JustEmails, SendGrid, and Postmark all have separate toggles for bounces vs. complaints vs. opens. You want bounces and complaints enabled at minimum.
What About Complaint Feedback Loops?
Complaints are worse than bounces. A bounce means the address is dead. A complaint means the address is alive — and the person actively reported you as spam.
ISPs track complaint rates. Gmail says anything above 0.1% is problematic. Above 0.3% and you're in trouble. That's 3 complaints per 1,000 emails. Not many.
When you get a complaint webhook:
- Suppress immediately. Don't wait. Don't debate.
- Never re-add them, even if they later click a "resubscribe" link in another email. Be skeptical.
- Audit the source. How did they get on your list? Was it a purchased list? A signup form without confirmation?
I've seen teams argue about "accidental" complaints for hours. "But they clicked the wrong button!" Sure, some people click Report Spam instead of Unsubscribe. Doesn't matter. ISPs don't care about your intent. They care about the complaint. Suppress and move on. This is one of those arguments you'll never win, so stop having it.
For more on protecting your sender reputation during the authentication setup phase, see our DMARC enforcement guide.
Next Steps
Now that you're handling events:
-
Monitor your bounce rate. Most providers show this in their dashboard. Industry benchmark is under 2% for bounces, under 0.1% for complaints. Higher than that? Your list hygiene is bad.
-
Add webhook logging. Store raw payloads for 30 days. When something goes wrong — and it will — you'll want the receipts.
-
Set up alerts. Bounce rate spikes above 5% in an hour? Something's broken. Bad import, code bug adding garbage addresses, whatever. Alert on it before your provider does. You can configure VeloCards to send Slack alerts when bounce thresholds are crossed.
-
Consider double opt-in. Yeah, it adds friction. But confirmation emails on signup eliminate most bad addresses before they ever bounce. Less cleanup later.
If you're also managing mailbox hosting alongside transactional email, check our Google Workspace alternatives guide for cost comparisons. And if you're debugging deliverability issues at the DNS level (SPF/DKIM failures causing bounces), our custom domain email setup guide covers the authentication side.
For click fraud issues on your marketing campaigns (different problem, but related pain), ClickzProtect has a breakdown of detection patterns.
Frequently Asked Questions
What happens if I ignore bounce webhooks?
Your email provider will eventually throttle or suspend your sending. Hard bounces that keep getting re-mailed signal to providers that you're not maintaining your list. ISPs will start rejecting your mail, and your domain reputation tanks. Most providers give you a few weeks of warnings before cutting you off — but by then the reputation damage is done.
What's the difference between a hard bounce and a soft bounce?
Hard bounces are permanent failures — the address doesn't exist, the domain is dead, or the mailbox has been deleted. Never retry these. Soft bounces are temporary — mailbox full, server temporarily unavailable, message too large. You can retry soft bounces with exponential backoff, but after 3-5 failures over several days, treat them as hard bounces.
How do I verify webhook signatures?
Most providers sign webhook payloads with HMAC-SHA256 using a secret you configure. Compare the signature header against a hash of the raw request body. Never parse the JSON before verifying — parse first, then hash, and you'll get mismatches from JSON serialization differences. Store the raw body bytes, verify, then parse.
Should I unsubscribe users who file complaints?
Yes, immediately and permanently. A complaint means they clicked 'Report Spam' in their email client. Even if they later ask to resubscribe, be cautious — some were accidental, but ISPs track complaint rates. High complaint rates will destroy your deliverability faster than bounces will.
Try JustEmails
Unlimited custom domain email hosting for $49/year flat — unlimited domains, unlimited mailboxes, 10 GB storage, full IMAP/SMTP. Built for agencies, freelancers, anyone juggling multiple domains.