Inbound Email

How to Process Inbound Email Webhooks Without Duplicate Replies

Build an inbound email webhook that verifies signatures, deduplicates retries, routes replies safely, and prevents one message from triggering duplicate actions.

Treat an inbound email webhook as an untrusted, retryable event. Verify its signature against the untouched request body, insert the provider event ID into a table with a unique constraint, enqueue work in the same database transaction, and return a success response only after that write commits. Parse content, route the message, and send any reply asynchronously. That sequence prevents forged events, lost work, and duplicate replies.

The safe inbound email flow

An inbound email endpoint should do only five things synchronously:

  1. Read the raw request body.
  2. Verify the provider signature.
  3. Validate the event type and minimum required fields.
  4. Persist the event and an outbox job atomically.
  5. Return a success response.

Everything expensive belongs in a worker: retrieving the full message, parsing MIME, scanning attachments, sanitizing HTML, calling an AI model, updating a ticket, or sending a reply.

This split matters because webhook delivery is retryable. Resend, for example, supports automatic retries and manual replays, and its current webhook tooling exposes delivery attempts and replay controls. A replay can arrive after the first attempt already succeeded. Your endpoint must therefore be correct when the same event arrives more than once, not merely fast when it arrives once. See the official Resend webhook overview and Headless Webhook API announcement.

1. Verify the signature before parsing JSON

Never trust a request because it reached a secret-looking URL. Verify the signature using the provider's official SDK where one exists. Crucially, verification must receive the raw bytes or raw text. Parsing JSON and serializing it again can change whitespace or key ordering and invalidate a legitimate signature.

Resend's official example reads the request with req.text() and verifies the svix-id, svix-timestamp, and svix-signature headers with the webhook secret. Its documentation explicitly warns that even a parse-and-reserialize cycle can break verification. See Managing Webhooks via API.

Here is a compact Next.js route using that contract:

import { NextRequest, NextResponse } from "next/server";
import { Resend } from "resend";
import { db } from "@/lib/db";

const resend = new Resend(process.env.RESEND_API_KEY!);

export async function POST(request: NextRequest) {
  const rawBody = await request.text();

  let event;
  try {
    event = resend.webhooks.verify({
      payload: rawBody,
      headers: {
        id: request.headers.get("svix-id") ?? "",
        timestamp: request.headers.get("svix-timestamp") ?? "",
        signature: request.headers.get("svix-signature") ?? "",
      },
      webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
    });
  } catch {
    return new NextResponse("Invalid signature", { status: 400 });
  }

  if (event.type !== "email.received") {
    return NextResponse.json({ accepted: true });
  }

  const providerEventId = request.headers.get("svix-id");
  if (!providerEventId || !event.data?.email_id) {
    return new NextResponse("Invalid event", { status: 400 });
  }

  const inserted = await db.transaction(async (tx) => {
    const eventRow = await tx.oneOrNone(
      `insert into inbound_webhook_events
         (provider_event_id, email_id, payload, state)
       values ($1, $2, $3::jsonb, 'accepted')
       on conflict (provider_event_id) do nothing
       returning provider_event_id`,
      [providerEventId, event.data.email_id, rawBody]
    );

    if (!eventRow) return false;

    await tx.none(
      `insert into email_job_outbox
         (dedupe_key, job_type, payload)
       values ($1, 'process_inbound_email', $2::jsonb)`,
      [providerEventId, JSON.stringify({ providerEventId })]
    );
    return true;
  });

  return NextResponse.json({ accepted: true, duplicate: !inserted });
}

Adapt the verifier and header names to your provider. Do not copy a signature algorithm from a different provider, and do not log the signing secret or raw message body.

2. Make the database enforce idempotency

Application-level checks such as "select, then insert" race under concurrent retries. Put uniqueness in the database:

create table inbound_webhook_events (
  provider_event_id text primary key,
  email_id text not null,
  payload jsonb not null,
  state text not null,
  received_at timestamptz not null default now(),
  processed_at timestamptz
);

create table email_job_outbox (
  id bigserial primary key,
  dedupe_key text not null unique,
  job_type text not null,
  payload jsonb not null,
  dispatched_at timestamptz
);

The provider event ID is the delivery-level deduplication key. The email's Message-ID is useful for conversation correlation, but it is the wrong sole key for webhook delivery. RFC 5322 says a message should have a Message-ID; it does not make the field mandatory. The same standard defines In-Reply-To and References for associating replies with earlier messages. Use those fields for threading, while retaining your own provider event ID and internal email ID for processing. See RFC 5322, section 3.6.4.

The outbox row closes another common gap. If you commit the event and then separately publish to a queue, the process can crash between those operations. Writing the event and outbox job in one transaction means a dispatcher can safely publish undispatched jobs later. The worker must still be idempotent because a queue can redeliver a job.

