When users send from their own mailbox, the bounce usually comes back as email.

That sounds simple until your application needs a clean event. The delivery failure may land in the user’s Gmail, Outlook, Microsoft 365, Office 365, or IMAP mailbox. Your app then needs to turn that mailbox message into a reliable webhook.

This guide shows one practical path:

  1. Confirm where the bounce arrives.
  2. Connect the mailbox that receives it.
  3. Route likely bounce messages.
  4. Send a webhook payload.
  5. Make the receiver safe for retries and replay.

The examples use actual MailWebhook route rules, pipeline mappers, and delivery headers. They stay inside the mailbox intake and webhook delivery features MailWebhook exposes today. The receiver still owns the final product decision, such as hard bounce, soft bounce, retry later, suppress, or review. For the broader system pattern, the inbound email processing architecture guide covers the full intake-to-worker path.

Step 1: Confirm where bounces arrive

Start with a controlled failed delivery.

Use a test mailbox or a staging account. Send one message through the same path your product uses in production. Then check where the failure notice appears.

Confirm these facts before you configure anything:

  • Does the bounce appear in the user’s mailbox?
  • Can the connected mailbox source see the message?
  • Does the message arrive in the folder or label you plan to monitor?
  • Does the raw message include DSN-like headers or MIME parts?
  • Does the subject or sender vary by provider?

Bounce arrival confirmation concept image

For Gmail and Microsoft mailboxes, use a provider-real message for this test. A route preview or synthetic test event can prove that your webhook receiver works. A provider-real bounce proves the mailbox behavior you need in production.

If no bounce appears in the mailbox, the provider or sending path is handling the failure before the mailbox access layer can see it.

Step 2: Connect the mailbox source

Connect the mailbox that receives the bounce.

MailWebhook supports these mailbox source paths:

The right source is the one that can see the bounce message. For user-mailbox bounce handling, that is usually the same connected mailbox used by the sender account.

Hosted MailWebhook mailboxes are built for receiving inbound email directly, so they sit outside this article’s user-sending-mailbox scope.

If you are building your own IMAP reader, the maintenance problem is broader than bounce handling. I covered that path in Why Custom IMAP Scripts Age Badly.

Step 3: Route likely bounce messages

MailWebhook routes run when their rule matches a normalized inbound email. Supported rule fields include:

  • subject_contains
  • subject_regex
  • to_contains and to_emails
  • from_contains, from_emails, and from_domains
  • headers_equals and headers_contains
  • attachments_mime
  • boolean groups: any, all, none, and negate

For bounce capture, start with a candidate route. The goal is to catch likely delivery failure messages, then let your receiver make the final classification.

Bounce route signal filter concept image

Here is a practical starter rule:

{
  "any": [
    {
      "from_contains": ["mailer-daemon"]
    },
    {
      "from_contains": ["postmaster"]
    },
    {
      "subject_contains": [
        "delivery status notification",
        "undelivered mail returned to sender",
        "delivery has failed",
        "failure notice",
        "message not delivered"
      ]
    },
    {
      "headers_contains": {
        "content-type": "report-type=delivery-status"
      }
    }
  ]
}

This rule uses only supported route fields. It works as a candidate filter because MailWebhook normalizes the subject, sender, recipient, headers, and attachment MIME data before route matching.

Keep the first version reviewable. Bounce messages vary across providers, and subjects can be encoded. The content-type header signal is often stronger when the message is a structured delivery status notification. Some providers still vary the exact header value.

After you inspect real messages, tighten the rule. For example, if a mailbox also receives many auto-replies, you can add a none block for known false positives:

{
  "any": [
    {
      "from_contains": ["mailer-daemon"]
    },
    {
      "from_contains": ["postmaster"]
    },
    {
      "headers_contains": {
        "content-type": "report-type=delivery-status"
      }
    }
  ],
  "none": [
    {
      "subject_contains": ["out of office", "automatic reply"]
    }
  ]
}

