Guides
Email delivery webhooks
YourMail can push real-time delivery events to your server as signed HTTP POST requests. Register a webhook endpoint in the dashboard and YourMail will notify you when an email is sent, delivered, bounced, or complained about.
Why use this
GET /v1/emails/:id works for occasional status checks, but webhooks let you react to delivery outcomes as they happen without keeping a poll loop running. This is how you keep your own database in sync with what YourMail knows.For example
When alice@example.com hard-bounces, YourMail fires email.bounced at your endpoint within seconds — so your app can immediately suppress her address and avoid burning your sender reputation.
Register an endpoint
Go to Webhooks in the dashboard, click Add endpoint, and paste your HTTPS URL. YourMail will show you a signing secret — copy it into your server as YOURMAIL_WEBHOOK_SECRET. You can register multiple endpoints and choose which event types each one receives.
Test-mode sends — anything from the shared no-reply@yourmail.dev sender — fire no webhooks. If you wired up an endpoint right after a test-mode quickstart and nothing arrived, that is why: send from your own verified domain, or use Send test event on the endpoint in the dashboard.
Event types
Every event has a type field. The full taxonomy:
email.sent- YourMail accepted the email and handed it to AWS SES.
email.delivered- The receiving mail server confirmed delivery.
email.opened- The recipient opened the email (requires open tracking to be enabled). May fire more than once.
email.clicked- The recipient clicked a tracked link in the email (requires click tracking to be enabled). May fire more than once.
email.bounced- A hard bounce was returned. The recipient address is automatically suppressed.
email.complained- The recipient filed a spam complaint. The address is automatically suppressed.
email.delivery_delayed- Delivery hit a temporary failure — the receiving server deferred it, or soft-bounced. A delay can still end in delivered; a soft bounce is SES reporting it has stopped retrying, and may be the last event you get for that message (its status stays sent). Don't wait on a follow-up that may not come.
email.failed- The email could not be submitted to SES — e.g. the from address failed DKIM signing after dispatch.
quota.exceeded- A send took the account past its monthly plan limit into the grace band. Fired once per period. Account-level, so it carries no emailId — the payload carries month_count, month_limit and hard_limit instead, so your app can compute its own headroom. (The account owner is emailed separately.) Mail keeps going out until hard_limit.
Payload shape
Every event POST body is a JSON object. The data field carries the email metadata. For email.bounced and email.complained, data also includes the affected recipient and a reason string from the mail server.
// Every webhook POST body has this shape:
{
"id": "evt_01j9abc123def456", // unique event ID — dedupe on this
"type": "email.delivered", // event type
"created_at": "2026-06-30T12:00:00.000Z", // ISO 8601 timestamp
"data": {
"email_id": "jn7abc123def456", // the email this event belongs to
"from": "hello@mail.acme.com",
"to": ["alice@example.com"],
"subject": "Your receipt",
"tags": [{ "name": "category", "value": "receipt" }]
}
}// email.bounced — data carries the affected recipient and reason:
{
"id": "evt_01j9xyz789ghi012",
"type": "email.bounced",
"created_at": "2026-06-30T12:00:02.000Z",
"data": {
"email_id": "jn7abc123def456",
"from": "hello@mail.acme.com",
"to": ["alice@example.com"],
"subject": "Your receipt",
"tags": [],
"recipient": "alice@example.com", // the (first) address that bounced/complained
"reason": "hard_bounce", // "hard_bounce" | "complaint" — why it is now suppressed
"recipients": [ // every affected address — one event can name several
{ "address": "alice@example.com", "reason": "hard_bounce" }
]
}
}
// email.complained has the same shape — recipient is who filed the complaint.
// `reason` is the suppression reason, not the mailbox provider's diagnostic;
// GET /v1/emails/:id carries that on its bounce event.At-least-once · not ordered
Events are delivered at least once and are not guaranteed to arrive in order — your handler can receive email.delivered before email.sent. Dedupe on the event id. Don't sort by created_at: it is when that delivery attempt was sent, not when the event happened, so a retried event can carry a later time than the one that followed it. If order matters, re-read the message with GET /v1/emails/:id — its status is always current.
Your endpoint must return a 2xx within 10 seconds. If it doesn't, the delivery is retried up to four more times — after 1, 5 and 30 minutes, then after 2 hours — each attempt signed afresh. After 20 deliveries in a row fail every retry, we switch the endpoint off and email the account owner (unless alerts are muted). While it is off, new events for it are not queued at all, so they cannot be resent later — after turning it back on, re-read the affected messages with GET /v1/emails/:id. Attempts logged before it was switched off can still be resent from its Deliveries tab for 7 days.
Signature verification
Every request includes a yourmail-signature header in the form t=<unix>,v1=<hmac-hex>. The HMAC is SHA-256 over "<timestamp>.<rawBody>" using your webhook secret as the key. Always verify this before processing an event — it proves the request came from YourMail, not a third party.
The easiest path is the SDK helper:
import { constructWebhookEvent } from "yourmail";
// In your handler — pass the RAW request body (a string or Buffer), not a
// parsed object. Throws if the signature doesn't hold, so nothing below runs
// on a forged event.
let event;
try {
event = await constructWebhookEvent({
// Rotating? Pass [newSecret, oldSecret] until the new one is deployed —
// rotation in the dashboard is a hard cut.
secret: process.env.YOURMAIL_WEBHOOK_SECRET!,
header: req.headers["yourmail-signature"],
payload: rawBody,
});
} catch {
return res.status(400).end();
}import { createHmac, timingSafeEqual } from "node:crypto";
function verifyYourMailWebhook(
secret: string,
header: string, // the yourmail-signature header value
rawBody: string, // the un-parsed request body string
): boolean {
// Header format: t=<unix_timestamp>,v1=<hmac_hex>
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string]),
);
const timestamp = parts["t"];
const signature = parts["v1"];
if (!timestamp || !signature) return false;
// Reject anything older (or newer) than five minutes. Without this, a
// captured request replays forever — the signature never expires by itself.
// The SDK helper applies the same 300-second window.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// Use a constant-time comparison to prevent timing attacks.
return expected.length === signature.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}Important — pass the raw request body (a string or Buffer), not a parsed JSON.parse result. Body parsers in Express or Next.js may reformat whitespace, which invalidates the signature.
Full handler example
A minimal Next.js App Router route that verifies the signature and branches on event type:
// app/api/webhooks/yourmail/route.ts (Next.js App Router)
import { constructWebhookEvent, type WebhookEvent } from "yourmail";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
let event: WebhookEvent;
try {
event = await constructWebhookEvent({
secret: process.env.YOURMAIL_WEBHOOK_SECRET!,
header: req.headers.get("yourmail-signature"),
payload: await req.text(),
});
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "email.delivered":
// mark order as sent in your DB
break;
case "email.bounced":
// suppress or notify the recipient
break;
case "email.complained":
// unsubscribe the recipient
break;
}
return NextResponse.json({ received: true });
}Last updated