For any outbound reply, add a second uniqueness boundary such as (inbound_email_id, action_type). Pass a deterministic idempotency key to the sending API when supported. This prevents a worker retry from sending the same acknowledgement twice. The broader pattern is covered in Idempotency Keys for Email APIs.

3. Route with the delivered recipient, not visible headers

The visible To and Cc headers are message content, not authoritative routing data. They can differ from the SMTP envelope because of BCC delivery, forwarding, aliases, or mailing lists. Use the provider's canonical delivered-recipient field to decide which tenant or workflow owns the message.

AWS makes this distinction explicit for SES receiving: downstream code should use the recipients field from the SES event rather than parsing To or Cc. The same documentation notes that raw received mail is MIME and that authentication and malware results are signals supplied to your application, not automatic business decisions. See Amazon SES email receiving concepts.

A practical routing address looks like this:

reply+th_7Jm2pQ9x@inbound.example.com

Map the opaque token to a stored thread and tenant. Then check that the thread is active and allowed to receive replies. Do not embed a sequential database ID, email address, or permission in the local part. Treat From, display names, subjects, and message bodies as untrusted input. A signed provider webhook proves who delivered the webhook; it does not prove that every claim inside the email is safe or authorized.

If an inbound message asks an AI agent to send mail, change account data, download a file, or reveal information, require a policy check and human approval for consequential actions. Email is an external input channel, not a privileged instruction channel.

4. Parse content and attachments as hostile data

Email bodies are not simple strings. RFC 5322 defines the header and body structure, while the MIME specifications extend it for alternative bodies, encodings, nested parts, and attachments. Use a maintained MIME parser rather than regular expressions. Preserve the raw message or a provider retrieval reference for debugging, but expose only normalized fields to application code.

For display:

  • Prefer the plain-text part when it is sufficient.
  • Sanitize HTML with a maintained allowlist sanitizer before rendering it.
  • Do not execute scripts, trust inline forms, or fetch remote images by default.
  • Apply output encoding appropriate to the UI context.

OWASP recommends HTML sanitization when untrusted rich content must be rendered. It also warns that changing content after sanitization can undo the protection. See the OWASP Cross Site Scripting Prevention Cheat Sheet.

Treat attachments like hostile uploads:

  • Enforce a total message size and per-file size before heavy processing.
  • Allow only file types required by the product.
  • Do not trust the filename, extension, or declared Content-Type alone.
  • Generate storage names and keep files outside the public web root.
  • Scan or sandbox files before extraction or user access.
  • Reject archives unless the product truly needs them, and cap expanded size.

These controls align with the OWASP File Upload Cheat Sheet, which recommends defense in depth rather than relying on a single content-type or extension check.

5. Prevent mail loops and unsafe automatic replies

The safest default is to ingest first and reply only when a defined product rule requires it. For each inbound email, decide among store, notify, draft_reply, send_reply, and quarantine. Keep draft_reply and send_reply separate states so an AI-generated response cannot silently become an external side effect.

Before sending an automated reply:

  • Confirm the route belongs to an active customer workflow.
  • Check consent, suppression, and opt-out state.
  • Allow at most one automatic reply for the same inbound action key.
  • Refuse messages identified as automated, bulk, or system-generated when a reply would create a loop.
  • Cap replies per thread, sender, tenant, and time window.
  • Require review for sensitive, financial, legal, or account-changing content.

Do not infer that provider acceptance means delivery or inbox placement. Record the outbound message ID, then consume delivery, bounce, complaint, and suppression events as separate facts.

Failure handling that survives production

Use explicit responses and states:

FailureEndpoint responseNext action
Missing or invalid signature400Reject and alert on a sustained spike
Unsupported event type200Ignore safely
Duplicate provider event ID200No new job and no new reply
Database unavailable before commit500Let the provider retry
Event committed, worker fails200Retry the internal job
Unsafe attachment or content200Quarantine and record the reason
Reply send times outNo webhook changeRetry with the same outbound idempotency key

Monitor signature failures, duplicate rate, webhook age, queue lag, processing failures, quarantines, and replies blocked by policy. Store enough structured context to debug an event without putting full email bodies or attachment contents into ordinary application logs.

Production checklist

  • Signature verification uses the untouched request body and a secret from a secrets manager.
  • The webhook event ID has a database unique constraint.
  • Event persistence and job creation share one transaction.
  • The endpoint acknowledges only after durable persistence.
  • The worker and reply sender are independently idempotent.
  • Tenant routing uses the provider's delivered-recipient field.
  • MIME parsing uses a maintained library.
  • HTML is sanitized and remote content is disabled by default.
  • Attachments have size, type, storage, and scanning controls.
  • Automatic replies pass consent, suppression, rate, and loop checks.
  • Agent-generated replies require approval when consequences are material.
  • Metrics distinguish receipt, processing, send acceptance, delivery, bounce, and complaint.

The core rule is simple: acknowledge receipt once, but make every downstream action safe to run again. If you want inbound inboxes, observable events, and constrained outbound sending behind one API, build the workflow with SendHQ.