Avoid making the rule too clever before you collect examples from the providers your users actually connect.

Step 4: Choose the webhook payload

Most teams should start with Generic JSON.

Generic JSON gives your receiver the normalized message, event metadata, headers, body text, body HTML, and attachment descriptors. It is the easiest payload to debug during setup.

A route that uses Generic JSON has this pipeline:

{
  "steps": [
    {
      "name": "map.generic_json",
      "args": {}
    }
  ]
}

A full route creation body looks like this:

{
  "name": "Mailbox bounce candidates",
  "endpoint_id": "00000000-0000-0000-0000-000000000000",
  "signing_secret_kid": "route-signing-prod",
  "enabled": true,
  "rule": {
    "any": [
      {
        "from_contains": ["mailer-daemon"]
      },
      {
        "from_contains": ["postmaster"]
      },
      {
        "subject_contains": [
          "delivery status notification",
          "undelivered mail returned to sender",
          "delivery has failed",
          "failure notice",
          "message not delivered"
        ]
      },
      {
        "headers_contains": {
          "content-type": "report-type=delivery-status"
        }
      }
    ]
  },
  "pipeline": {
    "steps": [
      {
        "name": "map.generic_json",
        "args": {}
      }
    ]
  }
}

If the receiver only needs a smaller bounce-candidate payload, the Custom JSON mapper can read roots such as message.*, ctx.*, meta.*, and vars.*.

This route emits a compact payload with the event id, source, message id, subject, sender, recipients, headers, text snippet, and attachment metadata:

{
  "name": "Mailbox bounce candidates compact",
  "endpoint_id": "00000000-0000-0000-0000-000000000000",
  "signing_secret_kid": "route-signing-prod",
  "enabled": true,
  "rule": {
    "any": [
      {
        "from_contains": ["mailer-daemon"]
      },
      {
        "from_contains": ["postmaster"]
      },
      {
        "subject_contains": [
          "delivery status notification",
          "undelivered mail returned to sender",
          "delivery has failed",
          "failure notice",
          "message not delivered"
        ]
      },
      {
        "headers_contains": {
          "content-type": "report-type=delivery-status"
        }
      }
    ]
  },
  "pipeline": {
    "steps": [
      {
        "name": "map.custom_json",
        "args": {
          "version": "v1",
          "vars": [
            {
              "name": "text",
              "expr": {
                "call.transform.html_to_text": {
                  "html": {
                    "var": "message.html"
                  },
                  "text": {
                    "var": "message.text"
                  }
                }
              }
            }
          ],
          "output": {
            "event_id": {
              "var": "ctx.event_id"
            },
            "route_id": {
              "var": "ctx.route_id"
            },
            "source_type": {
              "var": "ctx.source_type"
            },
            "message_id": {
              "var": "message.message_id"
            },
            "received_at": {
              "var": "message.received_at"
            },
            "subject": {
              "var": "message.subject"
            },
            "from": {
              "var": "message.from[0].email"
            },
            "to": {
              "var": "message.to"
            },
            "headers": {
              "var": "message.headers"
            },
            "text_preview": {
              "substr": [
                {
                  "var": "vars.text"
                },
                0,
                2000
              ]
            },
            "attachments": {
              "var": "message.attachments"
            }
          }
        }
      }
    ]
  }
}

This example intentionally maps the whole message.headers object. Many useful bounce headers contain hyphens, such as return-path, content-type, and auto-submitted. The Custom JSON variable path syntax supports dotted object traversal and array indexes, so sending the header map keeps those hyphenated keys intact for your receiver.

Step 5: Classify the bounce in your receiver

The route should catch candidates. Your receiver should decide what the candidate means.

Use the payload fields in this order:

  1. Check headers["content-type"] for delivery-status report hints.
  2. Check sender signals such as mailer-daemon and postmaster.
  3. Check the normalized subject.
  4. Read the body text for provider-specific diagnostic details.
  5. Link the bounce back to the original send record using the returned recipient, original headers, or provider-specific references.

