> ## Documentation Index
> Fetch the complete documentation index at: https://docs.blujay.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Track a message

> Understand message statuses, which service carried a message, why it failed, and how to follow it with webhooks or polling.

After Blujay accepts a message, it moves through a series of statuses until it's read, stops at the last status the service reports, or fails. This page explains each status, what each service can report, how to read failures, and how to follow a message with webhooks or polling.

## Statuses

```mermaid theme={null}
flowchart LR
  A[accepted] --> B[sending] --> C[sent] --> D[delivered] --> E[read]
  A --> F[failed]
  B --> F
  F -. rare .-> C
  G[received]
```

| Status | Meaning | Webhook event |
| - | - | - |
| `accepted` | Blujay accepted and queued the message. The `202` send response shows this as `delivery_status: queued`. | `message.accepted` |
| `sending` | Your Blujay phone number is sending the message, and Blujay is waiting for confirmation. | None |
| `sent` | The message left your number. | `message.sent` |
| `delivered` | The recipient's device confirmed delivery. | `message.delivered` |
| `read` | The recipient read the message. | `message.read` |
| `failed` | The message couldn't be sent. See [Failures](#failures). | `message.failed` |
| `received` | An inbound message from a contact. It never changes status. See [Receive messages](/messaging/receive-messages.md). | `message.received` |

A message doesn't always pass through every status. It can go straight from `accepted` to `delivered`, and a message can stay at `sent` for good. Always act on the latest status rather than waiting for a particular sequence.

`failed` is normally final. Rarely, Blujay confirms afterwards that a failed message did go out, and it moves on to `sent`, `delivered` or `read`. Treat the newest status as the truth.

`delivered` and `read` are reported for direct chats. A group chat may not report them: a group message can stay at `sent` even when everyone has it. Don't wait for `delivered` on a group message.

## What each service reports

How far a message gets depends on the service that carried it.

| Service | Statuses you can expect after `accepted` |
| - | - |
| iMessage | `sent`, then `delivered`. `read` only when the recipient shares read receipts. |
| RCS | `sent`, and `delivered` or `read` when they're reported. |
| SMS | `sent`. |

> [!WARNING]
>
> Never wait for an SMS message to reach `delivered` or `read`. For SMS, `sent` is usually the last status you get, and it's a success.

## Which service carried the message

Every message has two service fields:

| Field | Meaning |
| - | - |
| `preferred_service` | What you asked for in the send: `iMessage`, `RCS` or `SMS`, or `null` when you let Blujay choose (Auto). |
| `service` | The service that actually carried the message. `null` until Blujay confirms it. |

With Auto, both start as `null`. That's expected, not an error. `service` fills in as the message is sent.

Webhook payloads carry the same information in lowercase (`imessage`, `rcs`, `sms`). In webhooks, the field for what you asked for is called `requested_service`. From webhook version `2026-08-20` on, it holds the same value as `preferred_service`. See [Webhook versions](/webhooks/versions.md).

