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

# message.status_changed

> Event emitted when an outbound message's delivery or read state advances.

Emitted when an outbound message you sent advances to a new delivery or read state — the carrier confirmed it, the carrier rejected it, or the recipient read it. `POST /api/v1/messages` returns as soon as Dial accepts the message for sending, which is the right contract but tells you nothing about what happened next. This is the event that tells you.

Wait on it to confirm a message actually landed, to react the moment one is rejected, or to know when a recipient read it — instead of polling `GET /api/v1/messages`.

**Outbound only.** Inbound messages arrive as [`message.received`](/api-reference/events/message-received) and never emit `message.status_changed` — nothing advances an inbound message after it arrives.

## The two axes

Delivery and read are **independent facts**, and this event reports them as two separate axes rather than one combined status. A message can be delivered but not read, read on a rail that never confirms delivery, or rejected before either applies.

Every event carries the current value of **both** axes plus a `changed` field naming which one just moved. So a single event is always a complete snapshot — you never need to remember the other axis from a previous event.

Each axis has an explicit value for "this rail does not report it", because a missing value has to be distinguishable from a value that hasn't arrived yet:

**`deliveryState`**

| Value         | Meaning                                                                |
| ------------- | ---------------------------------------------------------------------- |
| `pending`     | Accepted for sending. A disposition is expected — wait for it.         |
| `delivered`   | Confirmed on the recipient's handset.                                  |
| `undelivered` | Accepted, then could not be delivered.                                 |
| `failed`      | Rejected at or before send.                                            |
| `unconfirmed` | This rail sends no delivery receipts. Only a read can ever upgrade it. |

**`readState`**

| Value         | Meaning                                                                |
| ------------- | ---------------------------------------------------------------------- |
| `unread`      | Reads are reported on this rail; not read yet.                         |
| `read`        | The recipient read the message.                                        |
| `unsupported` | This rail never reports reads. Nothing is coming, ever — stop waiting. |

The difference between `unconfirmed` and `unsupported` matters when you're deciding whether to keep a record open. `unsupported` is terminal: no read receipt will ever arrive, so close it. `unconfirmed` is silence that a later read receipt can still resolve into `delivered`.

### What each channel reports

| `channel`  | Delivery axis                                        | Read axis         |
| ---------- | ---------------------------------------------------- | ----------------- |
| `sms`      | `pending` → `delivered` \| `undelivered` \| `failed` | `unsupported`     |
| `rcs`      | `pending` → `delivered` \| `undelivered` \| `failed` | `unread` → `read` |
| `imessage` | `unconfirmed` → `delivered` (proven by a read)       | `unread` → `read` |

A read receipt is proof the handset received the message, so a read always implies delivery. On an iMessage number — where the rail sends no delivery receipts at all — the read receipt is the *only* evidence of delivery, which is why a read there advances both axes at once.

## Schema

