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.
| Field | Notes |
|---|---|
email_id | The id POST /emails returned, as a string. This is what joins the event back to your own record. |
from | The envelope sender of the message. |
to | The one recipient this event is about, as a string — not the message’s full recipient list. |
subject | |
bounce_type | Permanent, Transient or Undetermined, passed through from the receiving infrastructure unchanged. Empty on events that are not bounces. |
detail | The 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.
| Field | Notes |
|---|---|
inbound_id | The received message’s id. |
from | Who sent it. |
to | Array of recipients it was addressed to. |
subject | |
in_reply_to_email_id | The 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. |
text | The body with the quoted reply and signature block already stripped — the part an application actually wants. |
authentication_results | What 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.