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

# Send a message

> Send text, media, or both to one recipient over iMessage with automatic RCS then SMS fallback, using POST /send-message.

`send-message` delivers one message to one recipient. It tries iMessage first, falls back to RCS, then to SMS, and returns immediately with a message id you can track. Use it for one-off sends, CRM-triggered follow-ups, and anything an agent or workflow fires in real time.

The call is asynchronous. A `200`-class response means BlueReacher accepted the message and queued it, not that the recipient has it. Delivery state arrives on your webhook.

## Endpoint

```
POST https://api.bluereacher.com/functions/v1/send-message
```

| Property       | Value                                                      |
| -------------- | ---------------------------------------------------------- |
| Method         | `POST`                                                     |
| Content type   | `application/json`                                         |
| Success status | `202 Accepted` on create, `200 OK` on an idempotent replay |
| Rate limit     | 100 requests per minute per workspace                      |

Per-line send pacing is handled for you. Queue as fast as the rate limit allows and BlueReacher spaces the actual sends to protect your line.

## Authentication

Every request carries a bearer token:

```
Authorization: Bearer bluereacher_your_api_key
Content-Type: application/json
```

Keys start with `bluereacher_` and are scoped to one workspace. Create and rotate them in the dashboard under Settings, API keys. Send requests from your server only, never from a browser or mobile client. A missing, malformed, or revoked key returns `401`.

## Request fields

| Field          | Type             | Required    | Description                                                                                                                                                                                                |
| -------------- | ---------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `to`           | string           | Yes         | The recipient. Either an E.164 phone number (`+14155550142`) or an email address registered with iMessage (`dana@example.com`). Email recipients are iMessage only, so there is no fallback path for them. |
| `content`      | string           | Conditional | Message body. Required unless `media_url` is set. Maximum 2,000 characters, UTF-8, emoji allowed.                                                                                                          |
| `media_url`    | array of strings | No          | Up to 5 public HTTPS URLs to attach. Required if `content` is omitted.                                                                                                                                     |
| `line_id`      | string           | No          | The dedicated line to send from. Omit it and BlueReacher picks: the line already used with this contact if there is one, otherwise the least-loaded active line in your workspace.                         |
| `channel`      | string           | No          | Channel preference: `imessage`, `rcs`, or `sms`. Defaults to `imessage`. Sets where the delivery chain starts, not where it ends. See [Channel selection and fallback](#channel-selection-and-fallback).   |
| `external_id`  | string           | No          | Your own identifier for this message. Doubles as the idempotency key. Maximum 128 characters. Strongly recommended on every send.                                                                          |
| `scheduled_at` | string           | No          | ISO 8601 timestamp for a future send, with an offset or `Z` (`2026-07-28T14:00:00-05:00`). Between 60 seconds and 90 days from now.                                                                        |
| `subject`      | string           | No          | Optional subject line, maximum 128 characters. Does not count toward the 2,000 character `content` limit.                                                                                                  |

Unknown fields are rejected with `400` rather than ignored, so a typo in a field name fails loudly instead of silently dropping data.

## Response fields

| Field          | Type           | Description                                                                                                                                                                  |
| -------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`           | string         | BlueReacher message id, prefixed `msg_`. Use it to fetch the message later and to match webhook events.                                                                      |
| `status`       | string         | `queued` for an immediate send, `scheduled` when `scheduled_at` is set.                                                                                                      |
| `channel`      | string         | The channel the message is queued on: `imessage`, `rcs`, or `sms`. This is the intended channel at queue time. The final channel is confirmed on the `message.sent` webhook. |
| `to`           | string         | The recipient, normalized to E.164 when you passed a phone number.                                                                                                           |
| `from`         | string         | The E.164 number of the line sending this message.                                                                                                                           |
| `line_id`      | string         | The line that will send, whether you set it or BlueReacher selected it.                                                                                                      |
| `external_id`  | string or null | Your identifier, echoed back. `null` if you did not send one.                                                                                                                |
| `scheduled_at` | string or null | The scheduled send time in UTC, or `null`.                                                                                                                                   |
| `created_at`   | string         | ISO 8601 UTC timestamp of when BlueReacher accepted the request.                                                                                                             |

## Example request

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/send-message \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155550142",
    "content": "Hi Dana, saw you just opened the Austin office. Worth 10 minutes this week?",
    "external_id": "crm_88213-step1"
  }'
```

With media, a specific line, and a scheduled send:

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/send-message \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155550142",
    "content": "Here is the one-pager we talked about.",
    "media_url": ["https://cdn.example.com/decks/roi-onepager.pdf"],
    "line_id": "line_9f2c41",
    "scheduled_at": "2026-07-28T14:00:00-05:00",
    "external_id": "crm_88213-step2"
  }'
