message.status_changed
message.status_changed
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
readState
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
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:
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:
And an iMessage being read — note that this same receipt also produces a changed: "delivery" event, because the read proves the message was delivered:
When it fires
- Once per axis, per message. A message emits at most one
deliveryevent and at most onereadevent. Deduplicate on the envelopeid, which is stable per (message, axis, state) — a repeated carrier callback collapses onto the sameid. - 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
deliveryevent (unconfirmed→delivered) and areadevent, with distinctids. If you only care that the message landed, filter onchanged: "delivery"; if you only care about reads, filter onchanged: "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
readevent, becausereadStateisunsupportedfrom 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.