Skip to content
YourMail

Guides

Email delivery webhooks

POSTyour-endpoint

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

Polling 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" }]
  }
}

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.

How a webhook event reaches your endpointYour app sends an email with POST /v1/emails. YourMail sends it through Amazon SES in London. When a delivery event happens, YourMail sends a signed POST to your endpoint with a yourmail-signature header. Your endpoint must return 2xx within 10 seconds; otherwise the delivery is retried after 1, 5 and 30 minutes and then 2 hours. After 20 deliveries in a row fail every retry, the endpoint is switched off and the account owner is emailed.Your appPOST /v1/emailsYourMailsends via SES(London)Delivery eventdelivered, bounced,opened, …Your endpointsigned POSTyourmail-signature2xx within 10 s — doneotherwise retried: +1 min, +5 min,+30 min, +2 h, each signed afresh20 failed deliveries in a row → switched offVerify the signature over theraw body before acting on it,and dedupe on the event id:delivery is at least once,and not in order.
Each delivery event is POSTed to your endpoint with a yourmail-signature header. Answer 2xx within 10 seconds, or it is retried after 1, 5 and 30 minutes and then 2 hours.

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();
}

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