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
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 consentNames 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.
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.
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.
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.
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
Provider tells your app what happened to a sent messageUse for delivery, bounce, complaint, and engagement evidence.
Your app asks for current message activityUseful for reconciliation and recovery, but slower and quota-sensitive as a primary path.
Provider parses a message received at an address your app ownsTreat 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.