Skip to navigation

Message lifecycle

From accepted to a terminal outcome, and how to read the timeline

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)
StatusWhat it meansTerminal
acceptedWe have the delivery stored and are about to hand it off.No
queuedHanded off, in flight to the recipient’s provider.No
deliveredThe recipient’s mailbox provider accepted it.Yes
bouncedPermanently rejected. Stop sending to this address.Yes
deferredTemporarily refused. Still retrying on its own.No
failedRetries 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.

{
"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.

{
"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"
}
]
}
typeRecorded when
acceptedWe stored the delivery. The first entry of every timeline.
queuedThe delivery was handed off successfully.
deliveredThe recipient’s provider accepted it.
deferredThe provider refused temporarily; a retry is pending.
bouncedThe provider refused permanently.
failedRetries 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.

# 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 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.