# Email events

Every recipient on a send moves through a lifecycle, and Bird emits an event at each step. Events are per-recipient facts: a send to three addresses produces three independent event streams, each correlated by `email_id` + `recipient_id`. This page is the full event vocabulary; how events reach your endpoint (signatures, retries, ordering, replay) is covered in the [Webhooks guide](/docs/guides/webhooks).

The delivery lifecycle, as a path through the event types:

- `email.accepted`: Bird has the send and is preparing to deliver
- → `email.processed`: the message is built and queued for delivery
- → `email.delivered`: the recipient's mail server accepted it
- → or `email.deferred`: temporary failure; Bird retries, ending in `email.delivered` or `email.bounced`
- → or `email.bounced`: permanent failure; the recipient's terminal status is `bounced`
- → or `email.rejected`: the recipient was rejected before delivery was attempted (for example, suppressed); distinct from a bounce
- After `email.delivered`, the stream can continue with `email.out_of_band_bounce`, `email.complained`, `email.opened`, `email.clicked`, `email.unsubscribed`, and `email.list_unsubscribed`

Each recipient ends in exactly one terminal delivery status (`delivered`, `bounced`, `complained`, or `rejected`): the per-recipient `status` returned by [`GET /v1/email/messages/{message_id}/recipients`](/docs/api/reference/list-email-message-recipients). `GET /v1/email/messages/{message_id}` itself returns the aggregate message status and per-state counts. Engagement events (`opened`, `clicked`, unsubscribes) never change that terminal status. A classified `email.out_of_band_bounce` does: it retracts an earlier `delivered` and moves the recipient to `bounced`.

## The event envelope

