Processing bounce and complaint webhooks end to end
Turn bounce and complaint events into suppressions, user-facing flags and alerts. A worked pipeline from webhook receipt to updated account state.

On this page(9 sections)
A bounce or complaint webhook is only useful if something happens because of it. Too many integrations log the event, maybe increment a counter, and keep emailing the same dead address or the same annoyed recipient.
Here is a complete pipeline, from the HTTP request hitting your endpoint to the account state your product shows.
What you are trying to achieve
When a provider reports a hard bounce or a spam complaint, four things should follow:
- Stop sending to that address, quickly and reliably, so you do not keep damaging your sender reputation.
- Tell the product, so the user sees "we couldn't reach your email address" instead of silently missing invoices.
- Keep a record for support and audits.
- Alert humans when rates spike, because a sudden wave of bounces or complaints usually means something broke.
Stage 1: receive and verify
Your webhook endpoint should do as little as possible synchronously:
@app.post("/webhooks/email")
def email_webhook(request):
raw = request.body # raw bytes, before JSON parsing
sig = request.headers.get("X-Signature-Header")
if not verify_signature(raw, sig, WEBHOOK_SECRET):
return Response(status=401)
event = json.loads(raw)
enqueue("process_email_event", event) # durable queue, not in-memory
return Response(status=200)
The header name and signing scheme depend on your provider. Most use an HMAC over the raw body with a shared secret. Verify against the raw bytes; parsing and re-serializing JSON changes the bytes and breaks the comparison. Use a constant-time comparison function.
Return a 2xx quickly. Providers retry when you are slow or return errors, and processing inline invites timeouts, which invite retries, which invite duplicate processing.
Stage 2: normalize and deduplicate
In the background worker, convert the provider's payload into your own event shape and drop duplicates:
def process_email_event(event):
normalized = {
"provider_event_id": event["id"],
"type": map_type(event["event"]), # bounced, complained, delivered...
"recipient": event["to"].strip().lower(),
"message_id": event.get("message_id"),
"reason": event.get("bounce_reason"),
"occurred_at": event["timestamp"],
}
if not insert_event_if_new(normalized): # unique on provider_event_id
return # duplicate delivery; already handled
route(normalized)
Webhook systems deliver at least once, so duplicates are normal. A unique constraint on the provider's event ID is the simplest deduplication. If your provider does not supply an event ID, derive one from stable fields such as message ID, event type and timestamp.
Normalize addresses consistently. Lowercasing is generally safe for the domain part and is common practice for the whole address in suppression lists, even though the local part is technically case-sensitive.
Stage 3: route by event type
Hard bounces
A hard bounce means the receiving server permanently rejected the address: the mailbox does not exist, the domain does not exist, or a similar 5xx-class failure. Action:
- Add the address to your suppression list with reason
hard_bounceand the SMTP response text. - Mark the user's email as undeliverable in your product.
- Stop all future sends to it until the user updates or re-verifies the address.
Some providers suppress hard-bounced addresses automatically on their side. Keep your own suppression list anyway, so your application knows not to try, and so behavior does not change if you switch providers.
Soft bounces
Temporary failures, such as a full mailbox or a server temporarily refusing mail, usually do not warrant suppression on the first occurrence. Track them, and suppress only after repeated soft bounces over a period of days, since a mailbox that has been "full" for two weeks is probably abandoned.
Complaints
A complaint means the recipient marked your message as spam and their mailbox provider reported it through a feedback loop. Action:
- Suppress the address for that category of mail immediately. For marketing mail, that means unsubscribing them.
- For transactional mail, think carefully. A complaint about a password reset is often a mistake, but repeatedly sending to someone who complains is a reputation risk. Many teams suppress non-essential notifications and keep only critical account and security mail.
- Record the complaint against the template that triggered it, so product owners can see which messages generate complaints.
Unsubscribes
If your provider handles one-click unsubscribe links and reports them as events, record them as opt-outs for the relevant category.
Stage 4: update the product
Suppression is invisible to users unless you surface it. When an address is suppressed for a hard bounce:
- Show a banner in the app: "Emails to [email protected] are bouncing. Update your email address."
- For team accounts, notify an admin through another channel if the billing contact's address bounces.
- Provide a way to clear the suppression after the user fixes or confirms the address, ideally by sending a verification email that must be clicked.
This closes the loop. Without it, a customer whose invoices bounce finds out when their account is suspended for non-payment.
Stage 5: alert on anomalies
Individual bounces are normal. Spikes are not. Alert when:
- The hard bounce rate for a template or a time window jumps well above its baseline, which can indicate a bug that sends to malformed addresses or a bad list import.
- Complaints cluster around a single template, often after a content or frequency change.
- Bounces concentrate on one receiving domain, which may indicate that provider is blocking you rather than that the addresses are bad. In that case, suppressing every address would be the wrong reaction; investigate the block first.
That last point deserves emphasis. A bounce caused by a policy or reputation block (often a 5xx reply mentioning policy, spam or blocking) is not evidence that the address is invalid. Classify bounce reasons, and avoid permanently suppressing addresses because of a temporary block.
The whole pipeline at a glance
| Stage | Responsibility | Failure to avoid |
|---|---|---|
| Receive | Verify signature, enqueue, return 2xx | Slow handlers causing retries |
| Normalize | Map payload, deduplicate on event ID | Double-processing duplicates |
| Route | Suppress, track soft bounces, record complaints | Suppressing on policy blocks |
| Product | Surface undeliverable addresses, allow recovery | Silent failures users never see |
| Alert | Detect spikes by template and domain | Noticing a bad deploy days later |
Checklist
- Webhook endpoint verifies signatures on raw bytes and returns quickly.
- Events go to a durable queue and are deduplicated by provider event ID.
- Hard bounces and complaints update your own suppression list.
- Soft bounces are tracked and suppressed only after repeated failures.
- Policy blocks are distinguished from invalid addresses.
- Users can see and fix undeliverable addresses.
- Alerts fire on bounce and complaint spikes by template and receiving domain.
Key takeaways
- Acknowledge webhooks fast, and do the real work in a background worker.
- Deduplicate on event IDs, because providers deliver at least once.
- Suppress hard bounces and complaints in your own system, not just at the provider.
- Show undeliverable addresses to users so they can fix them.
- Alert on spikes, and do not mistake a reputation block for a list of bad addresses.
Start with Koltrix
Your domain, one inbox, and an API that sends.
A team inbox where AI sorts and drafts (nothing is sent without your click), plus the transactional API and SMTP relay your product sends with. 7 days free, no card.