Bounce classification funnel concept image

If you need full DSN parsing, do that in the receiver or a downstream worker. MailWebhook can deliver the candidate message and metadata. Your application owns the business policy that turns a candidate into hard_bounce, soft_bounce, temporary_failure, review, or ignore.

That separation is useful. You can change classification rules without changing the mailbox connection or delivery pipeline.

Here is a compact classifier for the Custom JSON payload from Step 4. It treats the route output as a candidate and returns reasons your application can log, review, or combine with provider-specific rules.

const senderTerms = ["mailer-daemon", "postmaster"];
const subjectTerms = [
  "delivery status notification",
  "undelivered mail returned to sender",
  "delivery has failed",
  "failure notice",
  "message not delivered",
];

function lower(value) {
  return typeof value === "string" ? value.toLowerCase() : "";
}

export function classifyBounceCandidate(payload) {
  const headers =
    payload && typeof payload.headers === "object" && payload.headers !== null
      ? payload.headers
      : {};

  const from = lower(payload?.from);
  const subject = lower(payload?.subject);
  const contentType = lower(headers["content-type"]);
  const textPreview = lower(payload?.text_preview);
  const reasons = [];

  if (
    contentType.includes("report-type=delivery-status") ||
    contentType.includes("message/delivery-status")
  ) {
    reasons.push("delivery-status content type");
  }

  if (senderTerms.some((term) => from.includes(term))) {
    reasons.push("delivery-system sender");
  }

  if (subjectTerms.some((term) => subject.includes(term))) {
    reasons.push("bounce-like subject");
  }

  if (textPreview.includes("final-recipient") || textPreview.includes("status: 5.")) {
    reasons.push("dsn-like body text");
  }

  return {
    message_id: payload?.message_id ?? null,
    is_bounce_candidate: reasons.length > 0,
    confidence: reasons.length >= 2 ? "high" : reasons.length === 1 ? "review" : "none",
    reasons,
  };
}
from typing import Any


SENDER_TERMS = ("mailer-daemon", "postmaster")
SUBJECT_TERMS = (
    "delivery status notification",
    "undelivered mail returned to sender",
    "delivery has failed",
    "failure notice",
    "message not delivered",
)


def _lower(value: Any) -> str:
    return value.lower() if isinstance(value, str) else ""


def classify_bounce_candidate(payload: dict[str, Any]) -> dict[str, Any]:
    headers = payload.get("headers")
    if not isinstance(headers, dict):
        headers = {}

    sender = _lower(payload.get("from"))
    subject = _lower(payload.get("subject"))
    content_type = _lower(headers.get("content-type"))
    text_preview = _lower(payload.get("text_preview"))
    reasons: list[str] = []

    if (
        "report-type=delivery-status" in content_type
        or "message/delivery-status" in content_type
    ):
        reasons.append("delivery-status content type")

    if any(term in sender for term in SENDER_TERMS):
        reasons.append("delivery-system sender")

    if any(term in subject for term in SUBJECT_TERMS):
        reasons.append("bounce-like subject")

    if "final-recipient" in text_preview or "status: 5." in text_preview:
        reasons.append("dsn-like body text")

    return {
        "message_id": payload.get("message_id"),
        "is_bounce_candidate": bool(reasons),
        "confidence": "high" if len(reasons) >= 2 else "review" if reasons else "none",
        "reasons": reasons,
    }
package bounce

import "strings"

type BouncePayload struct {
	MessageID   string            `json:"message_id"`
	Subject     string            `json:"subject"`
	From        string            `json:"from"`
	Headers     map[string]string `json:"headers"`
	TextPreview string            `json:"text_preview"`
}

type BounceClassification struct {
	MessageID         string   `json:"message_id"`
	IsBounceCandidate bool     `json:"is_bounce_candidate"`
	Confidence        string   `json:"confidence"`
	Reasons           []string `json:"reasons"`
}