```

## Example response

`202 Accepted`

```json theme={null}
{
  "id": "msg_01J8ZC4K2QW3RTY6H9NBVX",
  "status": "queued",
  "channel": "imessage",
  "to": "+14155550142",
  "from": "+16195550188",
  "line_id": "line_9f2c41",
  "external_id": "crm_88213-step1",
  "scheduled_at": null,
  "created_at": "2026-07-24T18:41:07Z"
}
```

## Idempotency with external\_id

Set `external_id` on every send. It is your key for two jobs at once: matching messages back to your own records, and making retries safe.

How it behaves:

* The first request with a given `external_id` creates the message and returns `202` with a new `id`.
* Any later request with the same `external_id` in the same workspace returns the original message body with `200` and the header `Idempotent-Replay: true`. Nothing is sent twice.
* Replaying an `external_id` with a different request body returns `409 idempotency_conflict`. Nothing is sent, and the original message is untouched.
* Keys are held for 30 days from first use. After that, the same value creates a new message.
* `external_id` comes back on every webhook for the message, so you can thread replies and delivery events onto your record without storing the BlueReacher `id`.

Good values are a UUID, or a CRM record id plus the sequence step: `crm_88213-step2`. Bad values are anything reused across contacts, like the campaign name.

Retry `5xx` responses, `429`, and network timeouts with the exact same body and the same `external_id`. Use exponential backoff. If the original request landed before the connection dropped, the retry returns the existing message instead of sending a duplicate.

```javascript theme={null}
async function sendMessage(payload) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch("https://api.bluereacher.com/functions/v1/send-message", {
      method: "POST",
      headers: {
        "Authorization": "Bearer bluereacher_your_api_key",
        "Content-Type": "application/json"
      },
      body: JSON.stringify(payload)
    });

    // 4xx other than 429 will not succeed on retry. Surface it.
    if (res.status < 500 && res.status !== 429) return res.json();

    await new Promise(r => setTimeout(r, 2 ** attempt * 1000));
  }
  throw new Error("send-message unavailable after 3 attempts");
}

