All dispatchesView as Markdown

Email webhooks: build secure, duplicate-safe delivery events

MC
Maya ChenEmail infrastructure at Email Bump

Receive delivered, deferred, bounced, complained, and engagement events without trusting forged requests, losing retries, or corrupting message state when events arrive twice or out of order.

An email webhook is an HTTPS request an email provider sends to your application when a message changes state. Instead of polling a provider dashboard, your system can learn that a message was delivered, deferred, bounced, complained about, opened, clicked, or suppressed—and connect that evidence to the product action that caused the send.

The endpoint is easy to demo and easy to get subtly wrong. Providers retry failed deliveries, operators replay events, batches can contain partial duplication, and events for one message can arrive out of order. A public endpoint that parses JSON before verifying the request can also accept forged bounce or complaint events. Production design begins with an at-least-once, untrusted-input model.

Email webhook events are evidence, not one universal state machine

Common outbound email events
EVENT        WHAT IT USUALLY MEANS                         PRODUCT USE
accepted     provider accepted or queued the send request      correlate submission
processed    provider prepared message for delivery            operational milestone
deferred     receiver temporarily rejected an attempt          alert on receiver trends
delivered    receiving server accepted responsibility          update delivery evidence
bounced      delivery failed permanently or finally expired    classify and suppress correctly
complained   recipient/provider reported spam                  suppress and investigate source
dropped      provider intentionally did not attempt delivery    inspect suppression or policy
opened       tracking resource was requested                    approximate engagement only
clicked      tracked redirect was requested                     stronger, still privacy-sensitive
unsubscribed subscription preference changed                   enforce purpose-level consent

Names and semantics differ by provider. Some use sent for provider submission, others for an outbound attempt. Some emit one bounce after retry exhaustion; others expose each deferral plus a final outcome. Normalize events into your own vocabulary, but retain provider type, raw diagnostic, and documentation version so normalization does not erase useful evidence.

Delivered normally means the receiving server returned success. It does not prove inbox placement, rendering, reading, or human recognition. Opens can be generated by privacy proxies and security scanners; clicks can be generated by automated link inspection. Keep transport outcomes and engagement evidence in separate fields.

Correlate every event to a durable message identity

Store the provider message ID returned at submission and attach your own non-sensitive message or outbox ID as supported metadata. The provider ID joins webhook evidence to a transport attempt. Your ID joins that attempt to the business intent—such as one receipt for payment 8142—without putting customer details in logs or provider tags.

Three identities to keep separate
BUSINESS INTENT     payment-receipt:payment_8142:v3
APP MESSAGE ID      018f...                         stable internal ownership
PROVIDER MESSAGE ID provider-specific-id            transport correlation
PROVIDER EVENT ID   provider-event-id                delivery deduplication
ATTEMPT ID          app-message-id:2                 retry evidence

An event ID identifies the notification. A message ID identifies the send.
Do not deduplicate every event for one message into a single row.

One message legitimately produces several events: accepted, deferred, delivered, opened, clicked, or bounced. The deduplication key must therefore be the provider's event identity, not only the message identity. If a provider does not supply a stable event ID, follow its documented recommendation or derive a conservative fingerprint from immutable fields while preserving the collision risk.

Verify the webhook before trusting its JSON

Use the provider's official verification library or documented algorithm. Most signed webhook designs cover the raw request body and a timestamp with an HMAC secret or asymmetric key. Reading and reserializing JSON before verification changes bytes and can invalidate a legitimate signature—or lead developers to disable verification to make the endpoint work.

Provider-neutral TypeScript handler shapetypescript
export async function POST(request: Request): Promise<Response> {
  const raw = new Uint8Array(await request.arrayBuffer())
  if (raw.byteLength > 256_000) return new Response("Too large", { status: 413 })

  const signature = request.headers.get("webhook-signature") ?? ""
  const timestamp = request.headers.get("webhook-timestamp") ?? ""

  // Use the provider's library/algorithm against raw bytes.
  // Enforce its timestamp tolerance and support active + next secret during rotation.
  if (!verifyProviderWebhook({ raw, signature, timestamp })) {
    return new Response("Invalid signature", { status: 400 })
  }

  const events = parseAndValidateEvents(raw)
  await persistWebhookBatch(events) // unique provider_event_id, one transaction
  return new Response(null, { status: 200 })
}
  • Require HTTPS and keep the endpoint on a narrowly scoped public route.
  • Verify signatures with constant-time comparison where the official scheme requires it.
  • Reject stale timestamps according to provider guidance to limit captured-request replay.
  • Store webhook secrets or public verification keys in a secret manager, not source code.
  • Rotate without a gap by accepting the active and next verification material for a bounded overlap.
  • Treat IP allowlists as defense in depth, not a replacement for cryptographic verification.
  • Apply body-size, content-type, parsing-depth, and request-rate limits before expensive work.

Persist first; process after the response

Webhook providers expect a fast successful response. If the handler performs CRM calls, suppression fan-out, analytics, template work, or customer notifications before returning, each downstream slowdown can trigger a retry. Persist the verified event, commit, return the provider's documented success status, then let a worker own side effects.