Events arrive at your webhook endpoint as the standard three-field envelope: `type`, `timestamp` (when the event occurred, RFC 3339), and a type-specific `data` object. Delivery mechanics, including the `webhook-id` deduplication header, the retry schedule, and why you sort by `timestamp` rather than arrival order, live in the [Webhooks guide](/docs/guides/webhooks#delivery-semantics).

For outbound email events, `data` always carries the identity base: `email_id`, `recipient_id`, `workspace_id`, the `recipient` address, and its envelope `recipient_role` (to/cc/bcc), plus the `tags` and `metadata` you provided on the send, echoed on every event so you can route and correlate deliveries against your own records without an extra lookup (each is `null` when the send carried none). Within an event type the field set is stable: required fields, no surprises.

```json
{
  "type": "email.delivered",
  "timestamp": "2026-07-23T14:51:47.107Z",
  "data": {
    "email_id": "em_01ky7qc398fmxraqtxn604zeq9",
    "recipient_id": "er_01ky7qc398fmwsc96nt6anrbqs",
    "workspace_id": "ws_01ky7m21hjffh9s8kq76gyb1xf",
    "recipient": "delivered@messagebird.dev",
    "recipient_role": "to",
    "tags": [{ "name": "category", "value": "welcome" }],
    "metadata": { "order_id": "ord_123" }
  }
}
```

Each event type's webhook `data` carries the fields listed in its catalog entry on this page. The same per-recipient events are also queryable after the fact with [`GET /v1/email/messages/{message_id}/events`](/docs/api/reference/list-email-message-events), where each event carries an `id` (`ev_` prefix), `type`, `occurred_at`, `recipient_id`, and the same type-specific detail. Use the events API to backfill, replay, or reconcile against your webhook deliveries by `email_id` + `recipient_id`. A few fields are surfaced only through the events API (noted on the relevant event), such as `is_prefetched` and `country`.

## Lifecycle events

### email.accepted

Fires once per requested recipient when Bird accepts the send and begins preparing delivery. This is the first event in every recipient's stream. Webhook payload: the identity base only.

### email.processed

Fires once per recipient when Bird has processed the message and queued it for delivery to the recipient's mail server. Webhook payload: the identity base only. To measure processing latency, compare this event's `timestamp` with `email.accepted`'s.

### email.delivered

The recipient's mail server accepted the message. Webhook payload: the identity base only; the `sending_ip` Bird sent from, useful when investigating deliverability issues that correlate with specific IPs, is on the event via the events API. Delivery means the remote server took responsibility for the message; engagement events tell you what happened after.

### email.deferred

A temporary delivery failure: the recipient's mail server asked Bird to try again later (mailbox full, greylisting, rate limiting). Bird retries automatically, and a deferred recipient eventually resolves to `email.delivered` or `email.bounced`, so treat this event as informational, not terminal. Webhook payload: `bounce_type`, `bounce_class`, `defer_reason` (the reason the receiving server gave), and `sending_ip`. A recipient can defer multiple times before resolving.

## Failure events

### email.bounced

A permanent delivery failure at SMTP time: the recipient's mail server refused the message. The recipient's terminal status becomes `bounced`. Webhook payload: the bounce classification, which is `bounce_type` (see the [classification table](#bounce-classification)), `bounce_class`, `bounce_code` (SMTP reply code, e.g. `550`), `bounce_description` (human-readable reason), and `sending_ip`. Hard bounces automatically [suppress](#auto-suppression) the address.

### email.out_of_band_bounce

A late bounce notification that arrived asynchronously **after** the message was already delivered: the receiving server accepted the message at SMTP time, then sent a bounce report afterwards. It carries the same bounce classification as `email.bounced` (`bounce_type`, `bounce_class`, `bounce_code`, `bounce_description`, `sending_ip`). When the bounce classifies (any class in the [classification table](#bounce-classification)), the receiving server has retracted its earlier acceptance, so the recipient's status moves from `delivered` to `bounced`, exactly as an in-band bounce would set it. Reports whose class is not in the table (auto-replies and similar) are recorded on the timeline without changing the status. Hard out-of-band bounces also trigger [auto-suppression](#auto-suppression), so future sends to the address are blocked.

### email.rejected

The recipient was rejected before the message ever reached the remote mail server: no delivery attempt was made. This is distinct from `email.bounced`: a bounce is the receiving server saying no, a rejection is the message never getting that far. Webhook payload: `rejection_reason` (also on the recipient record), one of:

| `rejection_reason`      | Meaning                                                                                                                          |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `recipient_suppressed`  | The recipient is on the workspace [suppression list](/docs/guides/email/suppressions); Bird did not attempt delivery             |
| `transmission_failed`   | The message could not be transmitted for delivery                                                                                |
| `generation_failure`    | The message could not be built for delivery (template or content issue)                                                          |
| `policy_rejection`      | The message was refused by sending policy                                                                                        |
| `domain_unverified`     | The sending domain was not verified                                                                                              |
| `quota_exceeded`        | The organization's send quota was reached                                                                                        |
| `recipient_not_allowed` | The recipient was not permitted for this send; shared onboarding-domain sends deliver only to verified members of your workspace |

### email.complained

The recipient marked the message as spam, reported back to Bird through the mailbox provider's feedback loop. Complaints arrive after delivery and set the recipient's terminal status to `complained`. Webhook payload: `feedback_type` (the kind of report the provider sent, such as `abuse` or `fraud`; null when the provider did not specify one). A complaint automatically [suppresses](#auto-suppression) the address. Keep complaint rates low, since mailbox providers throttle senders whose mail gets reported.

## Engagement events

Engagement events never change the recipient's delivery status: a recipient who opened is still `delivered`.

### email.opened

The tracking pixel in the message body loaded. Webhook payload: `ip_address` and `user_agent` (when known). The events API additionally exposes `is_prefetched` and `country` (ISO 3166-1 alpha-2, derived from the client IP). **Check `is_prefetched` before counting opens**: it is `true` when the open was auto-fetched by an inbox privacy feature (Apple Mail Privacy Protection, Gmail's image proxy) rather than a real user action, and counting prefetched opens inflates open rates.

### email.clicked

The recipient clicked a tracked link. Webhook payload: `url` (the clicked URL), `ip_address`, and `user_agent` (when known). The events API additionally exposes `country`. A click is the strongest engagement signal: privacy prefetchers load pixels, but they rarely follow links.

### email.unsubscribed

The recipient unsubscribed via the unsubscribe link in the message body. Webhook payload: the identity base only. Triggers [auto-suppression](#auto-suppression) for non-transactional mail. How the link gets into your mail is covered in [Unsubscribe links](/docs/guides/email/unsubscribe-links).

### email.list_unsubscribed

The recipient unsubscribed via the RFC 8058 one-click mechanism: the unsubscribe button mailbox providers render in their own UI, driven by the message's `List-Unsubscribe` headers. Webhook payload: the identity base only; the mechanism is the event type itself. Distinct from `email.unsubscribed` so you can tell provider-rendered unsubscribes apart from clicks on the in-body link. Also triggers [auto-suppression](#auto-suppression) for non-transactional mail.

## Message-level events

Two events describe the message as a whole rather than one recipient, so their `data` carries `email_id`, `workspace_id`, `tags`, and `metadata`, but no recipient identity. Both belong to [scheduled sending](/docs/guides/email/scheduled-sending):

### email.scheduled

Fires when Bird accepts a send with a `scheduled_at` in the future. Webhook payload: the message-level base plus `scheduled_at`, the time the message is scheduled to go out. When the time arrives, the normal per-recipient lifecycle starts with `email.accepted`.

### email.canceled

Fires when a scheduled message is canceled before it goes out. Webhook payload: the message-level base only. A canceled message never produces recipient lifecycle events.

## The receiving event

`email.received` is the one event about mail coming _into_ Bird rather than going out: it fires when Bird receives and parses an inbound message, and its payload carries the `inbound_message_id`, addressing, subject, and authentication verdicts. It belongs to the receiving pipeline, not the send lifecycle; setup, payload, and the fetch-back API are covered in [Receiving email](/docs/guides/email/receiving-email).

## Bounce classification

`bounce_class` is Bird's numeric bounce classification, carried on `email.bounced`, `email.out_of_band_bounce`, and `email.deferred` events. It rolls up into the coarse `bounce_type`, but keeps the fine-grained code so you can distinguish, say, a DNS failure from a spam block when both map to the same type:

| `bounce_class`               | `bounce_type`  | Meaning                                                                                 |
| ---------------------------- | -------------- | --------------------------------------------------------------------------------------- |
| `1`                          | `undetermined` | The receiving server's response was ambiguous                                           |
| `10`, `30`                   | `hard`         | Permanent failure: invalid address or non-existent domain                               |
| `20`–`24`, `40`, `70`, `100` | `soft`         | Transient failure: mailbox full, server temporarily unavailable, DNS or routing trouble |
| `25`                         | `admin`        | Administrative refusal: relaying denied, blocklisted domain                             |
| `50`–`54`                    | `block`        | The receiving server blocked the sending IP for reputation reasons                      |

Any class outside this list maps to `undetermined`. Only `hard` bounces trigger auto-suppression; `soft`, `block`, `admin`, and `undetermined` bounces do not, since the address may still be deliverable.

## Auto-suppression

Three event classes automatically add the recipient to the workspace suppression list: hard bounces (`email.bounced` or `email.out_of_band_bounce` with `bounce_type: "hard"`), spam complaints (`email.complained`), and unsubscribes (`email.unsubscribed` or `email.list_unsubscribed`). Each addition fires an `email_suppression.created` webhook event carrying the `suppression_id`, the suppressed `email`, the `reason`, and the `workspace_id`. Which categories each suppression blocks, and the full record schema, live in the [Suppressions guide](/docs/guides/email/suppressions).

Subsequent sends to a suppressed address are rejected up front with `email.rejected` and `rejection_reason: "recipient_suppressed"`; they never count against your deliverability.

## Next steps

- [Webhooks & events](/docs/guides/webhooks): endpoint setup, signature verification, retries, and replay
- [Webhooks API reference](/docs/api/reference/create-webhook): full endpoint and schema documentation
- [Suppressions](/docs/guides/email/suppressions): how the suppression list works and how to manage it
- [Unsubscribe links](/docs/guides/email/unsubscribe-links): wiring up the unsubscribe paths behind `email.unsubscribed` and `email.list_unsubscribed`
- [Testing & sandbox](/docs/guides/email/testing-sandbox): sandbox sends emit real events through the normal delivery path, ideal for exercising your handler