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

email.bounced
{
  "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.

TypeScript
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.