Shares the common [event envelope](/api-reference/events/overview#envelope) (`id`, `object`, `type`, `version`, `createdAt`, `relatedObject`). `relatedObject` is `{ id: <messageId>, type: "message", url: null }` — messages have no get-by-id endpoint yet. The envelope's `createdAt` is when the state advanced. The `data` payload:

| `data` field    | Type           | Description                                                                                                                            |
| --------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `messageId`     | string         | Dial-issued ID for the message. Matches the `id` returned by `GET /api/v1/messages` and by the send that created it.                   |
| `phoneNumberId` | string         | ID of the Dial number the message was sent from — matches `GET /api/v1/numbers`. Use it to filter the stream to one number.            |
| `from`          | string         | Your Dial number that sent the message, E.164.                                                                                         |
| `to`            | string         | Recipient phone number, E.164.                                                                                                         |
| `channel`       | string         | The rail the message was sent on: `"sms"`, `"imessage"`, `"rcs"`, or `"unknown"` when the rail didn't report which service carried it. |
| `changed`       | string         | Which axis this event reports: `"delivery"` or `"read"`. The other axis is a current-value snapshot, not a change.                     |
| `deliveryState` | string         | Current delivery state — see the table above.                                                                                          |
| `readState`     | string         | Current read state — see the table above.                                                                                              |
| `deliveryError` | string \| null | Plain-language reason the message wasn't delivered. Non-null only when `deliveryState` is `undelivered` or `failed`.                   |

Every field is a flat value — no nested objects — so you can filter on any of them directly, e.g. `dial wait-for message.status_changed -f deliveryState=failed`.

## Example

An SMS rejected by the carrier because the sender isn't registered for A2P/10DLC messaging:

```json
{
  "id": "evt_7c1d4e8f2a5b9c3d6e0f1a2b4c5d7e8f9a0b1c2d",
  "object": "event",
  "type": "message.status_changed",
  "version": 1,
  "createdAt": "2026-08-20T09:14:22.108Z",
  "relatedObject": { "id": "msg_01HW3X7P5QF6K2YQ9YJ7Q8R5N3", "type": "message", "url": null },
  "data": {
    "messageId": "msg_01HW3X7P5QF6K2YQ9YJ7Q8R5N3",
    "phoneNumberId": "clx8k2j9h0001mk08w3t5r7qz",
    "from": "+14155559876",
    "to": "+14155550123",
    "channel": "sms",
    "changed": "delivery",
    "deliveryState": "failed",
    "readState": "unsupported",
    "deliveryError": "The carrier rejected the message because the sender isn't registered for A2P/10DLC messaging."
  }
}
```

And an iMessage being read — note that this same receipt also produces a `changed: "delivery"` event, because the read proves the message was delivered:

```json
{
  "id": "evt_2b8e5f1a9c4d7e0f3a6b8c1d5e2f4a7b9c0d3e6f",
  "object": "event",
  "type": "message.status_changed",
  "version": 1,
  "createdAt": "2026-08-20T09:20:41.552Z",
  "relatedObject": { "id": "msg_01HW3X8Q6RG7L3ZR0ZK8R9S6P4", "type": "message", "url": null },
  "data": {
    "messageId": "msg_01HW3X8Q6RG7L3ZR0ZK8R9S6P4",
    "phoneNumberId": "clx8k2j9h0001mk08w3t5r7qz",
    "from": "+14155559876",
    "to": "+14155550123",
    "channel": "imessage",
    "changed": "read",
    "deliveryState": "delivered",
    "readState": "read",
    "deliveryError": null
  }
}
```

## When it fires

* **Once per axis, per message.** A message emits at most one `delivery` event and at most one `read` event. Deduplicate on the envelope `id`, which is stable per (message, axis, state) — a repeated carrier callback collapses onto the same `id`.
* **Never on send.** Accepting a message for sending is not a state change; that's what the send call already returned. The first event you see is the first real advance.
* **Both axes can fire from one signal.** A read receipt on an iMessage number emits a `delivery` event (`unconfirmed` → `delivered`) *and* a `read` event, with distinct `id`s. If you only care that the message landed, filter on `changed: "delivery"`; if you only care about reads, filter on `changed: "read"`.
* **Failures always carry `deliveryError`.** It's plain language safe to surface to a user or log — never a raw carrier code.
* **A rail's "unreported" axis never fires.** An SMS will not emit a `read` event, because `readState` is `unsupported` from the start. Don't write a consumer that waits for one.

## Related resource fields

`GET /api/v1/messages` exposes the same two axes on each message — `deliveryState`, `readState`, `readAt`, and `deliveryError` — so a fetch and a stream event never disagree. The older `status` and `statusError` fields are still returned and still mean delivery; they're retained as aliases.

Inbound messages report settled values on both axes: `deliveryState: "delivered"` (it arrived) and `readState: "unsupported"`, since Dial doesn't track whether you read an inbound message.