Platform
Webhooks
Signed, idempotent events for the full email lifecycle and inbound replies.
Register an endpoint and rootmail POSTs a signed JSON event whenever something happens to your mail. Deliveries are retried with backoff and each endpoint has a delivery log you can inspect and replay.
Manage endpoints
GET
/v1/webhook-endpointsPOST
/v1/webhook-endpointsGET
/v1/webhook-endpoints/:idPATCH
/v1/webhook-endpoints/:idGET
/v1/webhook-endpoints/:id/deliveriesDELETE
/v1/webhook-endpoints/:idEvents
message.sentevent | Accepted and handed to the provider. |
message.deliveredevent | Confirmed delivered to the recipient's server. |
message.openedevent | The recipient opened it. |
message.clickedevent | The recipient clicked a tracked link. |
message.bouncedevent | Hard or soft bounce — the reason is included. |
message.complainedevent | Marked as spam by the recipient. |
message.failedevent | Could not be sent. |
message.suppressedevent | Skipped because the address was on the suppression list. |
message.receivedevent | An inbound reply arrived (threaded). |
Payloads
Every event is the same envelope — the event name, when it occurred_at, and a data object carrying the message id. Fetch GET /v1/messages/:id (or its /audit) for the full record, including a bounce's reason.
message.delivered
{
"event": "message.delivered",
"occurred_at": "2026-07-22T10:00:05Z",
"data": {
"id": "msg_1a2b3c4d5e",
"event": "message.delivered",
"occurred_at": "2026-07-22T10:00:05Z"
}
}message.opened
{
"event": "message.opened",
"occurred_at": "2026-07-22T10:14:00Z",
"data": { "id": "msg_1a2b3c4d5e", "event": "message.opened", "occurred_at": "2026-07-22T10:14:00Z" }
}message.clicked
{
"event": "message.clicked",
"occurred_at": "2026-07-22T10:14:03Z",
"data": { "id": "msg_1a2b3c4d5e", "event": "message.clicked", "occurred_at": "2026-07-22T10:14:03Z" }
}message.bounced
{
"event": "message.bounced",
"occurred_at": "2026-07-22T10:00:06Z",
"data": { "id": "msg_1a2b3c4d5e", "event": "message.bounced", "occurred_at": "2026-07-22T10:00:06Z" }
}message.complained
{
"event": "message.complained",
"occurred_at": "2026-07-23T08:00:00Z",
"data": { "id": "msg_1a2b3c4d5e", "event": "message.complained", "occurred_at": "2026-07-23T08:00:00Z" }
}message.received (inbound reply)
{
"event": "message.received",
"occurred_at": "2026-07-22T11:00:00Z",
"data": { "id": "msg_reply_9z8y7x", "event": "message.received", "occurred_at": "2026-07-22T11:00:00Z" }
}noteThe remaining lifecycle events — message.sent, message.failed, message.suppressed — use the identical envelope with their own event name.
Verify the signature
Every delivery carries a Rootmail-Signature header: a timestamp and an HMAC-SHA256 of `${timestamp}.${rawBody}` keyed with your endpoint's signing secret. Verify it before trusting a payload, and reject stale timestamps to stop replays.
verify-webhook.ts
import crypto from "node:crypto";
// header: "Rootmail-Signature: t=1718531200,v1=<hex>"
export function verify(raw: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${raw}`)
.digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; // 5 min
return ok && fresh;
}CarefulVerify against the RAW request body, before any JSON parsing or middleware reformats it — re-serialized JSON won't match the signature.
noteAn endpoint that fails 10 deliveries in a row is auto-disabled; re-enable it from the dashboard once your receiver is healthy.