To choose a service yourself, see [Send messages](/messaging/send-messages.md#choose-a-service-or-use-auto) and [iMessage, RCS or SMS](/messaging/choosing-a-service.md).

## Failures

A failed message carries `failed_at`, a `failure` object with a lowercase `code` and a readable `reason`, and `retryable`, on the message itself (`GET /v1/messages/{message_id}` and the list endpoints) and in the `message.failed` webhook alike. `retryable: true` means `POST /v1/messages/{message_id}/retry` will be accepted; `false` means it returns `409` [`RETRY_NOT_ALLOWED`](/errors/retry-not-allowed).

For five reasons the message also carries a `failure_code` that names the exact problem:

| `failure_code` | Meaning | What to do |
| - | - | - |
| `ATTACHMENT_FETCH_FAILED` | Blujay couldn't download a media URL. | Check the URL is public and serves the file, or upload the file instead. See [Media](/messaging/media.md). |
| `ATTACHMENT_TOO_LARGE` | A file from a media URL is too large, or over the SMS/MMS size limit. | Send a smaller file, or upload it. |
| `UNSUPPORTED_ATTACHMENT_TYPE` | A file isn't a supported type, or the service that carried the message can't send it. | Send a supported type, or upload it. |
| `SENDER_RETIRED` | The sending number was retired. | Start a new chat from an active number. |
| `REPLY_REQUIRES_IMESSAGE` | A threaded reply could only go out as SMS or RCS, so Blujay didn't send it. | Send it again without `reply_to`. See [Threaded replies](/messaging/replies.md). |

Every other failure has no `failure_code`; `failure.code` still says what happened. To find failed messages, call `GET /v1/messages?status=failed`. In the `message.failed` webhook, versions `2026-08-01` and later carry the `failure` object, and `2026-09-02` and later also carry `failure_code` when there is one.

| `failure.code` | Meaning |
| - | - |
| `recipient_unavailable` | The recipient couldn't be reached on the service you asked for, for example `preferred_service: iMessage` for a number that doesn't use iMessage. |
| `service_mismatch` | The message wasn't sent with the service you asked for. |
| `unsupported_content` | The message has content the selected service can't send, such as a PDF over SMS or media over the SMS/MMS size limit. `failure_code` is `UNSUPPORTED_ATTACHMENT_TYPE` or `ATTACHMENT_TOO_LARGE`. |
| `reply_requires_imessage` | A threaded reply could only go out as SMS or RCS. |
| `sender_unavailable` | The sending number was unavailable. |
| `sender_retired` | Your phone number was replaced before the message went out. |
| `dispatch_unavailable` | Blujay couldn't hand the message off for sending. |
| `network_rejected` | The messaging network rejected the message. |
| `delivery_failed` | Any other failure. |

To send a failed message again, see [Retry a failed message](/messaging/send-messages.md#retry-a-failed-message).

## Degradations

Some features only exist on iMessage. When a direct message asks for one of them and goes out as SMS or RCS, Blujay still sends the text and lists what it dropped in `degradations`:

```json theme={null}
"degradations": [
  {
    "code": "effect_not_delivered",
    "reason": "The iMessage effect was not delivered because the message was sent as SMS or RCS."
  }
]
```

| `code` | Dropped feature |
| - | - |
| `effect_not_delivered` | A screen or bubble [effect](/messaging/effects.md). |
| `text_decorations_not_delivered` | Bold, italic, strikethrough or underline text styles. |

`degradations` is an empty array when nothing was dropped. Message reads and message webhooks carry the same entries.

## Follow a message

You can follow a message with webhooks, by polling, or both.

### With webhooks (recommended)

Subscribe to the message events you need, and Blujay posts each status change to your server as it happens.

```json message.delivered theme={null}
{
  "api_version": "v1",
  "webhook_version": "2026-10-03",
  "event_type": "message.delivered",
  "event_id": "evt_abc123",
  "created_at": "2026-10-14T17:01:04.000Z",
  "trace_id": "trc_8f2a1c0e4b7d",
  "workspace_id": "workspace_abc123",
  "subscription_id": "whsub_abc123",
  "data": {
    "message_id": "msg_abc123",
    "chat_id": "chat_abc123",
    "phone_number_id": "phone_abc123",
    "status": "delivered",
    "direction": "outbound",
    "requested_service": null,
    "service": "imessage",
    "sent_at": "2026-10-14T17:01:02.000Z",
    "delivered_at": "2026-10-14T17:01:04.000Z",
    "read_at": null,
    "failed_at": null,
    "failure": null,
    "degradations": [],
    "parts": [
      { "type": "text", "text": "Hi Ava, this is Sam from the Northwind support team. Reply here any time." }
    ],
    "reply_to": null
  }
}
```

* Webhooks are delivered at least once and can arrive out of order. Deduplicate on `event_id`, and never replace a newer status with an older one. Use the `sent_at`, `delivered_at` and `read_at` timestamps to order them.
* `message.accepted` is sent only for messages sent through the API. `message.sent`, `message.delivered`, `message.read` and `message.failed` also cover messages your team sends from Blujay.
* There's no event for `sending`.
* The message object and the webhook payload carry the same tracking fields: `direction`, `sent_at`, `delivered_at`, `read_at`, `failed_at`, `failure`, and `sender` on inbound group messages. Webhooks tell you when they change; reads give you the current values.

Set up an endpoint in [Webhooks](/webhooks/overview.md), and see every payload in [Webhook events](/webhooks/events.md).

### By polling

`GET /v1/messages/{message_id}` returns the current status:

```bash theme={null}
curl https://api.blujay.io/v1/messages/msg_abc123 \
  -H "Authorization: Bearer blu_your_api_key"
```

```json theme={null}
{
  "status": "success",
  "data": {
    "message_id": "msg_abc123",
    "chat_id": "chat_abc123",
    "contact_id": "contact_abc123",
    "phone_number_id": "phone_abc123",
    "preferred_service": null,
    "service": "SMS",
    "status": "sent",
    "parts": [
      { "type": "text", "text": "Hi Ava, this is Sam from the Northwind support team. Reply here any time." }
    ],
    "degradations": [],
    "reply_to": null,
    "edited_at": null,
    "edit_count": 0,
    "unsent_at": null,
    "unsent_scope": null,
    "trace_id": "trc_8f2a1c0e4b7d",
    "created_at": "2026-10-14T17:01:00.000Z",
    "updated_at": "2026-10-14T17:01:03.000Z"
  },
  "trace_id": "trc_51b9e07a3c6d",
  "timestamp": "2026-10-14T17:01:30.000Z"
}
```

Poll with a growing delay (for example 2, 4, 8 seconds, up to 30 seconds between polls) and stop at a status that is final for your use:

| `service` | Stop polling at |
| - | - |
| `SMS` | `sent` or `failed` |
| `iMessage` | `delivered`, `read` or `failed`. If it stays at `sent` for 10 minutes, stop polling and rely on webhooks for any later change. |
| `RCS` | `delivered`, `read` or `failed`, with the same 10-minute cap. |
| `null` (not confirmed yet) | Keep polling. |

Webhooks are better than polling for anything after that, including the rare `failed` that later becomes `sent`. To catch up on many messages at once, call `GET /v1/messages?updated_after=2026-10-14T17:00:00.000Z` with the time of your last sync. Reads count toward your read limit; see [Rate limits](/rate-limits.md).

Polling is fine for testing and for reconciling. In production, use webhooks and fall back to `GET /v1/messages/{message_id}` when an event seems missing.

> [!WARNING]
>
> **Common mistakes**
>
> * The `202` send response uses `id` and `delivery_status: queued`. Reads use `message_id` and `status: accepted`. They describe the same message.
> * `updated_at` moves whenever anything changes, including an edit or a read receipt. Order a conversation by `created_at`.
> * A `status: failed` with no `failure_code` is normal. The reason is in the `message.failed` webhook.

## Limitations

* `failure_code` covers five reasons you can act on. Every failed message also carries a `failure` object with a lowercase `code` and a readable `reason`, the same one its `message.failed` webhook carries, and `retryable`, which says whether `POST /v1/messages/{message_id}/retry` would be accepted.
* SMS never reports `delivered` or `read`. Group chats may not report them either.
* `sending` has no webhook event.

## Next steps

> **[Webhooks overview](/webhooks/overview.md)**
>
> Get status changes pushed to your server.

> **[Webhook events](/webhooks/events.md)**
>
> Every message event and its payload.

> **[Receive messages](/messaging/receive-messages.md)**
>
> Handle inbound texts, media and group replies.

> **[Send messages](/messaging/send-messages.md)**
>
> Start chats, send follow-ups and retry failures.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.