Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Webhook events

Configure endpoints in the dashboard. Every delivery carries:

webhook-signature: <hex> webhook-timestamp: <unix seconds> webhook-event: email.bounced

Verify with HMAC-SHA256(secret, timestamp + "." + rawBody), compared against webhook-signature. Use the raw body, before any JSON parsing — re-serialising changes the bytes and the signature will not match.

The timestamp is inside the signed material, so reject anything older than a few minutes to stop a captured request being replayed.

import crypto from 'node:crypto' // Express: app.post('/hooks', express.raw({ type: 'application/json' }), …) function verify(req, secret) { const ts = req.header('webhook-timestamp') const expected = crypto .createHmac('sha256', secret) .update(`${ts}.${req.body}`) // req.body is a Buffer, not parsed JSON .digest('hex') const sig = Buffer.from(req.header('webhook-signature'), 'hex') const ok = crypto.timingSafeEqual(Buffer.from(expected, 'hex'), sig) return ok && Math.abs(Date.now() / 1000 - Number(ts)) < 300 }
import hashlib, hmac, time def verify(headers, raw_body: bytes, secret: str) -> bool: ts = headers["webhook-timestamp"] expected = hmac.new( secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256 ).hexdigest() fresh = abs(time.time() - int(ts)) < 300 return hmac.compare_digest(expected, headers["webhook-signature"]) and fresh

Event types: email.sent, email.delivered, email.bounced, email.complained, email.delivery_delayed, email.opened, email.clicked, email.failed, email.refused, email.received.

The payload

{ "id": "evt_7Qk2...", "type": "email.bounced", "created_at": 1730000000, "data": { … } }

data has two shapes, and which one you get is decided by type.

Everything except email.received

Mail you sent. One event per RECIPIENT, because a message to five addresses can be delivered to four of them and bounce on the fifth, and one event carrying “partly bounced” would be useless.

FieldNotes
email_idThe id POST /emails returned, as a string. This is what joins the event back to your own record.
fromThe envelope sender of the message.
toThe one recipient this event is about, as a string — not the message’s full recipient list.
subject
bounce_typePermanent, Transient or Undetermined, passed through from the receiving infrastructure unchanged. Empty on events that are not bounces.
detailThe receiving server’s own diagnostic text, e.g. 550 5.1.1 user unknown. Prose from somebody else’s mail server: read it, do not parse it. Empty where there is none.
{ "id": "evt_7Qk2LmR4", "type": "email.bounced", "created_at": 1735689640, "data": { "email_id": "1042", "from": "hello@yourdomain.com", "to": "someone@example.com", "subject": "Your receipt", "bounce_type": "Permanent", "detail": "550 5.1.1 user unknown" } }

A email.refused event carries the same shape, with detail naming why we declined to send to that address — a suppression, an unsubscribe, an opt-out. It is the one event that means a message did not go, so it is the one worth handling even if you ignore the rest.

email.received

Mail that arrived at a domain you have inbound switched on for. A different shape, because it is a different thing: there is no message of yours to join to.

FieldNotes
inbound_idThe received message’s id.
fromWho sent it.
toArray of recipients it was addressed to.
subject
in_reply_to_email_idThe id of YOUR message this is a reply to, or absent. The point of the whole feature: you get the thread without parsing a subject line.
textThe body with the quoted reply and signature block already stripped — the part an application actually wants.
authentication_resultsWhat we determined about the sender, in Authentication-Results form: mx.postrun.dev; spf=pass smtp.mailfrom=acme.test; dkim=not-evaluated; dmarc=not-evaluated. Only SPF is checked today — DKIM and DMARC read not-evaluated, never none, so you can tell “we did not look” from “there was nothing to find”.
{ "id": "evt_9Wz1Ppq7", "type": "email.received", "created_at": 1735690000, "data": { "inbound_id": "88", "from": "someone@example.com", "to": ["support@yourdomain.com"], "subject": "Re: Your receipt", "in_reply_to_email_id": "1042", "text": "Thanks, got it.", "authentication_results": "mx.postrun.dev; spf=pass smtp.mailfrom=example.com; dkim=not-evaluated; dmarc=not-evaluated" } }

authentication_results is reporting, not a verdict. We do not refuse mail on it and neither should you without thinking: SPF fail is routine on forwarded mail, and what it is worth is a judgement only the receiving application can make.

Your endpoint has 10 seconds to answer; anything slower is a failed attempt. Failed deliveries are retried with backoff — ten attempts over about five hours — and the attempt log is in the dashboard, so “you never sent it” is a question either of us can answer.

Delivery is at-least-once, so dedupe on id. Every payload carries one:

{ "id": "evt_7Qk2...", "type": "email.delivered", "created_at": 1730000000, "data": { … } }

A receiver that commits the event and then fails to answer — a timeout after the write, a proxy dropping the response — looks exactly like one that never received it, so we retry and you see the event twice. Every retry of the same event carries the same id, so recording it and ignoring a repeat is all the protection you need.

Do not dedupe on anything else. webhook-timestamp is re-signed on each attempt, so it differs between retries of one event; and hashing the body collides, because two opens of the same message in the same second are byte-identical apart from the id.