message.status_changed

View as Markdown

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

ValueMeaning
pendingAccepted for sending. A disposition is expected — wait for it.
deliveredConfirmed on the recipient’s handset.
undeliveredAccepted, then could not be delivered.
failedRejected at or before send.
unconfirmedThis rail sends no delivery receipts. Only a read can ever upgrade it.

readState

ValueMeaning
unreadReads are reported on this rail; not read yet.
readThe recipient read the message.
unsupportedThis 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

channelDelivery axisRead axis
smspendingdelivered | undelivered | failedunsupported
rcspendingdelivered | undelivered | failedunreadread
imessageunconfirmeddelivered (proven by a read)unreadread

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 (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 fieldTypeDescription
messageIdstringDial-issued ID for the message. Matches the id returned by GET /api/v1/messages and by the send that created it.
phoneNumberIdstringID of the Dial number the message was sent from — matches GET /api/v1/numbers. Use it to filter the stream to one number.
fromstringYour Dial number that sent the message, E.164.
tostringRecipient phone number, E.164.
channelstringThe rail the message was sent on: "sms", "imessage", "rcs", or "unknown" when the rail didn’t report which service carried it.
changedstringWhich axis this event reports: "delivery" or "read". The other axis is a current-value snapshot, not a change.
deliveryStatestringCurrent delivery state — see the table above.
readStatestringCurrent read state — see the table above.
deliveryErrorstring | nullPlain-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:

1{
2 "id": "evt_7c1d4e8f2a5b9c3d6e0f1a2b4c5d7e8f9a0b1c2d",
3 "object": "event",
4 "type": "message.status_changed",
5 "version": 1,
6 "createdAt": "2026-08-20T09:14:22.108Z",
7 "relatedObject": { "id": "msg_01HW3X7P5QF6K2YQ9YJ7Q8R5N3", "type": "message", "url": null },
8 "data": {
9 "messageId": "msg_01HW3X7P5QF6K2YQ9YJ7Q8R5N3",
10 "phoneNumberId": "clx8k2j9h0001mk08w3t5r7qz",
11 "from": "+14155559876",
12 "to": "+14155550123",
13 "channel": "sms",
14 "changed": "delivery",
15 "deliveryState": "failed",
16 "readState": "unsupported",
17 "deliveryError": "The carrier rejected the message because the sender isn't registered for A2P/10DLC messaging."
18 }
19}

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

1{
2 "id": "evt_2b8e5f1a9c4d7e0f3a6b8c1d5e2f4a7b9c0d3e6f",
3 "object": "event",
4 "type": "message.status_changed",
5 "version": 1,
6 "createdAt": "2026-08-20T09:20:41.552Z",
7 "relatedObject": { "id": "msg_01HW3X8Q6RG7L3ZR0ZK8R9S6P4", "type": "message", "url": null },
8 "data": {
9 "messageId": "msg_01HW3X8Q6RG7L3ZR0ZK8R9S6P4",
10 "phoneNumberId": "clx8k2j9h0001mk08w3t5r7qz",
11 "from": "+14155559876",
12 "to": "+14155550123",
13 "channel": "imessage",
14 "changed": "read",
15 "deliveryState": "delivered",
16 "readState": "read",
17 "deliveryError": null
18 }
19}

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 (unconfirmeddelivered) and a read event, with distinct ids. 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.

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.