After the send
Webhooks
Delivery is a later fact than acceptance. This is how you learn it.
Creating an endpoint
POST /v1/webhooks with a name, an HTTPS URL and the events you want. Subscribe to * for everything. The signing secret is returned once, on creation — store it then, because it cannot be read back.
The URL must resolve to the public internet. We check at creation and again at delivery, and refuse anything pointing at a private network.
What arrives
A delivery eventPOST /your/endpoint
webhook-id: 9c3b1f8e-4d21-4a77-9f0e-2b6c5d4a3e10
webhook-timestamp: 1788412862
webhook-signature: v1=6f2a…
{
"id": "9c3b1f8e-4d21-4a77-9f0e-2b6c5d4a3e10",
"type": "delivered",
"created_at": "2026-09-03T09:41:02.118Z",
"data": {
"email_id": "0f9f8e7d-6c5b-4a39-8271-1a2b3c4d5e6f",
"delivery_id": "1a2b3c4d-5e6f-4a39-8271-0f9f8e7d6c5b",
"recipient": "you@example.com"
}
}Verifying it came from us
The signature is an HMAC-SHA256 over {id}.{timestamp}.{body}, hex-encoded and prefixed v1=, keyed with your endpoint secret. Verify against the raw body — parse it after, never before, because re-serializing changes the bytes.
Nodeimport { createHmac, timingSafeEqual } from "node:crypto";
export function verify(request, rawBody, secret) {
const id = request.headers["webhook-id"];
const timestamp = Number(request.headers["webhook-timestamp"]);
const signature = request.headers["webhook-signature"];
// Reject anything older than five minutes: a valid signature is still a replay.
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false;
const expected = "v1=" + createHmac("sha256", secret)
.update(`${id}.${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}Events
| Field | Type | Notes |
|---|---|---|
submitted | email | Handed to the sending infrastructure. |
delivered | email | Accepted by the receiving server. |
delivery_delayed | email | Deferred; we are still trying. |
bounced | email | Refused. A permanent bounce also adds a suppression. |
complained | email | Marked as spam by the recipient. Also suppressed. |
rejected | email | We refused to send it. |
rendering_failed | email | The template could not be rendered with those variables. |
opened | email | Open tracking fired. |
clicked | email | A tracked link was followed. |
sms.sent | sms | Handed to the carrier. |
sms.delivered | sms | Confirmed by the carrier. |
sms.failed | sms | The carrier refused or gave up. |
sms.received | sms | An inbound message arrived on one of your numbers. |
Delivery behaviour
- Each event is delivered once per endpoint, keyed by its id — but design for a repeat anyway and key on
id. - We wait ten seconds for a response. Anything outside
2xxis a failure and is retried. - Redirects are not followed. Point us at the final URL.
- An endpoint that keeps failing is disabled, and the dashboard says so.