Webhook inbox schemasql
CREATE TABLE email_webhook_events (
  provider            text        NOT NULL,
  provider_event_id   text        NOT NULL,
  provider_message_id text        NOT NULL,
  event_type          text        NOT NULL,
  occurred_at         timestamptz NOT NULL,
  received_at         timestamptz NOT NULL DEFAULT now(),
  payload             jsonb       NOT NULL,
  state               text        NOT NULL DEFAULT 'pending',
  attempts            integer     NOT NULL DEFAULT 0,
  next_attempt_at     timestamptz NOT NULL DEFAULT now(),
  last_error          text,
  PRIMARY KEY (provider, provider_event_id)
);

-- INSERT ... ON CONFLICT DO NOTHING makes redelivery harmless.
-- Encrypt or minimize payloads according to privacy and retention policy.

A durable inbox gives the endpoint an honest success boundary: the application owns the event once it is committed. Workers can retry transient database or downstream failures without asking the provider to redeliver. A dead-letter state retains exhausted work for investigation and controlled replay rather than dropping it or retrying forever.

Assume duplicate and out-of-order email events

At-least-once delivery means the same event may arrive more than once. Resend, for example, documents automatic retries and manual replay of both failed and successful webhook messages. Network loss creates ambiguity: your endpoint may commit the event and return 200, but the provider may not receive that response and can deliver it again.

Ordering is also not guaranteed. Separate workers, batched requests, retry schedules, and provider pipelines can make an open appear before delivered or a delayed attempt arrive after a terminal bounce. Resend explicitly documents that an opened event can arrive before delivered and recommends using the event timestamp when ordering matters.

Keep evidence richer than one status column
MESSAGE EVIDENCE
accepted_at          first provider acceptance
last_deferred_at     most recent temporary failure
delivered_at         receiver acceptance evidence
bounced_at           terminal failure evidence
complained_at        complaint evidence
last_opened_at       approximate engagement evidence
last_clicked_at      approximate engagement evidence

Do not let a late deferred event overwrite delivered.
Do not let an open erase a later bounce.
Keep the event log and derive the current operational view.

Define transition rules per provider and event type. Terminal delivery evidence may be monotonic for one provider, while delayed bounces require another rule. Store occurred_at from the signed event and received_at from your server; never sort solely by arrival time. When a provider changes semantics, version the adapter rather than silently reinterpreting historical rows.

Make bounce, complaint, and unsubscribe effects idempotent

Delivery events often trigger safety controls. A permanent unknown-user bounce can suppress an address. A spam complaint should stop applicable mail and trigger source investigation. An unsubscribe should update the relevant purpose or subscription rather than disabling security and billing notices indiscriminately.

  • Scope suppression by reason: nonexistent mailbox differs from sender authentication or policy failure.
  • Record the source event that created or changed a suppression so replay produces the same result.
  • Use a unique command or transition key for downstream CRM and analytics writes.
  • Do not send a second email in direct response to a bounce or complaint; that can create loops and repeat unwanted contact.
  • Propagate consent changes across every authorized sender, with a durable audit record and bounded retry.
  • Alert on clusters by recipient domain, sender domain, stream, tenant, and acquisition source—not only account totals.

Do not put sensitive data in webhook metadata

Webhook payloads can contain recipient addresses, subjects, URLs, IP addresses, diagnostics, tags, and custom metadata. Engagement events may reveal behavioral data. Store only fields the product or delivery operation needs, restrict access, encrypt sensitive payloads, redact application logs, and define retention independently from the provider's dashboard retention.

Use opaque internal IDs in provider metadata. Do not put access tokens, reset links, invoice details, health information, full names, or arbitrary user content in tags just because they are convenient to search. A metadata field can appear in provider logs, webhook requests, support exports, or analytics pipelines.

Test the failure modes, not only the happy event

  • Valid signed event creates one inbox row and acknowledges only after commit.
  • Invalid signature, stale timestamp, oversized body, malformed JSON, and unknown event type fail safely.
  • The same event delivered twice creates one durable row and one business effect.
  • A batch with one duplicate and one new event preserves the new event without replaying the old effect.
  • Delivered, deferred, opened, and bounced events arriving in every order produce the intended operational view.
  • Database outage returns a retryable failure; downstream outage does not hold the HTTP request open.
  • Secret rotation accepts both keys only during the planned overlap and rejects the retired key afterward.
  • Manual replay is safe for successful as well as failed events.

Use the provider's test-event feature, then trigger real sends to inboxes you control. A synthetic event validates the endpoint contract; it does not prove provider message IDs, SMTP diagnostics, suppression behavior, or production event selection are wired correctly. Observe queue age, verification failures, deduplication rate, processing retries, dead letters, and the time from provider occurrence to applied state.

Email webhooks vs polling and inbound email

01 / Outbound event webhookProvider tells your app what happened to a sent message

Use for delivery, bounce, complaint, and engagement evidence.

02 / Provider API pollingYour app asks for current message activity

Useful for reconciliation and recovery, but slower and quota-sensitive as a primary path.

03 / Inbound email webhookProvider parses a message received at an address your app owns

Treat message contents and attachments as untrusted input even after webhook verification.

A strong system uses webhooks for low-latency evidence and a reconciliation job for missing or ambiguous records. Polling is the repair path, not an excuse to ignore webhook durability. Inbound messages need a separate threat model because authenticated provider delivery does not make the sender, attachment, link, or instruction trustworthy.