tutorials
Process bounce and complaint webhooks into a suppression list
August 21, 2026

Your delivery provider faithfully POSTs a webhook every time a message bounces or someone reports it as spam. And then nothing happens. The events land in a log, your code 200s them, and on the next campaign you mail the same dead addresses again. A webhook you receive but never act on is worse than none, because it gives you the feeling of a working feedback loop.
Bounce and complaint webhooks are not the hard part. Providers (Resend, Postmark, SendGrid, SES) all emit them, and receiving an HTTP POST is trivial. The work is the loop after the POST: verify the payload is real, classify what kind of failure it is, decide whether the address gets one strike or several, write it to a suppression list, and (the step everyone forgets) check that list before the next send. This tutorial builds that whole loop by hand on Cloudflare Workers and D1, including the failure modes that quietly let suppressed addresses slip back into your sends. At the end, the shortcut if you would rather register the flow than maintain it.
What does it mean to process a bounce webhook into a suppression list?
Processing a bounce webhook into a suppression list means turning a one-off "this delivery failed" event into a persistent "never mail this address again" rule that your sender enforces. The webhook is the input; a checked-at-send-time suppression table is the output.
The loop has four stages, and skipping any one of them breaks it:
- Verify. Confirm the webhook actually came from your provider, not from someone who found the URL. An unauthenticated bounce endpoint is a denial-of-service hole: a stranger can suppress your real customers.
- Classify. A hard bounce (permanent), a soft bounce (temporary), and a complaint (the recipient hit "Report Spam") are three different things with three different policies. Treating them identically either loses you real subscribers or keeps mailing dead ones.
- Suppress. Write the address to a table, with enough metadata (reason, source, timestamp) to audit later and to filter future imports.
- Gate. Read that table before every send. This is the only stage that actually protects deliverability. The first three are bookkeeping; this one is the point.
People build stages one through three, watch the suppression table fill up, and feel done. Then they never wire stage four into the send path, so the table is a museum of addresses they keep mailing anyway. Keep that in mind as the reason the whole thing exists.
How do you design the suppression table?
Key it on the lowercased email address with a unique constraint, and store the reason, the soft-bounce counter, and a timestamp. That is enough to drive every policy decision and to audit why an address was blocked.
On Cloudflare you are writing to D1 (SQLite). The schema is small:
CREATE TABLE suppressions (
address TEXT PRIMARY KEY, -- always store lowercased
reason TEXT NOT NULL, -- 'hard_bounce' | 'complaint' | 'soft_bounce'
source TEXT, -- which provider/domain reported it
soft_count INTEGER NOT NULL DEFAULT 0,
permanent INTEGER NOT NULL DEFAULT 0, -- 1 = never mail again
suppressed_at INTEGER NOT NULL, -- epoch millis
updated_at INTEGER NOT NULL
);
-- The send-time gate does this lookup on every message, so it must be indexed.
-- PRIMARY KEY on address already gives you that index in SQLite.
Two design choices worth defending:
- Address is the primary key. Suppression is per-address, and you want an upsert (a second hard bounce for the same address should not create a duplicate row). The primary key gives you
INSERT ... ON CONFLICTfor free. soft_countlives on the same row. Soft bounces need a counter, and keeping it on the suppression row (rather than a separate events table) means the send-time gate is a single lookup. You can still keep a full event log elsewhere for analytics; the suppression table is the hot path and stays narrow.
Lowercasing the address everywhere (on write and on read) matters more than it looks. Jane@Example.com and jane@example.com are the same mailbox, and a provider may report the bounce in different casing than you sent to. Normalize on both sides or you will suppress one casing and happily mail the other.
How do you receive and verify the webhook?
Expose an HTTP endpoint, read the raw body, and verify the provider's HMAC signature before you parse or trust a single field. Verification comes first because a forged payload can poison your suppression list with addresses you do want to mail.
Here is the Worker entry point. The critical detail is that you verify against the raw bytes, not a re-serialized object, because JSON round-tripping changes whitespace and breaks the signature:
export default {
async fetch(req: Request, env: Env): Promise<Response> {
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
const raw = await req.text(); // raw body, used for both signature and parse
const signature = req.headers.get("x-webhook-signature") ?? "";
const valid = await verifyHmac(env.WEBHOOK_SECRET, raw, signature);
if (!valid) {
// Reject BEFORE parsing. A forged payload must never reach suppression.
return new Response("invalid signature", { status: 401 });
}
const event = JSON.parse(raw) as ProviderEvent;
await handleEvent(env, event);
return new Response("ok", { status: 200 });
},
};
async function verifyHmac(secret: string, body: string, header: string): Promise<boolean> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
const expected = [...new Uint8Array(mac)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
// Constant-time-ish compare: lengths must match and every byte must match.
if (expected.length !== header.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ header.charCodeAt(i);
return diff === 0;
}
The most common verification mistake produces this:
$ curl -X POST https://hooks.yourdomain.com/bounce -d '{"type":"bounce","email":"vip@bigcustomer.com"}'
ok
It returned ok and suppressed your biggest customer, because the endpoint never checked a signature. Anyone who guesses or logs the URL can now silently delete your list one address at a time. Verifying first turns that into a 401 and a non-event. If your provider only offers a static shared secret instead of HMAC, require it in a header and compare it constant-time; it is weaker, but it still closes the open door.
How do you classify a bounce versus a complaint?
Map the event into one of three buckets using the provider's event type and the SMTP status code: 5.x.x is a hard bounce, 4.x.x is a soft bounce, and any complaint or feedback-loop event is a complaint. The classification drives the policy, so get it before you suppress anything.
Provider payloads differ in shape, so normalize first into one internal event, then classify off the normalized form:
type Verdict = "hard_bounce" | "soft_bounce" | "complaint" | "ignore";
interface NormalizedEvent {
kind: "bounce" | "complaint" | "delivered" | "other";
recipient: string;
statusCode?: string; // RFC 3463 like "5.1.1" when present
source?: string;
}
function classify(e: NormalizedEvent): Verdict {
if (e.kind === "complaint") return "complaint"; // one strike, always
if (e.kind === "bounce") {
const code = e.statusCode ?? "";
if (code.startsWith("5")) return "hard_bounce"; // permanent: user unknown, blocked
if (code.startsWith("4")) return "soft_bounce"; // temporary: full mailbox, timeout
// No machine-readable code? Fall back to the textual hint, default to soft
// so you do not permanently suppress on an ambiguous bounce.
return /no such user|does not exist|user unknown|mailbox unavailable/i.test(
JSON.stringify(e),
)
? "hard_bounce"
: "soft_bounce";
}
return "ignore"; // delivered, opened, etc. are not suppression events
}
Two judgment calls are baked into that function and both are deliberate:
- Complaints are always one strike. There is no soft complaint. The recipient told their provider your mail is unwanted; mailing them again is the fastest route to a blocklist.
- Ambiguous bounces default to soft. When there is no parseable status code, the safe default is not permanent suppression. A wrongly-classified soft bounce costs you a few retries; a wrongly-classified hard bounce permanently deletes a real subscriber. Bias toward keeping the address and let the soft-bounce counter catch genuinely dead mailboxes over time.
This is the same hard-versus-soft logic covered operationally in handling email bounces and suppression the right way; here we are wiring it specifically to the webhook input. For the policy side rather than the code, the bounce and suppression best practices that protect your reputation cover thresholds and win-back rules in a checklist form, and the deliverability metrics worth watching tell you which numbers signal a bounce or complaint problem before it spreads.
How do you apply the suppression policy?
Suppress hard bounces and complaints permanently on the first event. For soft bounces, increment a counter and only suppress once it crosses a threshold, so a temporarily full mailbox does not cost you a subscriber forever.
const SOFT_BOUNCE_LIMIT = 4; // consecutive soft failures before giving up
async function handleEvent(env: Env, raw: ProviderEvent): Promise<void> {
const e = normalize(raw); // provider-specific -> NormalizedEvent
const verdict = classify(e);
if (verdict === "ignore") return;
const address = e.recipient.toLowerCase();
const now = Date.now();
if (verdict === "soft_bounce") {
// Upsert: bump the counter; suppress only once it crosses the limit.
const row = await env.DB.prepare(
`INSERT INTO suppressions (address, reason, source, soft_count, permanent, suppressed_at, updated_at)
VALUES (?, 'soft_bounce', ?, 1, 0, ?, ?)
ON CONFLICT(address) DO UPDATE SET
soft_count = soft_count + 1,
updated_at = excluded.updated_at
RETURNING soft_count`,
).bind(address, e.source ?? null, now, now).first<{ soft_count: number }>();
if ((row?.soft_count ?? 1) >= SOFT_BOUNCE_LIMIT) {
await env.DB.prepare(
`UPDATE suppressions SET permanent = 1, reason = 'soft_bounce', updated_at = ?
WHERE address = ?`,
).bind(now, address).run();
}
return;
}
// Hard bounce or complaint: permanent on the first occurrence.
await env.DB.prepare(
`INSERT INTO suppressions (address, reason, source, soft_count, permanent, suppressed_at, updated_at)
VALUES (?, ?, ?, 0, 1, ?, ?)
ON CONFLICT(address) DO UPDATE SET
reason = excluded.reason,
permanent = 1,
updated_at = excluded.updated_at`,
).bind(address, verdict, e.source ?? null, now, now).run();
}
One subtlety: a hard bounce or complaint upgrades an existing soft-bounce row to permanent, because the ON CONFLICT clause sets permanent = 1. That is correct. An address can soft-bounce twice and then complain; the complaint should win and lock it permanently.
A second subtlety not shown: soft-bounce counters should reset on a successful delivery. If you also process delivered events, clear the soft_count for that address so an old transient blip does not accumulate toward suppression months later. Keep that reset cheap and idempotent.
Where does the suppression check actually go?
Inside the send path, right before you call the delivery API, on every single message including transactional mail. This is the one stage that protects deliverability, and it is the one most homegrown systems leave out.
async function sendEmail(env: Env, msg: OutboundMessage): Promise<"sent" | "suppressed"> {
const address = msg.to.toLowerCase();
// The gate. One indexed lookup. Runs before every send, no exceptions.
const blocked = await env.DB.prepare(
`SELECT reason FROM suppressions WHERE address = ? AND permanent = 1`,
).bind(address).first<{ reason: string }>();
if (blocked) {
// Do not call the delivery API. Log it so you can see what you skipped.
console.log(`suppressed send to ${address} (${blocked.reason})`);
return "suppressed";
}
await deliver(env, msg); // your HTTPS sender; Workers cannot open port 25
return "sent";
}
Why "every send, no exceptions" including transactional? Because the cost of one ignored suppression is asymmetric. A password-reset email to a hard-bounced address is wasted, but a marketing email to a complainer is a reputation hit that affects every other recipient on that send. There is no category of mail where it is safe to skip the check, and a single WHERE address = ? lookup against an indexed primary key is fast enough that "skip it for transactional to save a query" is a false economy. Cloudflare Workers cannot open outbound port 25 for direct-to-MX SMTP anyway (no port 25 outbound), so every send already goes through an HTTPS call where one extra indexed read is noise. See sending email from a Cloudflare Worker for that send path in full.
Two operational rules close the loop:
- Filter imports against suppressions. When you import a list later (from Mailchimp, a CSV, anywhere), check every address against the suppression table before adding it. A complainer who lands back on a list through a careless import will complain again, and the second complaint is worse than the first. Treating suppression as one piece of ongoing list hygiene that keeps your sender reputation clean is what stops the table from being the only thing standing between you and a reputation hit.
- Pair suppression with easy unsubscribe. A clean List-Unsubscribe one-click header gives annoyed recipients an exit that is not the spam button, which keeps complaint events (and permanent suppressions) rarer in the first place.
Common failure modes
The suppression table fills up but bounce rate never drops
You built stages one through three and never wired the send-time gate. The table is write-only. Add the WHERE address = ? lookup before deliver().
A real subscriber got permanently suppressed after one full mailbox
You classified a 4.x.x soft bounce as permanent. Soft bounces must increment a counter and only suppress after a threshold; permanent is for 5.x.x and complaints.
Some bounced addresses still get mailed
Casing mismatch. The webhook reported User@host.com, you stored it as sent, and the gate compared a different case. Lowercase on write and on read.
Strangers are suppressing your customers
Your endpoint does not verify the signature. Verify the HMAC over the raw body and return 401 before parsing.
Or skip the loop: let the stack suppress automatically
Everything above is genuinely ownable, and for one provider's webhook shape it is a contained piece of code. The cost is that it is four coupled stages, and the one that breaks silently (the send-time gate) breaks in the direction of mailing dead addresses, which is exactly the failure you cannot see from the outside until your reputation is already gone.
Tinbox runs this loop on your own Cloudflare account. Inbound bounce DSNs and ARF complaint reports are parsed by email interpreters with their own eval suites, classified into hard bounce, soft bounce, or complaint, and written to a suppression table in your D1 database. The sender reads that same table before every send, transactional or marketing, so a suppressed address is skipped without you wiring the gate yourself. Soft-bounce counting, permanent escalation on complaint, and import-time filtering are all part of the flow. Because it is your Cloudflare account and your D1, the bounce and complaint data never leaves your infrastructure, and there is no per-event or per-suppression fee for processing any of it.
You still own the decisions only you can make: your soft-bounce threshold, what counts as a complaint source you trust, and your complaint-rate thresholds. Tinbox does the part that is identical for everyone: turn a bounce or complaint event into a suppressed address and enforce it at send time. The same lifecycle events also flow to your own delivered, bounced, and complained webhook stream if a downstream app needs to react, and the whole sending-and-reacting picture lives in the Tinbox overview.
Deploy this yourself: npx tinbox init yourdomain.com. The suppression list, bounce and complaint interpreters, and the send-time gate ship in the box; the docs cover wiring your provider's webhooks and tuning the soft-bounce policy.
Frequently asked questions
How do I process a bounce webhook into a suppression list?
Receive the webhook in an HTTP endpoint, verify it is authentic (HMAC signature or a shared secret), read the event type and recipient, and classify it. A hard bounce or a spam complaint is written to a suppression table immediately and permanently. A soft bounce increments a per-address failure counter and only suppresses after a threshold (typically three to five repeated soft failures). Finally, and this is the step people skip, check that suppression table before every outbound send so a suppressed address is never mailed again.
What is the difference between a hard bounce and a soft bounce?
A hard bounce is a permanent failure: the address does not exist, the domain has no mail server, or the recipient blocked you. RFC 3463 status codes starting with 5 (for example 5.1.1 'user unknown') are hard. A soft bounce is a temporary failure: a full mailbox, a server timeout, or greylisting. Status codes starting with 4 are soft. Suppress hard bounces on the first occurrence; give soft bounces a few retries before suppressing, because the address may become reachable again.
Should a spam complaint suppress an address?
Yes, immediately and permanently, on the first complaint. A complaint means the recipient clicked 'Report Spam', which is a stronger negative signal than a bounce. There is no retry and no win-back: mailing a complainer again produces another complaint and accelerates damage to your sending reputation. Record the complaint with a timestamp and source, remove the address from every active list, and never re-add it through a future import.
How do I verify a bounce webhook is authentic?
Never trust an unauthenticated POST to your bounce endpoint, because anyone who finds the URL could suppress your real subscribers. Most providers sign the webhook body with an HMAC and send the signature in a header; recompute the HMAC over the raw body with your shared secret and compare in constant time. If your provider only offers a static secret, require it in a header or path segment. Reject anything that fails verification before you touch the suppression table.
Where does the suppression check actually go?
Inside the send path, immediately before you hand the message to the delivery API, for every single send including transactional mail. A suppression list that is written but never read does nothing. The cheapest correct version is a single indexed lookup keyed on the lowercased recipient address; if a row exists, you skip the send and log it as suppressed rather than calling the delivery API.


