Learn

How email webhooks work (and how to handle them safely)

Updated September 25, 2026

In short

An email webhook is an HTTPS POST your email provider sends to your server when something happens to a message - delivered, bounced, complained, opened, clicked or received. Your endpoint should verify the signature over the raw request body, reject old timestamps, ignore events it has already processed, store the event, return 2xx quickly, and do the real work in the background.

Why webhooks instead of polling

Delivery happens after your API call returns, sometimes seconds later, sometimes hours for a slow retry. You could poll the API for every message's status, but that is a request per message per check. A webhook inverts it: the provider tells you once, when it knows.

How an email webhook is delivered and verifiedSendpositoryYour endpointPOST /webhooks/emailYour workerqueue + databaseemail.bounced happensPOST signed JSONSendpository-Signature: t=…,v1=…HMAC-SHA256(t + "." + rawBody)reject if older than 5 minskip if already seenemail_id + typeenqueue200 OK within 10 smark the address undeliverableEach attempt is logged with its status code.15 failures in a row disable the endpoint.

Swipe the diagram to see the rest

A webhook delivery. Verify the signature over the raw body, reject stale timestamps, store the event, answer 2xx quickly, and do the real work in the background.

What an event looks like

email.bounced
POST /webhooks/email HTTP/1.1
Content-Type: application/json
Sendpository-Event: email.bounced
Sendpository-Signature: t=1788026132,v1=4f3a9c...

{
  "type": "email.bounced",
  "created_at": "2026-09-25T09:12:44.019Z",
  "data": {
    "email_id": "d67e39ed-96be-4b35-ab22-853163d92c92",
    "from": "orders@mail.acme.com",
    "to": ["customer@example.com"],
    "subject": "Receipt for order #10428",
    "status": "bounced",
    "tags": { "type": "receipt", "order_id": "10428" }
  }
}

The email_id is the id your send call returned, and tags are whatever you attached - which is how an event finds its way back to the order or user it is about without another lookup.

EventWhenTypical action
email.deliveredThe recipient's server accepted itMark as delivered
email.delayedA temporary failure; being retriedUsually nothing
email.bouncedPermanent failure; address suppressedFlag the address on the user
email.complainedMarked as spam; address suppressedFlag the user; review what was sent
email.failedCouldn't be sent at allAlert; check the logs
email.opened / email.clickedTracking, when enabledAnalytics
email.receivedMail arrived at a receiving addressCreate a ticket, parse a reply

Verifying the signature

Your endpoint is a public URL, so anyone can POST to it. The signature proves the request came from the provider and wasn't changed. Sendpository computes an HMAC-SHA256 over the timestamp, a dot and the raw body, using your endpoint's secret, and sends both in one header. You recompute it and compare in constant time.

Node.js, without the SDK
import crypto from "node:crypto";

export function verify(rawBody: string, header: string, secret: string) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // replay window

  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

The timestamp check matters as much as the HMAC: without it, anyone who captures one genuine request could replay it at you forever. The SDK's verifyWebhook does both, and rejects anything older than five minutes.

Make the endpoint fast

Providers wait a limited time for your response - Sendpository waits 10 seconds - and treat a slow answer as a failure. Store the event (or put it on a queue), return 200, and do the work afterwards. A handler that sends its own emails or calls third-party APIs inline will eventually time out under load.

Make the endpoint idempotent

Design for the same event arriving twice - from a provider retry, a network hiccup, or a replay during recovery. Record what you have processed and skip repeats. For delivery events, the pair of email_id and type identifies an event; opens and clicks can legitimately repeat, so include created_at for those.

Don't rely on webhooks alone

Webhooks are the fast path, not the only path. On Sendpository each event is delivered once and every attempt is logged with its status code on the Webhooks page; an endpoint that fails 15 times in a row is disabled until you fix and re-enable it. If your endpoint was down, reconcile from the API - it can list messages by status and time window in one call:

Find bounces you may have missed
curl "https://api.sendpository.com/v1/emails?status=bounced&created_after=2026-09-24T00:00:00Z" \
  -H "Authorization: Bearer $SENDPOSITORY_API_KEY"

Checklist

  • HTTPS endpoint, verified signature, raw body.
  • Reject timestamps older than five minutes.
  • Deduplicate on email_id + type.
  • Return 2xx within a couple of seconds; process in the background.
  • Subscribe each endpoint only to the events it handles.
  • Reconcile from the API after downtime.

Questions

What is an email webhook?

An HTTP POST your email provider sends to a URL you register whenever an event happens to one of your messages - delivered, bounced, complained, opened, clicked - or when mail arrives at a receiving address.

How do I verify an email webhook?

Recompute the provider's signature over the raw request body with your endpoint secret and compare it in constant time, and reject requests whose timestamp is more than a few minutes old. On Sendpository that is HMAC-SHA256 over "timestamp.body", sent in the Sendpository-Signature header.

What happens if my webhook endpoint is down?

Each Sendpository delivery attempt is logged with its status code, and an endpoint is disabled after 15 consecutive failures. Fix it, re-enable it, and list anything you missed from the emails API by status and time.

Related

Send your first email in minutes

6,000 emails a month (up to 200 a day), free forever. No card needed.