> ## Documentation Index
> Fetch the complete documentation index at: https://developers.telnyx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Email Event Lifecycle

> Understand the full lifecycle of an email message — from send to delivery to bounce — and how each stage maps to Telnyx webhook events, the Events API, and the Email Detail Record (EDR).

When you `POST /v2/email_messages`, Telnyx returns `202 Accepted` — not "delivered". Everything that matters after that point happens asynchronously, over seconds to days, and is reported to you as a stream of events.

This page explains the **conceptual model** behind that stream: what states a message passes through, what causes each transition, which event fires, and what gets recorded. It is a companion to the [Webhooks & Events how-to guide](/docs/messaging/email/webhooks-events), which covers the mechanics of subscribing, verifying signatures, and polling. Read that one to wire things up; read this one to understand what you're seeing.

<Callout type="info">
  **The single most important idea on this page:** delivery state is tracked **per recipient**, not per message. A message to three recipients is three independent lifecycles that can end in three different states. Every webhook event you receive describes exactly one recipient.
</Callout>

***

## The two-layer model

Telnyx tracks an outbound email at two levels, and conflating them is the most common source of confusion.

| Layer                                     | What it tracks                                               | Values written by the outbound path                                                                                      |
| ----------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| **Message** (`email_messages.status`)     | Request lifecycle — did we accept and process your API call? | `queued`, `scheduled`, `sandbox`, `sending`, `completed`, `cancelled`, `failed`                                          |
| **Recipient** (`email_recipients.status`) | Delivery outcome — what happened to this one address?        | `queued`, `sent`, `gw_reject`, `deferred`, `delivered`, `bounced`, `failed`, `expired`, `cancelled`, `injection_timeout` |

The message layer answers "did Telnyx take the job?". The recipient layer answers "did the mail arrive?". A message reaches `completed` when every one of its recipients has reached a terminal state — `completed` means *finished*, not *delivered*.

The message layer moves to `failed` only on a pre-dispatch rejection that stops the whole request, such as a scheduled send that exceeds your daily send limit at fire time. Transport outcomes — `delivered`, `bounced`, `deferred` — are **never** written onto the message row.

<Callout type="warning">
  Treat the message-level values as a description of current behavior, not a frozen enum. The authoritative delivery state is always the recipient row, and that is what every delivery webhook and every Email Detail Record describes. Build your logic on the recipient layer.
</Callout>

The recipient status `sending` exists in the schema and the transition table, but the current outbound path never writes it — recipients go from `queued` straight to an injection outcome. Don't build branching that waits to observe it.

***

## The lifecycle

An email is handed to **KumoMTA**, the outbound mail transfer agent. Telnyx injects the message over HTTP, and KumoMTA reports back asynchronously with log records — one per event, per recipient. Those records drive the state machine.

### Per-recipient states

```mermaid theme={null}
stateDiagram-v2
    [*] --> queued: API accepts (202)
    queued --> sent: injection accepted / Reception
    queued --> gw_reject: injection refused
    queued --> injection_timeout: ambiguous timeout
    queued --> cancelled: cancellation
    injection_timeout --> sent: late Reception callback
    sent --> delivered: Delivery
    sent --> deferred: TransientFailure
    sent --> bounced: Bounce / OOB
    sent --> failed: AdminBounce
    sent --> expired: Expiration
    deferred --> delivered: retry succeeds
    deferred --> bounced: Bounce / OOB
    deferred --> failed: AdminBounce
    deferred --> expired: Expiration
    delivered --> bounced: OOB (async correction)
    queued --> failed: system failure
    sent --> failed: system failure
    deferred --> failed: system failure
    delivered --> [*]
    bounced --> [*]
    failed --> [*]
    expired --> [*]
    gw_reject --> [*]
    cancelled --> [*]
    injection_timeout --> [*]
```

Callbacks are not ordered, so `queued` also accepts a `Delivery`, `TransientFailure`, `Bounce`, `OOB`, `AdminBounce`, or `Expiration` that arrives before the injection outcome is recorded — going straight to `delivered`, `deferred`, `bounced`, `failed`, or `expired` respectively.

Terminal states are `delivered`, `bounced`, `failed`, `expired`, `gw_reject`, `cancelled`, and `injection_timeout`. Once a recipient is terminal it does not regress — a late `TransientFailure` arriving after a `delivered` is logged for diagnostics and ignored, never applied.

