Webhooks
Get told when an email is delivered or bounces, instead of polling.
Setup
Add an endpoint under Webhooks in the dashboard, or with
POST /api/v1/webhooks. The URL must be public and use https.
Use Send test event to check your receiver.
Events
email.delivered: The receiving server accepted the message.email.deferred: The receiving server refused temporarily; we will retry.email.bounced: The message will not arrive: hard bounce, async bounce (DSN) or retries expired.email.complained: The recipient reported the message as spam (a mailbox provider's feedback loop).recipient.suppressed: A hard bounce or a spam complaint put the recipient on your suppression list.email.received: A message arrived for one of your receiving domains. Fetch its content from the API.inbound.limited: Receiving hit the rate or monthly limit; senders are told to retry. Sent once per domain and hour.
email.bounced carries bounce_type:
hard (5xx during delivery), async
(a bounce message that came back later) or expired (retries ran out).
Sandbox mail fires events too, with "sandbox": true.
email.complained fires once per message when a mailbox provider's feedback loop
reports that the recipient marked it as spam. It carries feedback_type
(abuse in nearly every case) and, when the report names them, the reporting
user_agent and arrival_date; the recipient is suppressed
at the same time. Gmail reports complaints only in aggregate, so mail to Gmail never fires it.
Payload
{
"id": "msg_01926f3b8c2e7a1b9d4e5f6a7b8c9d0e",
"type": "email.bounced",
"timestamp": "2026-09-25T18:30:00.000Z",
"data": {
"email_id": 4821,
"message_id": "0192...@example.com",
"from": "App <hello@example.com>",
"subject": "Your receipt",
"recipient": "gone@example.org",
"sandbox": false,
"timestamp": "2026-09-25T18:29:59.412Z",
"bounce_type": "hard",
"response": {
"code": 550,
"enhanced_code": "5.1.1",
"classification": "InvalidRecipient",
"message": "User unknown"
}
}
}
Verifying signatures
Requests follow the Standard Webhooks spec, so any of
its libraries can verify them. The webhook-signature header is
v1, + base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body},
keyed with the base64-decoded part of your whsec_ secret.
Reject timestamps older than five minutes.
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.EML_WEBHOOK_SECRET);
// Throws if the signature or timestamp is invalid.
const event = wh.verify(rawBody, Object.fromEntries(request.headers));
Retries
Answer with any 2xx within 10 seconds. Anything else is retried 5 seconds, 5 minutes, 30 minutes,
2 hours, 5 hours, 10 hours and 10 hours after the failure before it, about 28 hours in all.
Delivery is at least once, so dedupe on webhook-id. The dashboard lists
each endpoint's recent deliveries and their responses, and Replay sends a past event
again with the same webhook-id.