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

# Send messages

> Start a chat with a first message, send follow-ups, and read back chats and messages.

Every conversation in Blujay is a chat between one of your Blujay phone numbers and one or more recipients. You start a chat and send its first message with `POST /v1/chats`, then send everything after that with `POST /v1/chats/{chat_id}/messages`. Both calls return `202 Accepted` as soon as Blujay has queued the message, and the message is sent in the background.

## Send the first message

`POST /v1/chats` starts a chat from one of your numbers and sends its first message. One number in `to` starts a direct chat. Two or more start a group chat (see [Group chats](/messaging/group-chats.md)).

```bash curl theme={null}
curl https://api.blujay.io/v1/chats \
  -H "Authorization: Bearer blu_your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-welcome" \
  -d '{
    "from": "+15555550100",
    "to": ["+15555550123"],
    "message": {
      "parts": [
        { "type": "text", "text": "Hi Ava, this is Sam from the Northwind support team. Reply here any time." }
      ]
    }
  }'
```

```javascript Node.js theme={null}
const response = await fetch("https://api.blujay.io/v1/chats", {
  method: "POST",
  headers: {
    Authorization: "Bearer blu_your_api_key",
    "Content-Type": "application/json",
    "Idempotency-Key": "order-1042-welcome",
  },
  body: JSON.stringify({
    from: "+15555550100",
    to: ["+15555550123"],
    message: {
      parts: [
        { type: "text", text: "Hi Ava, this is Sam from the Northwind support team. Reply here any time." },
      ],
    },
  }),
});

const body = await response.json();
if (body.status === "error") {
  throw new Error(`${body.error.code}: ${body.error.message} (trace ${body.trace_id})`);
}
console.log(response.status, body.data.chat_id, body.data.message.id);
```

```python Python theme={null}
import requests

response = requests.post(
    "https://api.blujay.io/v1/chats",
    headers={
        "Authorization": "Bearer blu_your_api_key",
        "Idempotency-Key": "order-1042-welcome",
    },
    json={
        "from": "+15555550100",
        "to": ["+15555550123"],
        "message": {
            "parts": [
                {"type": "text", "text": "Hi Ava, this is Sam from the Northwind support team. Reply here any time."}
            ]
        },
    },
    timeout=30,
)

body = response.json()
if body["status"] == "error":
    raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} (trace {body['trace_id']})")
print(response.status_code, body["data"]["chat_id"], body["data"]["message"]["id"])
```

```json 202 Accepted theme={null}
{
  "status": "success",
  "data": {
    "chat_id": "chat_abc123",
    "message": {
      "id": "msg_abc123",
      "direction": "outbound",
      "delivery_status": "queued",
      "preferred_service": null,
      "service": null,
      "from": "+15555550100",
      "to": ["+15555550123"],
      "created_at": "2026-10-14T17:01:00.000Z",
      "parts": [
        { "type": "text", "text": "Hi Ava, this is Sam from the Northwind support team. Reply here any time." }
      ]
    }
  },
  "trace_id": "trc_8f2a1c0e4b7d",
  "timestamp": "2026-10-14T17:01:00.000Z"
}
```

Save `data.chat_id` for follow-ups and `data.message.id` to track the message.

* `from` is one of your Blujay phone numbers, as a string in E.164 (`"+15555550100"`) or a common US format. List your numbers with [`GET /v1/phone-numbers`](/phone-numbers) and pick one whose `send_status.allowed` is `true`.
* For a direct chat, `from` is optional. When you leave it out, Blujay uses the number the recipient already knows you on. If there is none, it uses your only number, or the default number of the teammate the contact is assigned to. If none of those applies, the request returns `422` [`FROM_NUMBER_REQUIRED`](/errors/from-number-required). A group chat always needs `from`.
* `to` takes US phone numbers, in E.164 (`+15555550123`) or a common US format such as `(555) 555-0123`. Email addresses aren't accepted as recipients.
* If you already have a direct chat with that recipient from the same number, the message goes into that chat and you get its `chat_id` back.

