Skip to content

Events and payloads

The vocabulary SESKit normalises Amazon SES's notifications into, and the shape it forwards to your webhooks.

Event types

email.sent   email.delivered   email.bounced      email.suppressed
email.opened email.clicked     email.complained   email.received
Type Emitted when
email.sent A provider accepted the message
email.delivered The receiving server accepted it
email.bounced It could not be delivered
email.complained The recipient marked it as spam
email.opened The tracking pixel loaded — only with tracking on
email.clicked A rewritten link was followed — tracking only
email.suppressed SESKit added an address to the suppression list
email.received Mail arrived for one of your domains — see below

SES emits more types than these — Reject, Rendering Failure, DeliveryDelay — which SESKit records but does not currently forward.

Payload

{
  "id": "evt_01J8XQ...",
  "type": "email.bounced",
  "email_id": "email_01J8XQ...",
  "created_at": "2026-09-02T09:00:05+00:00",
  "data": {
    "to": ["user@example.com"],
    "bounce_type": "Permanent",
    "diagnostic": "smtp; 550 5.1.1 user unknown"
  }
}
Field
id Unique per event. Deduplicate on this — delivery is at-least-once
type One of the eight above
email_id The message this is about; matches GET /v1/emails/{id}. Absent on email.received, which carries inbound_id instead
created_at When the event happened, not when SESKit heard about it
data Type-dependent. Always includes to

Bounce types

The distinction that matters for your sender reputation:

bounce_type Means Do
Permanent The address does not exist, or refused permanently Stop sending to it. These are what push your bounce rate up
Transient A temporary failure — full mailbox, server down Safe to retry later
Undetermined The receiving server was unclear Treat as transient, watch for repeats

SESKit acts on this itself: a Permanent bounce puts the address on the project's suppression list and later sends to it are refused. Transient and Undetermined are left alone, because the address usually works again tomorrow.

email.suppressed

Raised by SESKit rather than by a provider, when an address is added to the list.

{
  "id": "evt_01J8XQ...",
  "type": "email.suppressed",
  "email_id": "email_01J8XQ...",
  "created_at": "2026-09-02T09:00:05+00:00",
  "data": {
    "to": ["user@example.com"],
    "reason": "bounce",
    "caused_by": "evt_01J8XP..."
  }
}
Field
reason bounce, complaint or unsubscribe
caused_by The event that led to it, or null — an unsubscribe is the recipient telling SESKit directly

One event per cause, not per address: a bounce naming three dead mailboxes is one thing that happened, so to is a list.

created_at is when SESKit suppressed the address, not when the bounce occurred. A notification that sat in a queue for an hour did not suppress anything an hour ago.

An address already on the list produces no second event, so a repeat bounce is not something your application has to deduplicate.

email.received

Raised when mail arrives for a domain you have receiving turned on for, and readable through the /v1/inbound endpoints. It is about a message somebody sent to you, so it has no email_id: it carries inbound_id, which matches GET /v1/inbound/{id}.

{
  "id": "evt_01J8XQ...",
  "type": "email.received",
  "inbound_id": "inbound_01J8XQ...",
  "created_at": "2026-10-05T10:00:00+00:00",
  "data": {
    "domain": "example.com",
    "from": "ada@other.org",
    "from_name": "Ada Lovelace",
    "to": ["support@example.com"],
    "cc": [],
    "recipients": ["support@example.com"],
    "subject": "Hello there",
    "received_at": "2026-10-05T10:00:00+00:00",
    "size": 4821,
    "attachment_count": 1,
    "verdicts": {
      "spf": "PASS",
      "dkim": "GRAY",
      "dmarc": "PASS",
      "spam": "PASS",
      "virus": null
    },
    "truncated": false,
    "parse_failed": false
  }
}

It describes the message and does not carry it. There is no body, no headers and no attachment in the payload, however large the mail is. Fetch the message with GET /v1/inbound/{inbound_id} — that is the call that returns the bodies, the headers and the attachments.

Field
inbound_id The received message. Use it to fetch the rest
from The address in the From header. Anybody can write anything there — see verdicts
to The To header
recipients Who the message was addressed to on the wire — the address that actually received it. A blind copy is in this list and in neither to nor cc
verdicts What Amazon SES concluded: PASS, FAIL, GRAY or PROCESSING_FAILED. SESKit records them and acts on none
truncated A limit was hit while reading the message, so something is missing from the stored copy
parse_failed Nothing could be read. The message still exists and GET /v1/inbound/{id}/raw has the original

A verdict that is null means Amazon SES did not say. It is not a pass. Filter on spam or failed authentication only after checking for it, or an unchecked message will read as a clean one.

created_at is when the message was received, not when SESKit processed it.

Delivery is at-least-once, as for every other event, so deduplicate on id. A message that is announced twice produces one event.

Every active endpoint receives it, as with every other type. If yours checks type against a fixed list, add email.received before you turn receiving on. Nothing is sent until you do.

Timestamps

created_at is when the event occurred at the provider, not when SESKit ingested it. A queue backlog that delivers a bounce an hour late still reports the hour it happened, which is why the metrics filter on it.