There is exactly **one deliberate exception**: `injection_timeout → sent`. See [Ambiguous injection](#ambiguous-injection-the-honest-unknown) below.

### The message-level track

The parent message runs on its own short track, and it is not part of the per-recipient state machine above:

```mermaid theme={null}
stateDiagram-v2
    [*] --> queued: API accepts (202)
    [*] --> scheduled: accepted with future send_at
    [*] --> sandbox: accepted in sandbox mode
    scheduled --> queued: scheduled job fires
    scheduled --> failed: daily send limit exceeded
    scheduled --> cancelled: cancelled before send
    queued --> sending: consumer picks the message up for injection
    sending --> completed: every recipient terminal
    queued --> completed: every recipient terminal
    queued --> cancelled: cancellation
```

When the consumer picks the message up it sets the **message** to `sending` and records a message-scoped `sending` event. That event is stored but deliberately does **not** publish a webhook — the per-recipient webhooks are published separately once the MTA responds. Recipient rows stay `queued` throughout; they only move once injection is accepted, refused, times out, or a callback arrives.

### What each recipient state means

<AccordionGroup>
  <Accordion title="queued — accepted, not yet handed to the MTA">
    The API validated your request, persisted the message, and returned `202`. Nothing has been transmitted yet.

    **Fires:** `email.queued` (also `email.scheduled` for a future `send_at`, or `email.sandbox` in sandbox mode — neither attempts delivery).

    **Not yet billable.** Acceptance by our API is not acceptance by the MTA.
  </Accordion>

  <Accordion title="sent — KumoMTA accepted the recipient into its queue">
    The message is now the MTA's responsibility. It will attempt delivery to the recipient's MX, retrying on transient failures.

    **Fires:** `email.sent` on a successful HTTP injection. A `Reception` callback also lands the recipient here but does not publish a duplicate event.

    **Billable from this point** — queue acceptance is the billing trigger.

    `sent` does **not** mean the recipient received it. Remote acceptance is reported separately as `email.delivered`.
  </Accordion>

  <Accordion title="delivered — the receiving server accepted the message">
    KumoMTA got a `250 OK` from the recipient's MX. This is the successful terminal state.

    **Fires:** `email.delivered`. **EDR records:** `delivered_at`.

    Delivered means *accepted by the receiving server* — it does not guarantee inbox placement. The mail can still be filed as spam by the recipient's provider after acceptance, which is invisible to SMTP. See [Deliverability](/docs/messaging/email/deliverability).
  </Accordion>

  <Accordion title="deferred — temporary failure, will retry">
    The receiving server returned a 4xx transient response (greylisting, rate limiting, "try again later"). KumoMTA backs off and retries.

    **Fires:** `email.deferred`. **EDR records:** `deferred_at`, plus `error_evidence` carrying code `30002`.

    A recipient can be deferred many times before resolving. `deferred_at` is stamped on the **first** deferral and locked — it is not a "most recent attempt" field. Repeated deferrals for the same address escalate an internal soft-bounce counter, which can eventually auto-suppress the address as a hard bounce.
  </Accordion>

  <Accordion title="bounced — permanent rejection by the receiver">
    The receiving server refused the message outright (5xx), or an asynchronous bounce report arrived after the fact.

    **Fires:** `email.bounced`. **EDR records:** `bounced_at` and `error_evidence` (code `30001`, or `30002` when KumoMTA reports a bounce carrying a 4xx code).

    Bounces with a `5.1.x` or `5.2.x` enhanced code (bad or disabled mailbox) auto-suppress the address. Bounces with `5.7.x` (sender policy/authentication) do **not** — the address may be perfectly valid, and the problem is on your side.
  </Accordion>

  <Accordion title="expired — KumoMTA exhausted its retry window">
    The MTA retried until its queue expiry deadline and gave up. Nothing about the recipient address was rejected; the remote server simply never accepted the message in time.

    **Fires:** `email.bounced` — the webhook event type for the `Expiration` callback is still the canonical bounce type. **EDR records:** `expired_at`, `expired: true`, and `error_evidence` with code `30005` (Queue expiry, source `mta`).

    **Billable** — the message was accepted into the MTA queue, which is the billing trigger.

    Expiration is provisionally treated as a hard bounce for suppression purposes.
  </Accordion>

  <Accordion title="failed — non-delivery caused by us or an operator">
    Distinct from `bounced` and `expired`: nothing about the recipient address was rejected and the MTA did not run out of retries. Either an internal/system failure occurred (e.g. the message could not be published to the send pipeline, or the account became ineligible before dispatch), or an operator cancelled the in-flight message (`AdminBounce`).

    **Fires:** `email.bounced` on the `AdminBounce` callback path. System failures insert a stored `failed` event but do **not** publish an `email.failed` webhook — see [System failures](#system-failures-stored-but-not-pushed).

    **EDR records:** `failed_at` and `error_evidence` (code `30003` when an SMTP status is present, otherwise `30099`).

    Because `failed` is not a recipient-side signal, it does not suppress the address.
  </Accordion>

  <Accordion title="gw_reject — KumoMTA refused the injection">
    The MTA rejected the message at the HTTP injection boundary, before it ever entered a queue. This is pre-queue, so it is **never billable**.

    **Fires:** `email.failed`. **EDR records:** `error_evidence` and `errors[]` with code `30006` (Gateway rejection, source `api`).
  </Accordion>

  <Accordion title="injection_timeout — we genuinely do not know">
    The injection HTTP call timed out with no response. KumoMTA may or may not have accepted the message.

    **Fires:** `email.injection_timeout`. **Not billable.** No EDR is published — the outcome is ambiguous, and a later `Reception` callback would publish one on reconciliation.

    See [Ambiguous injection](#ambiguous-injection-the-honest-unknown).
  </Accordion>

  <Accordion title="cancelled — withdrawn before it was queued">
    A scheduled or queued message was cancelled before injection. Only `queued` and `sending` recipients can be cancelled; anything already accepted by the MTA is past the point of recall and is left untouched. **Never billable.**
  </Accordion>
</AccordionGroup>

***

## How MTA callbacks map to events

KumoMTA emits typed log records. Each record type maps to exactly one canonical event type, and — separately — drives the recipient state machine.

| KumoMTA record     | Webhook published      | Recipient status | Stored event type | Internal `bounce_category` |
| ------------------ | ---------------------- | ---------------- | ----------------- | -------------------------- |
| `Reception`        | — (no duplicate event) | `sent`           | `queued`          | —                          |
| `Delivery`         | `email.delivered`      | `delivered`      | `delivered`       | —                          |
| `TransientFailure` | `email.deferred`       | `deferred`       | `deferred`        | —                          |
| `Bounce`           | `email.bounced`        | `bounced`        | `bounced`         | `permanent`                |
| `OOB`              | `email.bounced`        | `bounced`        | `bounced`         | `oob`                      |
| `Expiration`       | `email.bounced`        | **`expired`**    | `failed`          | `transient`                |
| `AdminBounce`      | `email.bounced`        | `failed`         | `failed`          | `admin`                    |
| `Feedback`         | `email.complained`     | *(no change)*    | `complained`      | —                          |

<Callout type="warning">
  **`bounce_category` is an internal classification and is not part of any public contract.** It is computed during callback handling and drives auto-suppression, but it is not forwarded into the published webhook, and it is never written into the recipient-scoped event row that `GET /v2/email_events` returns — that row's payload is built from correlation fields plus the recipient address only. Do not build consumer logic that reads it from either surface — use `error_evidence.code` to distinguish failure modes.
</Callout>

<Callout type="warning">
  **`Expiration` diverges three ways, and you need all three to reason about it.**

  * The **webhook event type** is `email.bounced` — the canonical bounce family type.
  * The **recipient status** (and therefore the EDR `status`) is `expired`, with `expired_at` and error code `30005`.
  * The **stored event type** returned by `GET /v2/email_events` is `failed` — the polling taxonomy has no `expired` member.

  Only `AdminBounce` maps the recipient to `failed`. `Bounce` and `OOB` map to `bounced`. If you are building suppression or list-hygiene logic, treat `permanent` and `oob` as recipient signals; treat `transient` and `admin` as operational signals about your sending, not about the address.
</Callout>

<Callout type="info">
  The `status` field inside a webhook payload is the **event type**, not the recipient status. An expired recipient produces a payload with `"status": "bounced"` while its EDR reads `"status": "expired"`. Read `error_evidence.code` to tell the failure modes apart: `30001` is a hard bounce, `30005` is queue expiry, `30003`/`30099` are system failures, `30006` is a gateway rejection.
</Callout>

### Reception callbacks

`email.queued` fires from two different places, meaning two different things:

1. **API acceptance** — we persisted your request. The recipient stays `queued`. Pre-queue, not billable.
2. **KumoMTA `Reception`** — the MTA logged reception of the message. This moves the recipient to `sent` and **is** billable. No duplicate `email.queued` event is published — the API-level event already recorded acceptance.

<Callout type="warning">
  **Count sends using `email.sent` webhooks.** A `Reception` callback updates the recipient to `sent` and billable, but does not publish a duplicate `email.queued` event — the API-level `email.queued` event already recorded acceptance. Counting `email.sent` is sufficient.

  Count from the recipient EDR instead — `billable: true`, or a non-null `sent_at` — or use `GET /v2/email_events/stats`, which aggregates at the recipient level. If you derive counts from the raw event stream, count `email.sent` events — each represents one accepted recipient.
</Callout>

### System failures: stored, but not pushed

When the message cannot be handed to the send pipeline at all — a publish failure into the send queue after the request has already been committed, or an account that became ineligible before a scheduled send fires — every still-non-terminal recipient is driven to `failed` and a **stored** `failed` event is written for each one.

That path does not publish an `email.failed` webhook, **and it does not publish an EDR either** — `mark_system_failure` updates the recipient rows and inserts stored events directly, without invoking the EDR publisher. For a failure before queue acceptance there may be no EDR at all; for a recipient already accepted, the existing EDR is **not** updated to reflect the failure.

If you rely exclusively on webhooks, these failures are invisible. Reconcile with `GET /v2/email_events` or the authoritative per-recipient status endpoint — **not** the EDR. The `email.failed` webhook is published for a *definite injection refusal* by the MTA (recipient status `gw_reject`, error code `30006`).

System failures also do not blanket-reset billability. The transition is permitted from `queued`, `sending`, `sent`, and `deferred`; a recipient that had already been accepted into the MTA queue (`sent` or `deferred`) keeps its `billable: true`, because the message really was transmitted. Only pre-queue recipients (`queued`, `sending`) end up non-billable.

### Complaints are additive, not a state change

`email.complained` fires when a mailbox provider forwards an ARF spam report (the recipient hit "mark as spam"). By definition this arrives *after* delivery. It does not move the recipient out of `delivered` or `bounced` — the delivery already happened, and that fact is not retracted. The complaint is recorded alongside it and auto-suppresses the address. Because it is a no-op for the state machine, it does not produce a new EDR.

### Ambiguous injection: the honest unknown

If the injection call times out, retrying risks delivering the message twice; not retrying risks losing it. Telnyx chooses **loss over duplication** — the industry-standard trade-off for transactional mail. The recipient moves to the terminal `injection_timeout` state and `email.injection_timeout` fires.

If KumoMTA *did* accept the message, its `Reception` callback arrives moments later and reconciles the recipient forward to `sent`, from where the normal lifecycle resumes. This is the only terminal-to-non-terminal edge in the entire state machine, and it is safe precisely because the callback is authoritative proof of acceptance.

<Callout type="info">
  `email.injection_timeout` is a **webhook-only** event type. It is not part of the stored event taxonomy returned by `GET /v2/email_events`, and the underlying recipient transition is recorded as `failed` in the persisted event row. If you rely on polling rather than webhooks, ambiguous injections surface as failures.
</Callout>

***

## Event reference

| Event                     | Trigger                                         | Recipient status    | EDR timestamp                  | Billable                            |
| ------------------------- | ----------------------------------------------- | ------------------- | ------------------------------ | ----------------------------------- |
| `email.queued`            | API accepted the request                        | `queued` → `sent`   | `created_at`                   | No (API acceptance is not billable) |
| `email.scheduled`         | Accepted with a future `send_at`                | `queued`            | `scheduled_at`                 | No                                  |
| `email.sandbox`           | Accepted in sandbox mode; no delivery attempted | `queued`            | `created_at`                   | No (`billable` forced false)        |
| `email.sent`              | KumoMTA accepted the HTTP injection             | `sent`              | `sent_at`                      | **Yes**                             |
| `email.delivered`         | KumoMTA `Delivery` (250 from remote MX)         | `delivered`         | `delivered_at`                 | Yes                                 |
| `email.deferred`          | KumoMTA `TransientFailure` (4xx)                | `deferred`          | `deferred_at` (first deferral) | Yes                                 |
| `email.bounced`           | KumoMTA `Bounce` / `OOB`                        | `bounced`           | `bounced_at`                   | Yes                                 |
| `email.bounced`           | KumoMTA `Expiration`                            | **`expired`**       | `expired_at`                   | Yes                                 |
| `email.bounced`           | KumoMTA `AdminBounce`                           | `failed`            | `failed_at`                    | Yes                                 |
| `email.complained`        | KumoMTA `Feedback` (ARF report)                 | unchanged           | —                              | Unchanged                           |
| `email.failed`            | KumoMTA refused the injection                   | `gw_reject`         | —                              | No                                  |
| `email.injection_timeout` | Ambiguous transport timeout on injection        | `injection_timeout` | —                              | No                                  |
| `email.opened`            | Tracking pixel loaded                           | unchanged           | —                              | No                                  |
| `email.clicked`           | Tracked link followed                           | unchanged           | —                              | No                                  |
| `email.unsubscribed`      | Tracked unsubscribe link used                   | unchanged           | —                              | No                                  |

A pre-dispatch system failure produces a stored `failed` event with **no** corresponding webhook; its billability depends on whether queue acceptance had already occurred. See [System failures](#system-failures-stored-but-not-pushed).

<Callout type="info">
  **Billing is derived from the source event, never from the resulting status.** A recipient is billable the moment it is proven to have been accepted into the MTA queue — by `Reception`, `Delivery`, `TransientFailure`, `Bounce`, `AdminBounce`, `OOB`, `Expiration`, or a successful injection.

  This is why a **bounce is still billable**: the message was transmitted; the receiver rejected it. And why `gw_reject`, `cancelled`, `injection_timeout`, and pre-queue system failures are **never** billable — nothing was ever transmitted. A message that fails on our side before reaching the queue costs you nothing.
</Callout>

### Bounce categories

Bounce-family callbacks are classified with a `bounce_category`:

| Category    | Meaning                                                                                | Recipient status | Auto-suppresses?          |
| ----------- | -------------------------------------------------------------------------------------- | ---------------- | ------------------------- |
| `permanent` | Hard bounce — 5xx from the receiving MX (mailbox not found, domain invalid)            | `bounced`        | Yes, on `5.1.x` / `5.2.x` |
| `oob`       | Out-of-band — an asynchronous bounce report arriving after the message left our system | `bounced`        | Yes, on `5.1.x` / `5.2.x` |
| `transient` | KumoMTA exhausted its retry window and gave up (`Expiration`)                          | `expired`        | Yes (provisional)         |
| `admin`     | Operator-initiated bounce (`AdminBounce`)                                              | `failed`         | No                        |

<Callout type="warning">
  `bounce_category` is **internal**. It is computed when the callback is handled and is used to drive auto-suppression, but it is **not** forwarded into the published webhook payload **and not** written into the recipient-scoped event row that `GET /v2/email_events` returns — that payload is built from correlation fields plus the recipient address only. Treat it as absent from both surfaces.

  Do not write handlers that branch on `bounce_category` on either surface. Use `error_evidence.code` (`30001` hard bounce, `30005` queue expiry, `30003`/`30099` system failure, `30006` gateway rejection) together with `error_evidence.enhanced_code` to distinguish the failure modes. The recipient `status` (`bounced` vs `expired` vs `failed`) on the authoritative recipient-status endpoint carries the same distinction the category was standing in for.
</Callout>

An out-of-band bounce is the reason a `delivered` recipient can later become `bounced`: the receiving server accepted the message, then generated a bounce afterwards. It is the only correction edge out of `delivered`, and it is genuinely common with forwarding setups and catch-all domains.

***

## The Email Detail Record (EDR)

The **Email Detail Record** is the durable, per-recipient record of what happened. Where webhooks are a real-time notification you might miss, the EDR is the system of record used for billing, support, and reconciliation.

**One recipient = one EDR identity.** The recipient's UUID is the stable EDR UUID, so a message to five people produces five EDR identities.

### What produces an EDR — and what doesn't

EDRs are published at three points, not on every event:

* **Sandbox acceptance** — a provisional record per recipient (`billable` forced false); sandbox never enters the send pipeline, so this is its only publication point.
* **Injection outcomes** — a provisional record for each recipient the MTA accepted, and a final record for each recipient it refused (`gw_reject`).
* **Applicable callback transitions** — a `Reception`, `Delivery`, `TransientFailure`, `Bounce`, `OOB`, `AdminBounce`, or `Expiration` that actually moves the recipient forward.

Nothing else publishes one:

* **Engagement events** (`email.opened`, `email.clicked`, `email.unsubscribed`) create stored events and webhooks but never invoke the EDR publisher.
* **Complaints on an already-`delivered` or `bounced` recipient** are state-machine no-ops, so they produce no new record.
* **Ambiguous injection timeouts** explicitly get no EDR — the outcome is unknown. If a late `Reception` reconciles the recipient to `sent`, that transition publishes the provisional record.
* **Pre-dispatch system failures** write stored events and update recipient rows directly; they do not publish an EDR.

### Provisional and final records

For a recipient **accepted by the MTA**, the first record is **provisional** — a unique-per-recipient rating intent, so a pipeline replay cannot create a second one. Every subsequent lifecycle transition (deferred, delivered, OOB, bounce, expiry) publishes an **append-only final** record: a new row that carries the same recipient UUID as its stable EDR identity. Records are never rewritten in place.

<Callout type="info">
  **A refused recipient starts final, not provisional.** When the MTA refuses injection, the recipient goes straight to `gw_reject` and a single **final** EDR is published for it — there is no provisional record first, because there was never a rating intent to reserve. Do not assume every EDR identity begins with a provisional row.
</Callout>

### Lifecycle fields

| Field                    | Meaning                                                                                                                                                                           |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status`                 | Current lifecycle state — the field you want. One of `queued`, `sending`, `sent`, `deferred`, `delivered`, `bounced`, `failed`, `expired`, `suppressed`, `cancelled`, `gw_reject` |
| `created_at`             | API acceptance time                                                                                                                                                               |
| `sent_at`                | Queue acceptance by the MTA                                                                                                                                                       |
| `delivered_at`           | Remote MX accepted                                                                                                                                                                |
| `deferred_at`            | **First** transient failure                                                                                                                                                       |
| `bounced_at`             | Permanent rejection                                                                                                                                                               |
| `failed_at`              | System/operator non-delivery                                                                                                                                                      |
| `expired_at`             | MTA retry window expired                                                                                                                                                          |
| `suppressed_at`          | Recipient was suppressed before sending                                                                                                                                           |
| `completed_at`           | First terminal outcome                                                                                                                                                            |
| `attempt_count`          | Delivery attempts made                                                                                                                                                            |
| `expired` / `suppressed` | Booleans mirroring the terminal condition                                                                                                                                         |
| `billable`               | Whether this recipient counts toward usage                                                                                                                                        |

Every lifecycle timestamp is **set-once**: it locks on first acceptance, and a later record that tries to overwrite a stamped value is rejected. That is why `deferred_at` is the first deferral rather than the most recent one — a retry never rewrites it, and it never rewrites `sent_at` either.

<Callout type="warning">
  **`delivery_status` is not the lifecycle state.** Despite the name, the EDR's `delivery_status` field is a fixed platform-level field currently emitted as `"not_configured"`. Read **`status`** for the delivery outcome. Filtering or branching on `delivery_status` will not do what you expect.
</Callout>

### Error fields

Two structured fields carry failure detail, and both use the **normalized 30xxx error taxonomy** — not raw SMTP codes.

Raw SMTP codes vary per remote MX, collide across unrelated failure modes, and are absent entirely for failures that never reached SMTP (queue expiry, suppression, gateway rejection). The EDR therefore carries a product-level code and preserves the raw SMTP status alongside it as `smtp_status`.

#### The 30xxx taxonomy

| Code    | Title                | `source` | `retryable` | Raised when                                                      |
| ------- | -------------------- | -------- | ----------- | ---------------------------------------------------------------- |
| `30001` | Hard bounce          | `smtp`   | `false`     | `bounced` with a 5xx code, or a bounce with no usable code       |
| `30002` | Deferred             | `smtp`   | `true`      | `deferred`, or `bounced` carrying a 4xx code                     |
| `30003` | Injection failure    | `mta`    | `false`     | `failed` **with** an SMTP status — the MTA refused the injection |
| `30004` | Suppressed recipient | `api`    | `false`     | `suppressed` — the address was on the suppression list           |
| `30005` | Queue expiry         | `mta`    | `false`     | `expired` — the retry window elapsed                             |
| `30006` | Gateway rejection    | `api`    | `false`     | `gw_reject` — refused at the HTTP injection boundary             |
| `30099` | Internal error       | `api`    | `false`     | `failed` **without** an SMTP status — nothing reached the MTA    |

#### `error_evidence`

Six fields. Required on every error status: `bounced`, `failed`, `deferred`, `expired`, `suppressed`, `gw_reject`. Omitted entirely on success statuses.

```json theme={null}
{
  "error_evidence": {
    "code": "30001",
    "message": "5.1.1 The email account that you tried to reach does not exist",
    "enhanced_code": "5.1.1",
    "source": "smtp",
    "smtp_status": 550,
    "retryable": false
  }
}
```

| Key             | Type            | Meaning                                                                                                                         |
| --------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `code`          | string          | Normalized 30xxx taxonomy code. **Never** a raw SMTP code — a record carrying `"550"` here is rejected before publication       |
| `message`       | string \| null  | Diagnostic text from the receiving server. Required when `source` is `smtp`; null for failures that never reached a remote host |
| `enhanced_code` | string \| null  | RFC 3463 enhanced status code in `X.Y.Z` form (`"5.1.1"`)                                                                       |
| `source`        | string          | Where the failure originated: `smtp`, `mta`, `api`, `dns`, `dkim`, or `spam`                                                    |
| `smtp_status`   | integer \| null | The raw SMTP status code, preserved for ops triage (`550`, `421`)                                                               |
| `retryable`     | boolean         | Whether retrying this address could succeed                                                                                     |

#### `errors[]`

Seven fields per entry. Non-empty for every error status, empty for success statuses.

```json theme={null}
{
  "errors": [
    {
      "code": "30001",
      "title": "Hard bounce",
      "detail": "5.1.1 The email account that you tried to reach does not exist",
      "source": "smtp",
      "smtp_status": 550,
      "enhanced_code": "5.1.1",
      "retryable": false
    }
  ]
}
```

| Key             | Type            | Meaning                                                                                   |
| --------------- | --------------- | ----------------------------------------------------------------------------------------- |
| `code`          | string          | Normalized 30xxx code — structurally required                                             |
| `title`         | string          | Human-readable taxonomy title (`"Hard bounce"`, `"Queue expiry"`) — structurally required |
| `detail`        | string \| null  | Diagnostic text. **This field is named `detail`, not `message`**                          |
| `source`        | string          | `smtp`, `mta`, `api`, `dns`, `dkim`, or `spam` — structurally required                    |
| `smtp_status`   | integer \| null | Raw SMTP status code when one exists                                                      |
| `enhanced_code` | string \| null  | Enhanced status code when one exists                                                      |
| `retryable`     | boolean         | Structurally required                                                                     |

`code`, `title`, `source`, and `retryable` are present on every entry. `detail`, `smtp_status`, and `enhanced_code` legitimately stay null for failures that never reached SMTP — fabricating a response string would be worse than leaving it empty.

### Webhook payloads

Webhook payloads are **not** a projection of the full EDR. They are built from a compact recipient-scoped shape:

```json theme={null}
{
  "id": "8f2b1c34-...",
  "recipient_id": "6c0a9d51-...",
  "status": "bounced",
  "occurred_at": "2026-07-24T12:00:00.000000Z",
  "from": { "email": "you@yourdomain.com", "name": "Your App" },
  "subject": "Your receipt",
  "to": { "email": "user@example.com", "name": null, "kind": "to" }
}
```

| Field               | Meaning                                                                                                                                                                     |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                | The message ID                                                                                                                                                              |
| `recipient_id`      | The durable recipient row UUID — your join key                                                                                                                              |
| `status`            | The **event type** (`queued`, `sent`, `delivered`, `bounced`, `deferred`, `failed`, `injection_timeout`, …), not the recipient's internal status                            |
| `occurred_at`       | When the event occurred                                                                                                                                                     |
| `from`              | Sender address and display name                                                                                                                                             |
| `subject`           | Message subject                                                                                                                                                             |
| `to` / `cc` / `bcc` | Exactly one recipient projection — `{email, name, kind}`. The key names which envelope field this recipient came from. BCC addresses are redacted to a stable HMAC sentinel |

On error events (`bounced`, `failed`, `deferred`, `expired`, `suppressed`, `gw_reject` recipient statuses) the payload additionally carries `error_evidence` and `errors[]`. Scheduled sends add `send_at`; ambiguous-injection events add `ambiguous_timeout: true`.

<Callout type="warning">
  **The raw `smtp_code` / `smtp_response` compatibility fields are conditional.** They are present only when the failure carries callback-driven SMTP evidence — a `Bounce`, `TransientFailure`, `OOB`, `AdminBounce`, or `Expiration` record from KumoMTA that actually reached the wire.

  Failures raised before SMTP was ever spoken carry **no** `smtp_code` or `smtp_response` at all. Injection refusals (`gw_reject`, code `30006`) build their payload from the normalized error contract only: `error_evidence` and `errors[]`, with `smtp_status` and `message` explicitly `null`. Treat `error_evidence` as the field you branch on, and both raw SMTP fields as optional extras that may be absent entirely.
</Callout>

<Callout type="warning">
  **What a webhook payload does not contain.** Billing fields (`billable`, `sandbox`), EDR lifecycle timestamps (`sent_at`, `delivered_at`, `bounced_at`, `expired_at`, …), SMTP-host evidence (`sending_ip`, `mx_hostname`, `mx_ip`), tags, and `bounce_category` are **not** forwarded into the published webhook.

  These fields are not uniformly recoverable elsewhere. Billing fields, lifecycle timestamps, and SMTP-host evidence live on the **EDR**. `bounce_category` is internal: it is not on the webhook, and it is not written into the recipient-scoped event row that `GET /v2/email_events` returns. The stored event returned by that endpoint carries a **sanitized** payload — internal correlation fields are stripped before it is returned — so it is not a full EDR replacement.

  Null values are **not** stripped — `"name": null` is sent as an explicit null. Handle nulls rather than assuming key absence.

  If you need the full record, use the authoritative per-recipient status endpoint or the EDR — not a `recipient_id` join against `GET /v2/email_events`, whose public payload omits the internal recipient identifiers.
</Callout>

***

## Consuming the lifecycle

Three surfaces expose the outbound lifecycle. Pick based on your infrastructure — but note that they do **not** carry identical fields (see the warning above).

| Surface      | Endpoint                     | Use when                                                                                                                                                                         |
| ------------ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Webhooks** | Your HTTPS endpoint          | You want push delivery in real time and can host a public endpoint                                                                                                               |
| **Polling**  | `GET /v2/email_events`       | You have no public endpoint, or you're reconciling after an outage. Returns a sanitized payload — internal correlation fields are stripped, and `bounce_category` is not present |
| **Stats**    | `GET /v2/email_events/stats` | You want aggregate recipient-level counts and rates, not individual events                                                                                                       |

<Callout type="info">
  **The WebSocket endpoint is inbound-only.**  is a best-effort real-time stream of **inbound** `email.received` events for your account. It does not carry the outbound delivery lifecycle — no `email.sent`, `email.delivered`, `email.bounced`, or any other outbound event is broadcast on it. Use webhooks or polling for outbound.
</Callout>

Webhooks fire **per event, per recipient**, and each subscription carries an allowlist — you receive only the event types you asked for. A subscription listing only `email.bounced` gets bounces and nothing else.

See the [Webhooks & Events guide](/docs/messaging/email/webhooks-events) for subscription setup, payload envelopes, Ed25519 signature verification, cursor pagination, and retry behavior.

<Callout type="info">
  The polling API and the webhook allowlist use **different taxonomies**. Polling filters accept bare event names (`delivered`, `bounced`); webhook subscriptions use the prefixed form (`email.delivered`). The stored taxonomy has no `expired` or `injection_timeout` member — both are recorded as `failed` — and it excludes webhook-only types such as `email.injection_timeout` entirely. Don't assume a name valid in one is valid in the other.
</Callout>

### Designing a consumer

A few properties of the stream are worth designing around:

**Events are not ordered.** A `Delivery` callback can arrive before the `Reception` that logically precedes it. The state machine handles this — `queued → delivered` is a legal edge — and your consumer should too. Use the timestamps, not arrival order.

**Events are idempotent, and replays happen.** The same callback redelivered produces no second state transition and no duplicate stored event. Your handler should be equally tolerant: key on the event ID.

**Terminal is not always final.** `delivered → bounced` via an out-of-band report is legitimate and will happen in production. Don't build logic that assumes a delivered message can never bounce.

**Scope everything by recipient.** A three-recipient message produces three parallel event streams. Aggregating them into one message-level status will lose information the moment one recipient bounces and another delivers.

**Don't count from webhooks alone.** Some outcomes are stored without a webhook (pre-dispatch system failures), and one webhook type covers several statuses (`email.bounced` for bounced, expired, and admin-bounced). Reconcile against `GET /v2/email_events`, the stats API, or the authoritative per-recipient status endpoint. Note that system failures do not publish an EDR, so the EDR alone will not close that gap.

***

## Delivery failures come in two layers

When something goes wrong, the layer tells you where to look.

### Layer 1 — API errors (`10xxx`)

The request was rejected synchronously. No message was created, no event fires, nothing is billed. You get an HTTP error with a Telnyx error code — `10015` for validation failures, `10007` for an unverified domain or unauthorized sender, `10006` for a bad API key.

These are **your** bugs, and they are fixable before you retry. Full list in the [Error codes reference](/docs/messaging/email/error-codes).

### Layer 2 — Delivery failures (`30xxx`)

The request succeeded and delivery failed downstream. These surface asynchronously as events, and the detail lives in `error_evidence`:

* `error_evidence.code` — the **normalized** taxonomy code (`"30001"`, `"30002"`, `"30005"`), never a raw SMTP code
* `error_evidence.source` — where it originated (`smtp`, `mta`, `api`, …)
* `error_evidence.retryable` — whether retrying this address could succeed
* `error_evidence.smtp_status` — the raw SMTP status, preserved for triage (`550`, `421`)
* `error_evidence.enhanced_code` — the RFC 3463 enhanced status code (`"5.1.1"`)
* `error_evidence.message` — what the receiving server actually said (null when nothing reached SMTP)

**Route on `code` and `retryable`, triage on `smtp_status` and `enhanced_code`.** `30002` is temporary (you'll see `email.deferred`, and the MTA is already retrying — do nothing). `30001` is permanent (you'll see `email.bounced`, and retrying the same address will fail again). `30005` means we ran out of retry window, and `30003`/`30006`/`30099` mean the failure was on our side of the wire, not the recipient's.

Within a hard bounce, the enhanced code tells you whose problem it is. `5.1.x` and `5.2.x` are recipient problems — bad or full mailbox, and the address is auto-suppressed. `5.7.x` is a **sender** problem — authentication or policy rejection — and the address is deliberately *not* suppressed, because it is probably fine. A rise in `5.7.x` means you should be checking SPF, DKIM, and DMARC, not cleaning your list.

***

## Related

* [Webhooks & Events](/docs/messaging/email/webhooks-events) — subscribe, verify signatures, poll, paginate
* [Error codes](/docs/messaging/email/error-codes) — the full API error reference
* [Deliverability](/docs/messaging/email/deliverability) — reputation, authentication, and bounce handling
* [Suppressions](/docs/messaging/email/suppressions) — how auto-suppression works and how to manage the list
* [Send an email](/docs/messaging/email/send-email) — the send API and its fields