var senderTerms = []string{"mailer-daemon", "postmaster"}

var subjectTerms = []string{
	"delivery status notification",
	"undelivered mail returned to sender",
	"delivery has failed",
	"failure notice",
	"message not delivered",
}

func containsAny(value string, terms []string) bool {
	for _, term := range terms {
		if strings.Contains(value, term) {
			return true
		}
	}
	return false
}

func ClassifyBounceCandidate(payload BouncePayload) BounceClassification {
	from := strings.ToLower(payload.From)
	subject := strings.ToLower(payload.Subject)
	contentType := strings.ToLower(payload.Headers["content-type"])
	textPreview := strings.ToLower(payload.TextPreview)
	reasons := make([]string, 0, 4)

	if strings.Contains(contentType, "report-type=delivery-status") ||
		strings.Contains(contentType, "message/delivery-status") {
		reasons = append(reasons, "delivery-status content type")
	}

	if containsAny(from, senderTerms) {
		reasons = append(reasons, "delivery-system sender")
	}

	if containsAny(subject, subjectTerms) {
		reasons = append(reasons, "bounce-like subject")
	}

	if strings.Contains(textPreview, "final-recipient") ||
		strings.Contains(textPreview, "status: 5.") {
		reasons = append(reasons, "dsn-like body text")
	}

	confidence := "none"
	if len(reasons) >= 2 {
		confidence = "high"
	} else if len(reasons) == 1 {
		confidence = "review"
	}

	return BounceClassification{
		MessageID:         payload.MessageID,
		IsBounceCandidate: len(reasons) > 0,
		Confidence:        confidence,
		Reasons:           reasons,
	}
}

Step 6: Make the webhook receiver safe

MailWebhook sends webhook deliveries as HTTP POST requests with Content-Type: application/json.

The delivery headers matter:

  • X-MailWebhook-Signature verifies the request body.
  • X-Idempotency-Key is stable for the same message and route across retries and replay.
  • Idempotency-Key can also be present as a compatibility alias when customer endpoint headers have no idempotency header variant.

Your receiver should follow this sequence:

  1. Read the raw request body.
  2. Verify the signed delivery header X-MailWebhook-Signature against the raw body.
  3. Store X-Idempotency-Key before changing recipient state.
  4. Save the payload or enqueue durable work.
  5. Return 2xx only after durable acceptance.

Safe webhook receiver sequence concept image

Treat duplicate delivery as normal. If the same X-Idempotency-Key arrives again, return success when the first request was already accepted. That keeps retry and replay safe.

The idempotency claim should happen before the receiver updates recipient state. These examples pair with the classifier above, use Redis-style atomic SET NX semantics, and assume signature verification already passed. Pass your application’s recipient-update function into the handler. For a full raw-body endpoint in each stack, the Node.js, Python, and Go implementation guide expands this receiver pattern.

const claimTtlSeconds = 60 * 60 * 24 * 7;

export async function claimDelivery(redisClient, idempotencyKey) {
  if (!idempotencyKey) {
    throw new Error("X-Idempotency-Key is required");
  }

  const result = await redisClient.set(
    `mailwebhook:delivery:${idempotencyKey}`,
    "claimed",
    {
      NX: true,
      EX: claimTtlSeconds,
    },
  );

  return result === "OK";
}

export async function handleBounceCandidate(
  redisClient,
  idempotencyKey,
  payload,
  updateRecipientState,
) {
  const claimed = await claimDelivery(redisClient, idempotencyKey);
  if (!claimed) {
    return { duplicate: true };
  }

  const classification = classifyBounceCandidate(payload);
  if (classification.is_bounce_candidate) {
    await updateRecipientState(classification);
  }

  return { accepted: true, classification };
}
from collections.abc import Callable
from typing import Any


CLAIM_TTL_SECONDS = 60 * 60 * 24 * 7


