Webhooks & events
A 202 from the send endpoint means accepted, not delivered. Everything that happens afterwards reaches you here.
Register an endpoint
{
"url": "https://yourapp.com/hooks/sreemail",
"events": ["email.bounced", "email.complained"]
}An empty events array subscribes to everything. The response contains a secret, returned once, that signs every delivery to this endpoint. URLs must be HTTPS.
Event shape
{
"id": "evt_use1_01JQ…",
"type": "email.bounced",
"createdAt": "2026-07-26T10:31:02.000Z",
"data": {
"messageId": "msg_use1_01JQ…",
"to": ["user@example.com"],
"bounceType": "permanent",
"reason": "550 5.1.1 user unknown",
"tags": { "campaign": "receipts" }
}
}Event types
| Type | Meaning |
|---|---|
email.sent | Handed to the receiving mail server. |
email.delivered | Accepted by the recipient's server. |
email.bounced | Rejected. Permanent bounces suppress the address. |
email.complained | Marked as spam. Always suppresses the address. |
email.delayed | Temporary failure; delivery is still being retried. |
email.rejected | Refused before sending, usually for content. |
email.failed | Could not be sent, e.g. a template error or exhausted retries. |
email.opened | Tracking pixel loaded. Opt-in per domain. |
email.clicked | A rewritten link was followed. Opt-in per domain. |
domain.verified | A sending domain finished verification. |
domain.verification_failed | A domain was rejected or timed out. |
Note
Only permanent bounces suppress an address. A full mailbox or a greylist produces email.delayed or a transient bounce and is retried — suppressing on those would blacklist recipients for a temporary condition.
Verifying signatures
Every delivery carries sreemail-signature, formatted t={unix},v1={hex}. The value is an HMAC-SHA256 of {timestamp}.{raw body} keyed with your endpoint secret.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, header: string, secret: string) {
const parts = new Map(header.split(",").map((p) => p.split("=") as [string, string]));
const t = Number(parts.get("t"));
// Reject replays. The timestamp is inside the signed
// payload, so it cannot be swapped for a fresh one.
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest();
const given = Buffer.from(parts.get("v1")!, "hex");
return expected.length === given.length &&
timingSafeEqual(expected, given);
}Use the raw body
Verify against the exact bytes received, before any JSON parsing. Re-serialising changes key order and whitespace, and the signature will never match.
Retries
Return any 2xx to acknowledge. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours.
A 4xx other than 429 stops the ladder immediately — your endpoint understood the request and refused it. After 20 consecutive failures the endpoint is disabled and the account owner is notified.
Replaying events
Events outlive their delivery attempts. When a bad deploy takes your handler down, list what you missed and replay it rather than losing the mail.
Filter with messageId to see the full history of one message, or page through with limit.