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.
Swipe the diagram to see the rest
What an event looks like
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.
| Event | When | Typical action |
|---|---|---|
email.delivered | The recipient's server accepted it | Mark as delivered |
email.delayed | A temporary failure; being retried | Usually nothing |
email.bounced | Permanent failure; address suppressed | Flag the address on the user |
email.complained | Marked as spam; address suppressed | Flag the user; review what was sent |
email.failed | Couldn't be sent at all | Alert; check the logs |
email.opened / email.clicked | Tracking, when enabled | Analytics |
email.received | Mail arrived at a receiving address | Create 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.
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:
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.