Skip to content

The events

Nine, plus a test. Subscribe with a list of names, or ["*"] for everything including events added later.

EventWhen
mail.receivedA message arrived in a mailbox
email.sentA message was handed to SES
email.deliveredThe receiving server accepted it
email.bouncedIt came back
email.complainedSomebody marked it as spam
email.openedThe tracking image was loaded
email.delayedSES is still trying
email.rejectedSES refused to send it
thread.updatedA thread was moved, read, starred or labelled through the API

webhook.test is sent only by a ping. It is deliberately not in the subscribable list, because sending a test under a real event name would have a receiver record something that never happened.

The email.* events need a configuration set

Everything except email.sent comes from SES's own reporting. Without SES_CONFIGURATION_SET set, SES never reports, and those events never fire. See Sending domains.

email.sent fires from the app itself the moment SES accepts a message, so it arrives either way.

mail.received

json
{
  "id": "whd_…",
  "object": "event",
  "type": "mail.received",
  "created_at": "2026-09-21T09:58:02.000Z",
  "data": {
    "email": {
      "id": "msg_…",
      "thread_id": "thr_…",
      "mailbox_id": "mbx_…",
      "mailbox": "support@example.com",
      "message_id": "<CAO…@mail.gmail.com>",
      "from": { "name": "Ada", "address": "ada@example.net" },
      "to": [{ "name": null, "address": "support@example.com" }],
      "delivered_to": "support@example.com",
      "subject": "Refund for order 4821",
      "snippet": "Hi — I'd like to return…",
      "folder": "inbox",
      "has_attachments": true,
      "spf": "pass",
      "dkim": "pass",
      "dmarc": "pass",
      "spam_score": 0,
      "received_at": "2026-09-21T09:58:01.000Z"
    }
  }
}

delivered_to is the address this copy actually arrived at, which differs from mailbox when a catch-all caught it.

The body is not included — it can be megabytes. Fetch it with GET /messages/:id when you need it.

email.sent

json
{
  "type": "email.sent",
  "data": {
    "email": {
      "id": "msg_…",
      "thread_id": "thr_…",
      "mailbox": "receipts@example.com",
      "ses_message_id": "0100018f…",
      "message_id": "<…@example.com>",
      "from": "receipts@example.com",
      "to": [{ "name": null, "address": "customer@example.net" }],
      "cc": [],
      "subject": "Your receipt",
      "status": "sent",
      "api_key_id": "key_…",
      "sent_at": "2026-09-21T09:58:00.000Z"
    }
  }
}

api_key_id is set when the message came in through the API, so you can tell a script's mail from a person's.

The delivery events

email.delivered, email.bounced, email.complained, email.opened, email.delayed and email.rejected all carry the same shape:

json
{
  "type": "email.bounced",
  "data": {
    "email": {
      "id": "msg_…",
      "thread_id": "thr_…",
      "mailbox": "receipts@example.com",
      "ses_message_id": "0100018f…",
      "message_id": "<…@example.com>",
      "from": "receipts@example.com",
      "to": [{ "name": null, "address": "gone@example.net" }],
      "subject": "Your receipt",
      "status": "bounced"
    },
    "recipients": ["gone@example.net"],
    "detail": "smtp; 550 5.1.1 user unknown",
    "occurred_at": "2026-09-21T09:58:40.000Z"
  }
}

recipients is which addresses this event was about — a message to five people can bounce for one of them, and to still lists all five.

thread.updated

Fires when a thread is changed through PATCH /threads/:id — not when it changes in the web app, and not when a new message arrives.

json
{
  "type": "thread.updated",
  "data": { "thread": { "object": "thread", "id": "thr_…", "folders": ["archive"], "…": "…" } }
}

data.thread is the full thread object as it now is.

Which endpoints hear what

The endpointHears
No mailbox_idEvery event on the account
A mailbox_idOnly events for that mailbox

An event with no mailbox of its own reaches every endpoint that has no mailbox_id.

Released under the MIT Licence.