uCheckeruChecker
14 min read

Automated Bounce Handling: Webhooks, Logs, and Scripts

Every unhandled bounce counts against you. Mail providers track rejection rates, and once yours climbs past 2-3%, your campaigns start landing in spam. Manual processing stops being practical somewhere around a few thousand addresses. You need automation: webhook events from your ESP, SMTP log parsing, and scripts that scrub dead addresses from the database on their own.


What actually happens when a message bounces

The recipient's SMTP server returns an error code. 550 5.1.1 means the mailbox does not exist. 552 5.2.2 means it is full. 421 means the server is temporarily unavailable. Your MTA (Postfix, Exim, whatever you run) receives that response and generates a bounce notification. If you send through an ESP like Mailgun, SendGrid, or Amazon SES, they intercept that moment and can push the data to you via webhook.

A hard bounce (5xx) means the address is permanently dead. A soft bounce (4xx) is a temporary condition: full mailbox, unreachable server, graylisting. A soft bounce can turn into a hard bounce if it repeats 3-5 times in a row.

The real problem is not the bounces themselves. Most teams find out about them from an ESP report a day after the send. By then the reputation damage is done. Automation cuts that delay to seconds.

Three ways to catch bounces

1. Webhook from your ESP. The easiest path. SendGrid, Mailgun, Postmark, and Amazon SES all support HTTP POST to your endpoint on every bounce. You get JSON containing the address, error type, SMTP code, and timestamp. All you need is a handler.

2. SMTP log parsing. If you send through your own MTA (Postfix, Exim), bounce data lives in the logs. Postfix writes to /var/log/mail.log, Exim to /var/log/exim/mainlog. A script pulls out addresses with 5xx and 4xx codes.

3. Return-Path mailbox parsing. When the recipient's server cannot deliver a message, it sends an NDR (Non-Delivery Report) to the Return-Path address. You can configure a dedicated mailbox like bounces@yourdomain.com, connect via IMAP, and parse incoming messages. It is an older approach, but it works well when your ESP does not support webhooks.

In practice most projects combine approaches one and two. The webhook handles the main stream; logs serve as a backup and a source of details the ESP sometimes does not pass along.

Setting up a webhook endpoint

A webhook endpoint is a plain HTTP server that accepts POST requests from your ESP. Payload format differs by provider, but the structure is the same: JSON with bounce details. Here are minimal handlers in Python (Flask) and Node.js (Express).

Python (Flask)

import hmac, hashlib, json, os
from flask import Flask, request, jsonify
from datetime import datetime

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["BOUNCE_WEBHOOK_SECRET"]