### Rules for a first message

Until the contact replies to your number, Blujay rejects a send with `400` when it contains:

* a link, in a `link` part or in the text ([`FIRST_MESSAGE_LINK_NOT_ALLOWED`](/errors/first-message-link-not-allowed) on `POST /v1/chats`, [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked) on follow-ups),
* a `media` or `vcard` part ([`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked)),
* text over 300 characters ([`PRE_REPLY_LENGTH_EXCEEDED`](/errors/pre-reply-length-exceeded)).

At most 6 texts go out in a row without a response ([`PRE_REPLY_LIMIT_REACHED`](/errors/pre-reply-limit-reached)). A first message also can't be a threaded reply or carry an effect or text styles: `reply_to` returns `400` [`VALIDATION_ERROR`](/errors/validation-error), and `effect` or `text_decorations` return `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled).

The full rules, what counts as a response, and the group variants are in [Sending rules](/messaging/sending-rules.md).

> [!TIP]
>
> Blujay treats anything that looks like a domain name as a link, including `example.com` without `https://` and the domain in an email address such as `help@example.com`. Keep those out of a first message too.

These rules apply to every `POST /v1/chats` request, even when the recipient already has a chat with your number. Use `POST /v1/chats/{chat_id}/messages` for everything after the first message.

## What `202 Accepted` means

`202` means Blujay validated the request, saved the message and queued it for sending. It doesn't mean the message was sent or delivered.

| Field | At acceptance |
| - | - |
| `delivery_status` | Always `queued`. Read the live status from `GET /v1/messages/{message_id}`, where a queued message shows as `accepted`. |
| `preferred_service` | The service you asked for, or `null` when you let Blujay choose. |
| `service` | `null`. It's set once Blujay confirms whether the message went out as iMessage, RCS or SMS. |
| `parts` | The parts as Blujay will send them. A media URL or a `vcard` part comes back as a `media` part with a new `attachment_id`. |

> [!NOTE]
>
> **Send vs read field names.** The `202` body names the message `id` and its state `delivery_status`. Every read (`GET /v1/messages/{message_id}`, the list endpoints) and every webhook names them `message_id` and `status`. Reads also drop `from`, `to` and `direction`. Map `data.message.id` to `message_id` when you store it.

Follow the message to `sent`, `delivered`, `read` or `failed` with [webhooks](/webhooks/overview.md) or by polling. See [Track a message](/messaging/lifecycle.md).

If the request times out or you get a `5xx`, send it again with the **same** `Idempotency-Key`. If Blujay already accepted it, you get `200` with the original response instead of a second message. A request Blujay rejected with a `4xx` isn't remembered under its key, so you can fix the request and send it again with the same key. See [Idempotency](/idempotency.md).

## Send a follow-up

Send every message after the first with `POST /v1/chats/{chat_id}/messages`. The body is a `message` object with the same shape as in `POST /v1/chats`.

```bash theme={null}
curl https://api.blujay.io/v1/chats/chat_abc123/messages \
  -H "Authorization: Bearer blu_your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-link" \
  -d '{
    "message": {
      "parts": [{ "type": "link", "value": "https://example.com/orders/1042" }]
    }
  }'
```

The response is the same `202` shape as the first send. Once the contact has replied, follow-ups can use every part type, including links, plus [threaded replies](/messaging/replies.md) and [effects and text styles](/messaging/effects.md). Until then the [first-message rules](#rules-for-a-first-message) still apply.

## Message parts

A message is a list of `parts`, sent in order.

| Part | Fields | Notes |
| - | - | - |
| `text` | `text` | Up to 10,000 characters per part, and 300 characters of text in the whole message until the contact replies. Must contain more than whitespace. Add `text_decorations` for [text styles](/messaging/effects.md#style-text). |
| `link` | `value` | An `https://` URL up to 2,048 characters. Over iMessage the recipient sees a link preview; over SMS and RCS, how the link looks depends on the recipient's phone. It must be the only part in the message, and it's rejected until the contact replies. |
| `media` | `attachment_id` or `url` | A file you uploaded, or a public HTTPS URL. Rejected until the contact replies. See [Media](/messaging/media.md). |
| `vcard` | `vcard_id` | A saved contact card. Rejected until the contact replies. See [Contact cards](/messaging/contact-cards.md). |

A message has 1 to 100 parts, at most 40 of them `media` or `vcard`, and at most 40,000 characters of text in total. Text and media parts can be mixed, for example a caption followed by a photo:

```json theme={null}
{
  "message": {
    "parts": [
      { "type": "text", "text": "Here's the replacement part we're sending." },
      { "type": "media", "url": "https://example.com/images/part-7731.jpg" }
    ]
  }
}
```

Other optional `message` fields:

| Field | What it does |
| - | - |
| `preferred_service` | Pick the service for a direct chat. See below. |
| `reply_to` | Send an iMessage threaded reply. Follow-ups only. See [Threaded replies](/messaging/replies.md). |
| `effect` | Add an iMessage screen or bubble effect. Follow-ups only. See [Effects](/messaging/effects.md). |
| `idempotency_key` | An alternative to the `Idempotency-Key` header. If you send both, they must match, or you get `409` [`IDEMPOTENCY_KEY_MISMATCH`](/errors/idempotency-key-mismatch). |
| `client_reference` | Your own reference for the message, up to 255 characters. It comes back as `client_reference` whenever you read the message. Webhook payloads don't carry it; map `message_id` to your reference when you need it there. |

One optional field sits next to `message` at the top level of the request:

| Field | What it does |
| - | - |
| `pre_reply_override` | `true` lets one more text through after the 6-in-a-row limit (the 7th). Nothing else goes out until the contact responds. See [Sending rules](/messaging/sending-rules.md). |

Unknown fields are rejected with `400` [`VALIDATION_ERROR`](/errors/validation-error).

## Choose a service or use Auto

In a direct chat, leave out `preferred_service` to use **Auto**: Blujay chooses iMessage, RCS or SMS for the recipient. Auto is the right choice for most messages.

Set `preferred_service` only when you need a specific outcome. Values are case-sensitive.

| Value | What happens |
| - | - |
| *(omitted)* | Auto. Blujay picks the service, and `service` reports which one carried the message. |
| `iMessage` | iMessage only. If Blujay can't confirm the recipient uses iMessage, the message fails instead of going out another way. The message has no `failure_code`; the `message.failed` webhook carries `failure.code: recipient_unavailable`. |
| `RCS` or `SMS` | Keeps the message off iMessage. It goes out as RCS or SMS, whichever the recipient's number supports. Check `service` for the one that carried it. |

Group chats don't accept `preferred_service` and return `400` [`VALIDATION_ERROR`](/errors/validation-error). To check a number before you send, see [iMessage, RCS or SMS](/messaging/choosing-a-service.md).

## Handle send errors

Most send errors come back before anything is queued, so nothing was sent. None of the `400` and `403` errors below is retryable; fix the request or wait for the contact.

| Error | What to do |
| - | - |
| `400` [`VALIDATION_ERROR`](/errors/validation-error) | A field is missing, malformed or not allowed here (for example `reply_to` on a first message, or `preferred_service` on a group). Read `error.details`. |
| `400` [`FIRST_MESSAGE_LINK_NOT_ALLOWED`](/errors/first-message-link-not-allowed) | The first message contains a link. Send it as a follow-up once the contact replies. |
| `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked) | The contact hasn't replied yet and the message has a link, media or a contact card. Send text only. |
| `400` [`PRE_REPLY_LENGTH_EXCEEDED`](/errors/pre-reply-length-exceeded) | The contact hasn't replied yet and the text is over 300 characters. Shorten it. |
| `400` [`PRE_REPLY_LIMIT_REACHED`](/errors/pre-reply-limit-reached) | You've sent 6 texts without a response. Wait for the contact, or send one more with `pre_reply_override: true`. |
| `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) | The feature isn't available here: an effect or text styles on a first message, a group chat on the Free Plan, or a feature switched off for your workspace. |
| `403` [`CONTACT_NOT_VERIFIED`](/errors/contact-not-verified) | On the Free Plan, the recipient hasn't texted your number and been verified in the app. See [Plans](/plans.md). |
| `403` [`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first) | On the AI Agent plan, this number only replies to people who text it first, and it can't start or send to group chats. See [Plans](/plans.md). |
| `403` [`RECIPIENT_SUPPRESSED`](/errors/recipient-suppressed) | The recipient opted out. Don't retry. See [Opt-outs](/opt-outs.md). |
| `403` [`SENDER_SUSPENDED`](/errors/sender-suspended) | Sending is paused for this number. Send from another number or contact support. |
| `422` [`FROM_NUMBER_REQUIRED`](/errors/from-number-required) | You left out `from` and Blujay couldn't pick a number for this recipient. Set `from`. |
| `429` [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded) or [`QUEUE_FULL`](/errors/queue-full) | The number hit a sending limit or its queue is full. Wait `Retry-After` seconds. Each number's live limits are in `limits` on [`GET /v1/phone-numbers`](/phone-numbers). |
| `503` [`QUEUE_UNAVAILABLE`](/errors/queue-unavailable) | Blujay couldn't accept the message right now. Retry with the same `Idempotency-Key`. |
| `409` [`SENDER_RETIRED`](/errors/sender-retired) | Follow-ups only: the chat's number was retired. Start a new chat from an active number. |
| `409` [`IDEMPOTENCY_IN_PROGRESS`](/errors/idempotency-in-progress) | The first request with this key is still running. Retry shortly with the same key. |
| `409` [`IDEMPOTENCY_REQUEST_MISMATCH`](/errors/idempotency-request-mismatch) | You reused a key with a different body. Use a new key for a new message. |
| `422` [`ATTACHMENT_FETCH_FAILED`](/errors/attachment-fetch-failed) | A media URL isn't safe to fetch. See [Media](/messaging/media.md). |
| `404` [`NOT_FOUND`](/errors/not-found) | The chat, attachment or vCard doesn't exist in your workspace. |

Every error carries a `trace_id`. Include it when you contact support. See [Errors](/errors.md) for the envelope.

## List chats and messages

All list endpoints return a page of results, newest change first. Pass `limit` (1 to 100, default 25) and, for the next page, the previous page's `next_cursor` as `cursor`. Stop when `has_more` is `false`.

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

```json theme={null}
{
  "status": "success",
  "data": {
    "data": [
      {
        "message_id": "msg_def456",
        "chat_id": "chat_abc123",
        "contact_id": "contact_abc123",
        "phone_number_id": "phone_abc123",
        "preferred_service": null,
        "service": "iMessage",
        "status": "received",
        "parts": [{ "type": "text", "text": "Thanks, got it!" }],
        "degradations": [],
        "reply_to": null,
        "edited_at": null,
        "edit_count": 0,
        "unsent_at": null,
        "unsent_scope": null,
        "created_at": "2026-10-14T17:04:12.000Z",
        "updated_at": "2026-10-14T17:04:12.000Z"
      },
      {
        "message_id": "msg_abc123",
        "chat_id": "chat_abc123",
        "contact_id": "contact_abc123",
        "phone_number_id": "phone_abc123",
        "preferred_service": null,
        "service": "iMessage",
        "status": "read",
        "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:03:40.000Z"
      }
    ],
    "next_cursor": "cur1.example",
    "has_more": true,
    "limit": 2
  },
  "trace_id": "trc_3d9b7e1c2a4f",
  "timestamp": "2026-10-14T17:05:00.000Z"
}
```

An inbound message has `status: received`. Messages are ordered by when they last changed (`updated_at`), not when they were sent. An edit, a read receipt or any status change moves a message back to the top, so a message can appear on a page you already fetched. Deduplicate on `message_id`, and sort by `created_at` if you're showing a conversation. The `trace_id` on a message is the trace of the request that sent it; inbound messages have none.

| Endpoint | Returns |
| - | - |
| `GET /v1/chats` | Your chats, most recently active first. |
| `GET /v1/chats/{chat_id}` | One chat, with its participants, the number it uses (`phone_number_id`) and its confirmed `service`. |
| `GET /v1/chats/{chat_id}/messages` | The messages in one chat. |
| `GET /v1/messages` | Messages across every chat. Filter with `status` (for example `failed`) or `updated_after` (an ISO 8601 time) to sync only what changed. There are no other filters. |
| `GET /v1/messages/{message_id}` | One message with its current status. |

> [!WARNING]
>
> **Common mistakes**
>
> * `202` means queued, not delivered. Track the message before you tell anyone it arrived.
> * `from` is a string (`"+15555550100"`), not an array and not a `phone_number_id`. Endpoints that take one of your numbers by ID use `from_phone_number_id`.
> * URL-encode `+` in query strings (`%2B15555550123`), or the server reads it as a space.
> * `next_cursor` values are opaque. Pass them back exactly as returned; never send a placeholder such as `cursor=string`.
> * The `202` field `id` is the `message_id` you read back later. `delivery_status: queued` is `status: accepted` on reads.

## Retry a failed message

`POST /v1/messages/{message_id}/retry` sends a failed message again with the same `message_id`.

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

A successful retry returns `202` with the same shape as a send, and the message's status goes back to `accepted`.

A failed message's `retryable` field tells you whether retry is possible. `false` means the message may already have reached the recipient, so check the conversation before you send a replacement. `true` means it never left Blujay; retry can still be refused if the recipient has since opted out or the number can no longer send. Only direct-chat messages that failed before they left Blujay can be retried. Everything else returns `409` [`RETRY_NOT_ALLOWED`](/errors/retry-not-allowed), including group messages and messages that may already have reached the recipient. Blujay refuses those so the recipient never gets a duplicate. No field on the message tells you in advance whether a retry is allowed; the call is the check. When you get `RETRY_NOT_ALLOWED`, don't automatically send a replacement: check the message status and the conversation first, and send a new message only when you're sure the first one didn't arrive or a duplicate is acceptable. Retrying an inbound message returns `400` [`INVALID_MESSAGE`](/errors/invalid-message).

## Limitations

* Recipients must be US phone numbers. Email addresses return `400` [`VALIDATION_ERROR`](/errors/validation-error).
* Until the contact replies: no links, media or contact cards (`400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked)), 300 characters per text (`400` [`PRE_REPLY_LENGTH_EXCEEDED`](/errors/pre-reply-length-exceeded)), 6 texts in a row (`400` [`PRE_REPLY_LIMIT_REACHED`](/errors/pre-reply-limit-reached)). See [Sending rules](/messaging/sending-rules.md).
* Free Plan numbers send only to verified contacts (`403` [`CONTACT_NOT_VERIFIED`](/errors/contact-not-verified)) and can't use group chats (`400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled)). AI Agent numbers only reply to people who text first (`403` [`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first)). See [Plans](/plans.md).
* A `link` part must be the only part in its message.
* `preferred_service` works in direct chats only (`400` [`VALIDATION_ERROR`](/errors/validation-error) in groups).
* Webhook payloads don't carry `client_reference`; reads do.
* Retry works only for direct-chat messages that failed before leaving Blujay; check `retryable` on the message first (`409` [`RETRY_NOT_ALLOWED`](/errors/retry-not-allowed)).

## Next steps

> **[Sending rules](/messaging/sending-rules.md)**
>
> The pre-reply rules, what counts as a response, and the exact error codes.

> **[Track a message](/messaging/lifecycle.md)**
>
> Statuses, services and failure codes, and how to follow them.

> **[Media](/messaging/media.md)**
>
> Upload files once or send them from a URL.

> **[Idempotency](/idempotency.md)**
>
> Retry any send safely without sending twice.


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