Skip to content

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.

Koltrix Team5 min read
A pile of opened paper envelopes
Photo by sue hughes on Unsplash
On this page(9 sections)
  1. What you are trying to achieve
  2. Stage 1: receive and verify
  3. Stage 2: normalize and deduplicate
  4. Stage 3: route by event type
  5. Hard bounces
  6. Soft bounces
  7. Complaints
  8. Unsubscribes
  9. Stage 4: update the product
  10. Stage 5: alert on anomalies
  11. The whole pipeline at a glance
  12. Checklist
  13. Key takeaways

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:

  1. Stop sending to that address, quickly and reliably, so you do not keep damaging your sender reputation.
  2. Tell the product, so the user sees "we couldn't reach your email address" instead of silently missing invoices.
  3. Keep a record for support and audits.
  4. 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_bounce and 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.

SharePost on XLinkedIn