> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.rray.app/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.rray.app/_mcp/server.

# Message lifecycle

A **message** is one send. A **delivery** is one recipient's copy of it: every address
across `to`, `cc` and `bcc` becomes its own delivery, tracked and counted separately.
The message carries an aggregate status; each delivery carries its own.

## The path a delivery takes

```
accepted ──┬──► queued ──┬──► delivered
           │             ├──► bounced
           │             ├──► deferred ──► delivered | bounced | failed
           │             └──► failed
           └──► failed      (we never managed to hand it off)
```

| Status      | What it means                                             | Terminal |
| ----------- | --------------------------------------------------------- | -------- |
| `accepted`  | We have the delivery stored and are about to hand it off. | No       |
| `queued`    | Handed off, in flight to the recipient's provider.        | No       |
| `delivered` | The recipient's mailbox provider accepted it.             | Yes      |
| `bounced`   | Permanently rejected. Stop sending to this address.       | Yes      |
| `deferred`  | Temporarily refused. Still retrying on its own.           | No       |
| `failed`    | Retries ran out without delivering.                       | Yes      |

Once a delivery reaches `delivered`, `bounced` or `failed` it stays there: a late or
out-of-order report cannot move it back. `deferred` is the one non-terminal outcome, so a
delivery sitting in it will still change.

## Reading the three failure states

The distinction is what you should *do*, not how bad it looks.

* **`bounced` — do not send to this address again.** The provider refused it
  permanently: the mailbox does not exist, the domain does not accept mail, or the
  message was rejected on policy. Retrying costs you sender reputation.
* **`deferred` — do nothing.** The provider asked us to come back later: a full mailbox,
  a greylisting delay, a rate limit at their end. Retries are ours to run; a second send
  from you just duplicates the message.
* **`failed` — investigate, then decide.** We retried until the queue expired and never
  got through. The address may be fine and the destination may have been down for hours.
  This is the one state where a fresh send can be the right answer.

## The aggregate status

A message's `status` is folded from its deliveries. With one recipient it is simply that
recipient's status. With several it is their shared status, or `mixed` when they disagree.

```json
{
  "id": "msg_01M4ANAVD7EYMHY0PWSH5PBDJD",
  "object": "message",
  "status": "mixed",
  "deliveries": [
    { "address": "billing@rray.app", "role": "to", "status": "delivered" },
    { "address": "records@rray.app", "role": "cc", "status": "bounced" }
  ]
}
```

`mixed` appears only on the message, never on a delivery. When you see it, read
`deliveries` — the aggregate deliberately does not pick a winner.

Filtering `GET /v1/messages?status=...` matches the aggregate status, so
`status=mixed` is a valid filter and `status=bounced` will not return the message above.

## The event timeline

`GET /v1/messages/{id}` returns an `events` array: every status change across every
delivery of the message, oldest first. Each entry names the `address` it belongs to, so
a multi-recipient message interleaves its deliveries in one timeline.

```json
{
  "events": [
    {
      "id": "evt_01M4ANAVDNW22V8M1EHEPQJ8DM",
      "object": "delivery_event",
      "type": "accepted",
      "address": "billing@rray.app",
      "created_at": "2026-10-07T07:48:02.590140Z"
    },
    {
      "id": "evt_bfa8d9d5a3eeaafd9bf798230b2cfaed",
      "object": "delivery_event",
      "type": "queued",
      "address": "billing@rray.app",
      "created_at": "2026-10-07T07:48:02Z"
    },
    {
      "id": "evt_54e3166c7b31ddc76f699de9ef0efff7",
      "object": "delivery_event",
      "type": "delivered",
      "address": "billing@rray.app",
      "created_at": "2026-10-07T07:48:04Z"
    }
  ]
}
```

| `type`      | Recorded when                                                                |
| ----------- | ---------------------------------------------------------------------------- |
| `accepted`  | We stored the delivery. The first entry of every timeline.                   |
| `queued`    | The delivery was handed off successfully.                                    |
| `delivered` | The recipient's provider accepted it.                                        |
| `deferred`  | The provider refused temporarily; a retry is pending.                        |
| `bounced`   | The provider refused permanently.                                            |
| `failed`    | Retries were exhausted, or we never managed to hand the delivery off at all. |

A `deferred` entry can appear several times — one per refused attempt — and the timeline
then continues to whatever the retries reach. Treat a `type` you do not recognise as "no
status change" rather than an error: the set can grow, and the field is a string in the
schema for exactly that reason.

## Following a send

Send, then poll the message. There is nothing to subscribe to yet.

```bash
# 1. send — 202 with the id, nothing else
curl -X POST https://api.rray.app/v1/messages \
  -H "Authorization: Bearer $RRAY_API_KEY" \
  -H "Idempotency-Key: order-1042-receipt" \
  -H "Content-Type: application/json" \
  -d '{"from": "receipts@yourdomain.com", "to": ["billing@rray.app"], "subject": "Your receipt", "text": "Thanks!"}'

# 2. read it back
curl https://api.rray.app/v1/messages/msg_01M4ANAVD7EYMHY0PWSH5PBDJD \
  -H "Authorization: Bearer $RRAY_API_KEY"
```

A send that returns `202` is accepted, not delivered — the status at that moment is
`accepted`. Most deliveries reach a terminal status within seconds, but a `deferred` one
can take hours, so poll with backoff rather than in a tight loop, and stop once every
delivery is terminal.

For aggregate numbers over time, use [`GET /v1/stats`](/api) instead of walking the
message list: it returns gap-filled per-day, per-week or per-month counters for each
status.

## What is retained

Messages, deliveries and the event timeline stay readable for the full stored history.
The rendered body (`GET /v1/messages/{id}/body`) is the exception: it is kept for 30 days
after send, then removed, and the endpoint answers `404 message.body_unavailable`
afterwards. See [Limits & facts](/docs/get-started/limits).