def claim_delivery(redis_client: Any, idempotency_key: str | None) -> bool:
    if not idempotency_key:
        raise ValueError("X-Idempotency-Key is required")

    return bool(
        redis_client.set(
            f"mailwebhook:delivery:{idempotency_key}",
            "claimed",
            nx=True,
            ex=CLAIM_TTL_SECONDS,
        )
    )


def handle_bounce_candidate(
    redis_client: Any,
    idempotency_key: str | None,
    payload: dict[str, Any],
    update_recipient_state: Callable[[dict[str, Any]], None],
) -> dict[str, Any]:
    claimed = claim_delivery(redis_client, idempotency_key)
    if not claimed:
        return {"duplicate": True}

    classification = classify_bounce_candidate(payload)
    if classification["is_bounce_candidate"]:
        update_recipient_state(classification)

    return {"accepted": True, "classification": classification}
package bounce

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

const claimTTL = 7 * 24 * time.Hour

func ClaimDelivery(ctx context.Context, client *redis.Client, idempotencyKey string) (bool, error) {
	if idempotencyKey == "" {
		return false, errors.New("X-Idempotency-Key is required")
	}

	return client.SetNX(
		ctx,
		fmt.Sprintf("mailwebhook:delivery:%s", idempotencyKey),
		"claimed",
		claimTTL,
	).Result()
}

func HandleBounceCandidate(
	ctx context.Context,
	client *redis.Client,
	idempotencyKey string,
	payload BouncePayload,
	updateRecipientState func(context.Context, BounceClassification) error,
) (BounceClassification, bool, error) {
	claimed, err := ClaimDelivery(ctx, client, idempotencyKey)
	if err != nil || !claimed {
		return BounceClassification{}, !claimed, err
	}

	classification := ClassifyBounceCandidate(payload)
	if classification.IsBounceCandidate {
		if err := updateRecipientState(ctx, classification); err != nil {
			return classification, false, err
		}
	}

	return classification, false, nil
}

Step 7: Test the full bounce path

Continue past route preview.

Test the whole path with a real provider message:

  1. Connect the mailbox.
  2. Create the bounce-candidate route.
  3. Send a controlled message that will fail.
  4. Confirm the bounce appears in the mailbox.
  5. Confirm a MailWebhook event is created.
  6. Confirm the route matched.
  7. Confirm the webhook reached your receiver.
  8. Confirm the receiver stored the idempotency key.
  9. Confirm the recipient state changed once.
  10. Replay the event and confirm no duplicate side effect occurs.

End-to-end bounce replay test concept image

The replay test is important. A receiver that handles replay safely is much more likely to handle timeouts and retries safely in production.

Common failure modes

The bounce never reaches the mailbox.

Check the sending provider and envelope behavior. MailWebhook can route the message only after the mailbox source can see it.

The route misses a real bounce.

Inspect the actual message headers and subject. Add a new supported rule signal such as headers_contains, subject_contains, or from_contains.

The route catches auto-replies.

Add a none block for repeated false positives, or keep the route broad and reject non-bounces in the receiver.

The receiver updates state twice.

Store X-Idempotency-Key before side effects. Return 2xx for already accepted keys.

The payload is too broad.

Start with Generic JSON while debugging. Move to Custom JSON once you know which fields your receiver needs.

Where MailWebhook fits

MailWebhook fits the mailbox intake and email webhook API layer:

  • Connect Gmail, Google Workspace, Microsoft 365, Office 365, Outlook, or IMAP mailboxes.
  • Route likely bounce messages with supported rule fields.
  • Emit Generic JSON or Custom JSON.
  • Sign webhook deliveries.
  • Include an idempotency key.
  • Record delivery attempts.
  • Retry transient failures.
  • Replay existing events after a receiver or route fix.

Sender reputation, consent, SMTP envelope behavior, Return-Path control, and outreach policy belong to the sending layer. MailWebhook is useful after the bounce lands in a mailbox and your app needs a reliable webhook event.