def verify_signature(payload: bytes, signature: str) -> bool:
    expected = hmac.new(
        WEBHOOK_SECRET.encode(), payload, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

@app.route("/webhooks/bounce", methods=["POST"])
def handle_bounce():
    sig = request.headers.get("X-Signature", "")
    if not verify_signature(request.data, sig):
        return jsonify({"error": "bad signature"}), 403

    events = request.json if isinstance(request.json, list) else [request.json]

    for event in events:
        email = event.get("email") or event.get("recipient", "")
        bounce_type = event.get("type", "hard")  # hard | soft
        code = event.get("code", "550")
        reason = event.get("reason", "")
        ts = event.get("timestamp", datetime.utcnow().isoformat())

        if bounce_type == "hard":
            suppress_email(email, code, reason, ts)
        else:
            record_soft_bounce(email, code, reason, ts)

    return jsonify({"status": "ok"}), 200

def suppress_email(email, code, reason, ts):
    """Add address to suppression list and remove from active list."""
    # INSERT INTO suppression_list (email, code, reason, bounced_at) ...
    # DELETE FROM subscribers WHERE email = ...
    print(f"[HARD] {email} | {code} | {reason}")

def record_soft_bounce(email, code, reason, ts):
    """Track soft bounce count. After 3 consecutive, move to suppression."""
    # UPDATE subscribers SET soft_bounce_count = soft_bounce_count + 1 ...
    # IF soft_bounce_count >= 3: suppress_email(...)
    print(f"[SOFT] {email} | {code} | {reason}")

Node.js (Express)

const express = require("express");
const crypto = require("crypto");

const app = express();
app.use(express.json());

const WEBHOOK_SECRET = process.env.BOUNCE_WEBHOOK_SECRET;

function verifySignature(payload, signature) {
  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(JSON.stringify(payload))
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

app.post("/webhooks/bounce", (req, res) => {
  const sig = req.headers["x-signature"] || "";
  if (!verifySignature(req.body, sig)) {
    return res.status(403).json({ error: "bad signature" });
  }

  const events = Array.isArray(req.body) ? req.body : [req.body];

  for (const event of events) {
    const email = event.email || event.recipient || "";
    const bounceType = event.type || "hard";
    const code = event.code || "550";
    const reason = event.reason || "";

    if (bounceType === "hard") {
      suppressEmail(email, code, reason);
    } else {
      recordSoftBounce(email, code, reason);
    }
  }

  res.json({ status: "ok" });
});

function suppressEmail(email, code, reason) {
  // INSERT INTO suppression_list ...
  // DELETE FROM subscribers WHERE email = ...
  console.log(`[HARD] ${email} | ${code} | ${reason}`);
}

function recordSoftBounce(email, code, reason) {
  // UPDATE subscribers SET soft_bounce_count = soft_bounce_count + 1 ...
  // IF soft_bounce_count >= 3 -> suppressEmail(...)
  console.log(`[SOFT] ${email} | ${code} | ${reason}`);
}

app.listen(3000, () => console.log("Bounce webhook on :3000"));

A few things people commonly miss. First, always verify the request signature. Without it, anyone can POST to your endpoint and wipe live addresses from the database. Second, handle arrays of events. SendGrid, for example, batches 10-100 events per request. Third, return 200 fast and push heavy work (DB queries, notifications) to a queue asynchronously. If your endpoint is slow, the ESP retries, and you end up processing duplicates.

Payload formats across ESPs

Every provider formats its webhook differently. Here are the key fields to target.

SendGrid sends an array of objects. Event type is the event field (value bounce), address is email, reason is reason, SMTP code is status (a string like "550 5.1.1").

Mailgun sends form-urlencoded by default, not JSON. The address is in recipient, the code in code, the error description in error. Signature verification uses HMAC SHA256 with your API key.

Amazon SES routes notifications through SNS. The bounce notification arrives as an SNS Message with JSON inside. Bounce type is in bounceType (Permanent / Transient), addresses are in the bouncedRecipients array. You need to confirm the SNS subscription first or notifications will not arrive.

# Example payload from Amazon SES (via SNS)
{
  "notificationType": "Bounce",
  "bounce": {
    "bounceType": "Permanent",
    "bounceSubType": "General",
    "bouncedRecipients": [
      {
        "emailAddress": "dead@example.com",
        "action": "failed",
        "status": "5.1.1",
        "diagnosticCode": "smtp; 550 5.1.1 user unknown"
      }
    ],
    "timestamp": "2026-03-20T14:22:01.000Z"
  }
}

Parsing SMTP logs on your own server

If you send through Postfix or Exim, bounce data lives in the logs. Postfix writes a line containing status=bounced plus the original SMTP response. The script below extracts those records and produces a CSV for suppression.

#!/usr/bin/env python3
"""
parse_postfix_bounces.py
Parses /var/log/mail.log and exports bounced addresses to CSV.
"""
import re, csv, sys
from collections import defaultdict
from datetime import datetime

LOG_PATH = "/var/log/mail.log"
OUTPUT = "bounced_emails.csv"

# Postfix bounce line:
# Mar 20 14:22:01 mail postfix/bounce[12345]: ABC123: sender non-delivery notification: DEF456
# or
# Mar 20 14:22:01 mail postfix/smtp[12345]: ABC123: to=<user@example.com>,
#   relay=mx.example.com[1.2.3.4]:25, delay=1.2, status=bounced
#   (host mx.example.com said: 550 5.1.1 user unknown)

BOUNCE_RE = re.compile(
    r"to=<(?P<email>[^>]+)>.*status=bounced.*said:s*(?P<code>d{3})s+(?P<detail>[^)]+)",
    re.IGNORECASE,
)

def parse_log(path: str) -> list[dict]:
    results = []
    with open(path) as f:
        for line in f:
            m = BOUNCE_RE.search(line)
            if m:
                results.append({
                    "email": m.group("email"),
                    "code": m.group("code"),
                    "detail": m.group("detail").strip(),
                    "line": line.strip()[:200],
                })
    return results

def main():
    bounces = parse_log(LOG_PATH)
    if not bounces:
        print("No bounce records found.")
        sys.exit(0)

    # Deduplicate: one address may bounce multiple times
    seen = {}
    for b in bounces:
        key = b["email"].lower()
        if key not in seen:
            seen[key] = b

    with open(OUTPUT, "w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=["email", "code", "detail"])
        w.writeheader()
        for b in seen.values():
            w.writerow({k: b[k] for k in ["email", "code", "detail"]})

    print(f"Found {len(seen)} unique bounce addresses -> {OUTPUT}")

if __name__ == "__main__":
    main()

Exim uses a different line format. Look for == (delivery) and ** (bounce). An Exim bounce line looks like: 2026-03-20 14:22:01 1abc23-000456-AB ** user@example.com R=remote_smtp T=remote_smtp: SMTP error from remote mail server ... 550 5.1.1 user unknown.

Run the parser via cron every hour or more frequently. The output is a CSV with addresses that a sync script loads into your suppression list.

Suppression script: removing addresses from the database

Both the webhook handler and the log parser end at the same step: the address has to come off the mailing list. Here is a script that works with PostgreSQL and maintains a suppression list.

#!/usr/bin/env python3
"""
suppress_bounced.py
Reads CSV of bounce addresses and updates the database:
1. Inserts into suppression_list
2. Marks subscribers as inactive
"""
import csv, os
import psycopg2

DB_URL = os.environ["DATABASE_URL"]
CSV_PATH = "bounced_emails.csv"

def run():
    conn = psycopg2.connect(DB_URL)
    cur = conn.cursor()

    with open(CSV_PATH) as f:
        reader = csv.DictReader(f)
        count = 0
        for row in reader:
            email = row["email"].lower().strip()
            code = row.get("code", "550")
            detail = row.get("detail", "")

            # Upsert into suppression_list
            cur.execute("""
                INSERT INTO suppression_list (email, bounce_code, reason, created_at)
                VALUES (%s, %s, %s, NOW())
                ON CONFLICT (email) DO UPDATE SET
                    bounce_code = EXCLUDED.bounce_code,
                    reason = EXCLUDED.reason,
                    updated_at = NOW()
            """, (email, code, detail))

            # Deactivate subscriber
            cur.execute("""
                UPDATE subscribers
                SET status = 'bounced', updated_at = NOW()
                WHERE LOWER(email) = %s AND status = 'active'
            """, (email,))

            count += 1

    conn.commit()
    cur.close()
    conn.close()
    print(f"Suppressed {count} addresses")

if __name__ == "__main__":
    run()

Note the ON CONFLICT. The same address can bounce across different campaigns, and without an upsert the script crashes on duplicates. Also, do not DELETE from the subscribers table. Use a status column (bounced, suppressed) so you keep the history and can analyze failure patterns later.

Same thing in Node.js

// suppress_bounced.js
const { Pool } = require("pg");
const fs = require("fs");
const { parse } = require("csv-parse/sync");

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

async function run() {
  const raw = fs.readFileSync("bounced_emails.csv", "utf-8");
  const rows = parse(raw, { columns: true, skip_empty_lines: true });

  const client = await pool.connect();
  let count = 0;

  try {
    await client.query("BEGIN");

    for (const row of rows) {
      const email = (row.email || "").toLowerCase().trim();
      const code = row.code || "550";
      const detail = row.detail || "";

      await client.query(
        `INSERT INTO suppression_list (email, bounce_code, reason, created_at)
         VALUES ($1, $2, $3, NOW())
         ON CONFLICT (email) DO UPDATE SET
           bounce_code = EXCLUDED.bounce_code,
           reason = EXCLUDED.reason,
           updated_at = NOW()`,
        [email, code, detail]
      );

      await client.query(
        `UPDATE subscribers SET status = 'bounced', updated_at = NOW()
         WHERE LOWER(email) = $1 AND status = 'active'`,
        [email]
      );

      count++;
    }

    await client.query("COMMIT");
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }

  console.log(`Suppressed ${count} emails`);
}

run().then(() => pool.end());

Soft bounces: count them, do not ignore them

Hard bounces are straightforward: suppress and move on. Soft bounces are trickier. A full mailbox today may be cleared tomorrow. A server down for an hour will come back. Suppressing after the first soft bounce means losing live subscribers.

A practical approach: keep a counter. First soft bounce, record it and do nothing. Second consecutive soft bounce, mark the address as at_risk. Third consecutive, move to suppression. If a successful delivery occurs between bounces, reset the counter.

-- Table schema for tracking soft bounces
CREATE TABLE bounce_tracker (
    email          TEXT PRIMARY KEY,
    consecutive    INT DEFAULT 0,
    last_bounce_at TIMESTAMPTZ,
    last_success_at TIMESTAMPTZ,
    status         TEXT DEFAULT 'active'
        CHECK (status IN ('active', 'at_risk', 'suppressed'))
);

-- On soft bounce:
UPDATE bounce_tracker
SET consecutive = consecutive + 1,
    last_bounce_at = NOW(),
    status = CASE
        WHEN consecutive + 1 >= 3 THEN 'suppressed'
        WHEN consecutive + 1 >= 2 THEN 'at_risk'
        ELSE status
    END
WHERE email = 'user@example.com';

-- On successful delivery:
UPDATE bounce_tracker
SET consecutive = 0,
    last_success_at = NOW(),
    status = 'active'
WHERE email = 'user@example.com';

Three is a reasonable threshold for most cases. Mailchimp uses five, but that is too lenient. While you wait for bounce number five, the address is already dragging down your sender reputation.

Cron, queues, and monitoring

Webhooks run in real time; the log parser runs on a schedule. For the parser you need cron. A minimal setup:

# crontab -e
# Parse logs every hour
0 * * * * /usr/bin/python3 /opt/scripts/parse_postfix_bounces.py >> /var/log/bounce-parser.log 2>&1

# Sync suppression list every hour (10 min after the parser)
10 * * * * /usr/bin/python3 /opt/scripts/suppress_bounced.py >> /var/log/bounce-suppress.log 2>&1

# Weekly report: how many addresses suppressed this week
0 9 * * 1 /usr/bin/python3 /opt/scripts/bounce_report.py | mail -s "Bounce report" ops@yourdomain.com

For the webhook endpoint, what matters most is uptime monitoring. If your endpoint goes down, ESPs retry for a while (SendGrid for 72 hours, Mailgun for 24), then stop. Any bounces during that window are lost. Set an uptime check on the endpoint and alert to Slack or Telegram when it does not respond.

At high volume (tens of thousands of messages per day), do not process bounces synchronously inside the webhook handler. Write the event to a queue (Redis, RabbitMQ, SQS) and handle it in a separate worker. The endpoint responds in milliseconds; the worker deals with the database at its own pace.

Five mistakes that break your automation

No deduplication. An ESP may send the same event twice after a timeout retry. Without an idempotency key or upsert, you get duplicate records in the suppression list and skewed stats.

DELETE instead of UPDATE. Physically deleting a subscriber destroys the history. A month later that address can re-enter your database through a signup form and you start mailing a dead inbox again. The suppression list needs to be permanent. Even if an address comes back to life, verify it before reactivating.

Ignoring soft bounces. "It is a temporary error, it will clear up." Not if the mailbox has been abandoned. Soft bounces without tracking become quiet reputation damage.

No webhook signature verification. An open endpoint without auth is an attack surface. Someone can POST fake bounces and remove live addresses from your list.

No alerting on script failure. The script dies with an error and nobody notices for two weeks. During that time your bounce rate creeps from 1.5% to 4%, and Gmail has already started routing your mail to Promotions. An automated alert on failure is not optional.

Preventive validation: cut bounces before sending

Bounce handling is reactive. By the time you process a bounce, the message was sent, the rejection happened, and some reputation was already spent. The better strategy is preventing bounces entirely by checking addresses before they go out.

Real-time validation at the signup step (API check) blocks dead addresses before they enter your database. Bulk validation before each large send cleans addresses that have died since the last check. Our data shows quarterly validation brings bounce rates down 3-5x compared to automated bounce handling alone.

The full pipeline: validation at the front door (API check on signup) plus periodic bulk validation plus automated bounce processing via webhook and logs. Three layers, each catching what the previous one missed.

Full Python pipeline example

Here is a consolidated script you can use as a starting point. It accepts webhook events, parses logs on a schedule, and updates the suppression list through a single shared function.

# bounce_pipeline.py - single entry point for bounce processing
import os, re, csv, json, logging
from datetime import datetime, timedelta

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger("bounce")

class BounceProcessor:
    def __init__(self, db_conn):
        self.db = db_conn

    def handle_hard(self, email: str, code: str, reason: str):
        """Hard bounce -> immediate suppression."""
        email = email.lower().strip()
        cur = self.db.cursor()
        cur.execute("""
            INSERT INTO suppression_list (email, bounce_code, reason, created_at)
            VALUES (%s, %s, %s, NOW())
            ON CONFLICT (email) DO UPDATE SET
                bounce_code = EXCLUDED.bounce_code,
                updated_at = NOW()
        """, (email, code, reason))
        cur.execute("""
            UPDATE subscribers SET status = 'bounced', updated_at = NOW()
            WHERE LOWER(email) = %s AND status = 'active'
        """, (email,))
        self.db.commit()
        log.info(f"HARD suppress: {email} ({code})")

    def handle_soft(self, email: str, code: str, reason: str):
        """Soft bounce -> increment counter, suppress after 3rd."""
        email = email.lower().strip()
        cur = self.db.cursor()
        cur.execute("""
            INSERT INTO bounce_tracker (email, consecutive, last_bounce_at)
            VALUES (%s, 1, NOW())
            ON CONFLICT (email) DO UPDATE SET
                consecutive = bounce_tracker.consecutive + 1,
                last_bounce_at = NOW(),
                status = CASE
                    WHEN bounce_tracker.consecutive + 1 >= 3 THEN 'suppressed'
                    WHEN bounce_tracker.consecutive + 1 >= 2 THEN 'at_risk'
                    ELSE bounce_tracker.status
                END
        """, (email,))

        # Check if suppression threshold reached
        cur.execute(
            "SELECT consecutive FROM bounce_tracker WHERE email = %s",
            (email,)
        )
        row = cur.fetchone()
        if row and row[0] >= 3:
            self.handle_hard(email, code, f"soft bounce x{row[0]}: {reason}")
        else:
            self.db.commit()
            log.info(f"SOFT bounce #{row[0] if row else 1}: {email}")

    def process_webhook_event(self, event: dict):
        """Process one event from a webhook payload."""
        email = event.get("email") or event.get("recipient", "")
        bounce_type = event.get("type", "hard")
        code = str(event.get("code", event.get("status", "550")))
        reason = event.get("reason", event.get("error", ""))

        is_hard = bounce_type == "hard" or code.startswith("5")
        if is_hard:
            self.handle_hard(email, code, reason)
        else:
            self.handle_soft(email, code, reason)

    def parse_postfix_log(self, path="/var/log/mail.log"):
        """Parse Postfix log and process found bounces."""
        pattern = re.compile(
            r"to=<(?P<email>[^>]+)>.*status=bounced.*said:\s*"
            r"(?P<code>\d{3})\s+(?P<detail>[^)]+)",
            re.IGNORECASE,
        )
        count = 0
        with open(path) as f:
            for line in f:
                m = pattern.search(line)
                if m:
                    code = m.group("code")
                    if code.startswith("5"):
                        self.handle_hard(
                            m.group("email"), code, m.group("detail")
                        )
                    else:
                        self.handle_soft(
                            m.group("email"), code, m.group("detail")
                        )
                    count += 1
        log.info(f"Parsed {count} bounce events from {path}")

What to watch after going live

Automation is running, bounces are being processed. These metrics tell you whether the system is healthy.

Bounce rate per campaign. Should stay under 2%. If it has not dropped after deploying automation, the problem is your address sources, not your handling logic.

Suppression list growth. Fast growth means too much garbage is entering your database. Slow, steady growth means the system is working and your sources are clean.

Processing latency. Time from bounce to suppression. For webhooks it should be seconds. For the log parser, under an hour. Rising latency points to queue or cron issues.

False positives. Subscribers suppressed by mistake. If someone reports they stopped receiving mail, check the logs. False positives happen during temporary DNS failures when a soft bounce gets classified as hard. Proper signature verification and accurate code parsing keep this rare.

Want to cut your bounce rate as low as it can go? Check your list before the next send in uChecker. Front-door validation plus regular bulk cleaning plus automated bounce processing adds up to a bounce rate under 0.5%.

bounce email automationbounce webhookSMTP bounce handlingsuppression list scripthard bounce soft bounceemail deliverabilitybounce log parsingemail list hygiene