// Same external_id on every attempt, so a retry can never double-send.
sendMessage({
  to: "+14155550142",
  content: "Following up on the Austin note.",
  external_id: "crm_88213-step2"
});
```

## Channel selection and fallback

BlueReacher checks what the recipient can actually receive at send time, then delivers on the best available channel.

| Recipient                  | Channel used | What the recipient gets                                                                                 |
| -------------------------- | ------------ | ------------------------------------------------------------------------------------------------------- |
| iMessage available         | `imessage`   | Blue bubble, typing indicator, delivered and read receipts                                              |
| No iMessage, RCS available | `rcs`        | Rich formatting and high-resolution media, delivered receipt, read receipt where the carrier reports it |
| Neither                    | `sms`        | Plain text, MMS when media is attached, delivered receipt where the carrier reports it                  |

The `channel` field sets where that chain starts. `imessage` is the default and runs the full chain. `rcs` skips the iMessage attempt and starts at RCS. `sms` goes straight to SMS. Fallback always continues downward from wherever you start, so `channel` narrows the options but never locks the message to one path.

Things to know about fallback:

* The decision happens at send time, not when you call the API. For a scheduled message, the channel can differ from the `channel` value in the response if the recipient's situation changed in between.
* The `channel` in the API response is the intended channel. The `message.sent` webhook carries the channel that was actually used. Report on the webhook value.
* Read receipts exist on iMessage. On RCS and SMS you get delivery state only, and SMS delivery depends on what the carrier reports back.
* Media is re-encoded for the channel it lands on. See [Character and media limits](#character-and-media-limits).
* Long messages split into segments on the SMS path. iMessage and RCS send them whole.
* iMessage sends are unlimited on your line. RCS and SMS fallback traffic is metered per message and shows up on your workspace usage page.
* The iMessage path has no A2P registration required. SMS fallback travels carrier networks, so carrier rules apply to that traffic. SMS fallback is off by default on new workspaces and your account manager turns it on.
* To avoid fallback entirely, run the number through the availability check first and only send to iMessage-capable numbers.

If the recipient has opted out, nothing is sent on any channel. STOP keywords are caught automatically and suppressed across your whole workspace, and the request returns `403 recipient_opted_out`.

## Character and media limits

**Text**

| Limit             | Value                  |
| ----------------- | ---------------------- |
| `content` maximum | 2,000 characters       |
| `subject` maximum | 128 characters         |
| Encoding          | UTF-8, emoji supported |

Over 2,000 characters returns `422 content_too_long` with the actual count in the error message. On SMS fallback, text is split into standard segments (160 characters GSM-7, 70 characters when the body contains non-GSM characters such as emoji) and billed per segment. Keep cold outreach under 320 characters and the whole thing stays cheap on every channel.

**Media**

| Limit                   | Value               |
| ----------------------- | ------------------- |
| Attachments per message | 5                   |
| Size per file           | 25 MB               |
| Total per message       | 100 MB              |
| URL scheme              | HTTPS only          |
| Fetch timeout           | 10 seconds per file |

Supported types: `image/jpeg`, `image/png`, `image/gif`, `image/heic`, `image/webp`, `video/mp4`, `video/quicktime`, `audio/mpeg`, `audio/mp4`, `application/pdf`. Anything else returns `422 unsupported_media_type`.

How media is handled:

* Files are fetched server-side at send time. The URL has to be publicly reachable with no auth header. Signed URLs need to stay valid through the send, which for a scheduled message means valid at `scheduled_at`, not at request time.
* `content` sends first, then attachments in array order, all in the same thread under one message `id`.
* On SMS fallback, attachments become MMS. Files over 1 MB are compressed to fit carrier limits. Anything that cannot be compressed under the cap, such as a PDF, is replaced with a secure link in the message body that stays live for 7 days.
* A URL that returns a non-200, times out, or exceeds the size cap returns `422 media_fetch_failed` and nothing is sent. Nothing partially delivers.

## Scheduling

Set `scheduled_at` to a future ISO 8601 timestamp and the message holds in the queue until then. `status` comes back as `scheduled` instead of `queued`.

* Window: at least 60 seconds and no more than 90 days ahead. Outside that returns `422 invalid_scheduled_at`.
* Include an offset (`-05:00`) or `Z`. A timestamp with no timezone is rejected.
* Scheduled sends respect the sending window on the line. If the time lands inside a quiet window, the message goes out at the start of the next allowed window.
* Cancel a scheduled message before it fires from the Messages view in the dashboard.
* The idempotency rules are identical. A retried schedule request with the same `external_id` returns the existing scheduled message rather than queuing a second one.

## Status values

| Status      | Meaning                                                                          |
| ----------- | -------------------------------------------------------------------------------- |
| `scheduled` | Accepted and holding for `scheduled_at`.                                         |
| `queued`    | Accepted and waiting on line pacing.                                             |
| `sent`      | Handed off on the channel named in the `message.sent` webhook.                   |
| `delivered` | Confirmed on the recipient's device.                                             |
| `read`      | Recipient opened it. iMessage only, and only when they have read receipts on.    |
| `failed`    | Could not be delivered on any available channel. The webhook carries the reason. |

Only `scheduled` and `queued` ever come back from this endpoint. Everything after that arrives on your webhook.

## Errors

Errors return a single `error` object:

```json theme={null}
{
  "error": {
    "code": "content_too_long",
    "message": "content is 2,431 characters. The limit is 2,000.",
    "field": "content"
  }
}
```

| Status | Code                     | What it means                                                                             |
| ------ | ------------------------ | ----------------------------------------------------------------------------------------- |
| `400`  | `invalid_json`           | Body is not valid JSON, or contains a field this endpoint does not accept.                |
| `401`  | `invalid_api_key`        | Key is missing, malformed, or revoked. Check the `Authorization` header format.           |
| `403`  | `recipient_opted_out`    | The recipient opted out. Nothing was sent and nothing was charged. Do not retry.          |
| `403`  | `line_inactive`          | The line exists but is paused. Pick another `line_id` or omit the field.                  |
| `404`  | `line_not_found`         | No line with that `line_id` in this workspace.                                            |
| `409`  | `idempotency_conflict`   | This `external_id` was already used with a different request body. Nothing was sent.      |
| `422`  | `invalid_recipient`      | `to` is not valid E.164 and not a valid email address.                                    |
| `422`  | `empty_message`          | Neither `content` nor `media_url` was provided.                                           |
| `422`  | `content_too_long`       | `content` is over 2,000 characters.                                                       |
| `422`  | `too_many_attachments`   | More than 5 URLs in `media_url`.                                                          |
| `422`  | `media_too_large`        | A file is over 25 MB, or the request total is over 100 MB.                                |
| `422`  | `unsupported_media_type` | The fetched file is not a supported type.                                                 |
| `422`  | `media_fetch_failed`     | A `media_url` did not return a 200 within 10 seconds, or is not HTTPS.                    |
| `422`  | `invalid_scheduled_at`   | Not ISO 8601, missing a timezone, in the past, under 60 seconds out, or over 90 days out. |
| `422`  | `external_id_too_long`   | Over 128 characters.                                                                      |
| `429`  | `rate_limited`           | Over 100 requests per minute. The `Retry-After` header gives the seconds to wait.         |
| `500`  | `internal_error`         | Something failed on our side. Retry with the same `external_id`.                          |
| `503`  | `service_unavailable`    | Temporary. Retry with backoff and the same `external_id`.                                 |

Retry `429`, `500`, and `503` with the same `external_id`. Every `4xx` other than `429` fails the same way on retry, so fix the request instead.

## Next steps

* Check whether a number can receive iMessage before you spend a send: [Check availability](/api-reference/check-imessage)
* Catch replies, delivery states, and opt-outs: [Webhooks](/api-reference/webhooks)
* Background on the platform and what the API covers: [iMessage API guide](https://bluereacher.com/imessage-api)
