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

# Rich messaging features

> Send typing indicators, read receipts, tapbacks, voice notes, and contact cards from your BlueReacher lines, plus the media upload endpoint that backs them.

Plain text is one part of an iMessage thread. These six endpoints send the rest: the typing bubble, the read receipt, tapbacks, voice memos, and contact cards. They use the same base URL, the same API key, and the same field names as the messaging endpoints.

Every feature on this page is native to iMessage. When a thread is not on iMessage, the request still succeeds and BlueReacher tells you what it did instead. See [iMessage only, and what happens elsewhere](#imessage-only-and-what-happens-elsewhere).

## Endpoints at a glance

| Endpoint                      | What it does                                                         | Creates a message | Billed    |
| ----------------------------- | -------------------------------------------------------------------- | ----------------- | --------- |
| `POST /send-typing-indicator` | Shows the animated typing bubble on the recipient's screen           | No                | Free      |
| `POST /mark-read`             | Sends a read receipt so your contact sees "Read" under their message | No                | Free      |
| `POST /send-reaction`         | Adds or removes a tapback on a specific message                      | No                | Free      |
| `POST /send-voice-note`       | Sends an audio file that renders as an inline voice memo             | Yes               | 1 message |
| `POST /send-contact-card`     | Sends a vCard that renders as a tappable contact card                | Yes               | 1 message |
| `POST /upload-media`          | Hosts a file and returns a reusable `media_url`                      | No                | Free      |

## Base URL and authentication

All requests go to `https://api.bluereacher.com/functions/v1/` and are POST with a JSON body.

```bash theme={null}
Authorization: Bearer bluereacher_your_api_key
Content-Type: application/json
```

Your API key covers every line on your account. Call these endpoints from your server, never from a browser or a mobile app, because the key can send on any of your lines.

## Identifying the line and the recipient

Every endpoint on this page takes one sender and one recipient.

| What you set | Fields               | Rule                    |
| ------------ | -------------------- | ----------------------- |
| Sender       | `from` or `line_id`  | Exactly one is required |
| Recipient    | `to` or `contact_id` | Exactly one is required |

* `from` is one of your dedicated iMessage lines in E.164 format, for example `+13105550142`. Lines are managed for you, so you only need the number.
* `line_id` is the same line by its BlueReacher id, for example `line_7c2a9d41`. Use it when your line numbers can change.
* `to` is the recipient number in E.164 format.
* `contact_id` is a contact record already stored in BlueReacher. BlueReacher resolves it to the number on file.

Sending both `from` and `line_id`, or both `to` and `contact_id`, returns `400 invalid_request`.

None of these endpoints accept a `channel` field in the request. The features only exist on iMessage, so the channel is decided for you and returned in the response. To send something over another channel when iMessage is not available, use the `fallback` field described below.

## iMessage only, and what happens elsewhere

When the thread is not on iMessage, BlueReacher does not throw an error and does not silently drop your call. It returns `200` with `"status": "skipped"` and a `skipped_reason`, or it downgrades the send if you asked for a fallback.

| Feature          | iMessage              | RCS              | SMS / MMS              | With `"fallback": "sms"`                                     |
| ---------------- | --------------------- | ---------------- | ---------------------- | ------------------------------------------------------------ |
| Typing indicator | Yes                   | No               | No                     | Not available, always skipped                                |
| Read receipt     | Yes                   | No               | No                     | Not available, always skipped                                |
| Reaction         | Yes                   | No               | No                     | Sends a text line, for example `Loved "Thursday at 2 works"` |
| Voice note       | Inline voice memo     | Audio attachment | Audio attachment (MMS) | Sends the audio as an attachment                             |
| Contact card     | Tappable contact card | File attachment  | File attachment (MMS)  | Sends the `.vcf` as an attachment                            |

The `fallback` field takes two values on `/send-reaction`, `/send-voice-note`, and `/send-contact-card`:

| Value  | Behavior                                                                                                                                                                      |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `none` | Default. If the thread is not on iMessage, nothing is sent and the response is `"status": "skipped"`.                                                                         |
| `sms`  | Sends over the best non-iMessage channel available for that number, RCS where the number supports it, otherwise SMS/MMS. The response `channel` tells you which one was used. |

Skipped calls cost nothing. Fallback sends are billed as one message on the channel that carried them.

`skipped_reason` values:

| Value                            | Meaning                                                                                      |
| -------------------------------- | -------------------------------------------------------------------------------------------- |
| `recipient_not_imessage`         | The number is not reachable over iMessage right now                                          |
| `feature_unsupported_on_channel` | The thread is on RCS or SMS and the feature has no equivalent there                          |
| `message_not_on_imessage`        | The message you referenced in `/send-reaction` or `/mark-read` was not carried over iMessage |

To check before you call, read the `channel` field on the inbound message or on the message you are replying to. It is `"imessage"`, `"rcs"`, or `"sms"`.

## POST /send-typing-indicator

Shows the animated typing bubble in the recipient's thread. Use it before a reply so the conversation reads like a person on the other end.

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

Requires `Authorization: Bearer bluereacher_your_api_key` and `Content-Type: application/json`.

### Request fields

| Field         | Type    | Required    | Description                                                                   |
| ------------- | ------- | ----------- | ----------------------------------------------------------------------------- |
| `from`        | string  | Conditional | Your line in E.164 format. Required unless `line_id` is set.                  |
| `line_id`     | string  | Conditional | Your line by id. Required unless `from` is set.                               |
| `to`          | string  | Conditional | Recipient in E.164 format. Required unless `contact_id` is set.               |
| `contact_id`  | string  | Conditional | Recipient by stored contact id. Required unless `to` is set.                  |
| `duration`    | integer | No          | Seconds to keep the bubble visible, 1 to 60. Default `10`.                    |
| `external_id` | string  | No          | Your own reference, max 128 characters. Echoed back and used for idempotency. |

The bubble clears when `duration` elapses or as soon as you send a message on that line, whichever comes first. There is no stop call.

A natural pace is roughly one second of typing per 15 characters of the reply, capped at 12 seconds. Longer than that reads as a stall.

### Response fields

| Field            | Type           | Description                                      |
| ---------------- | -------------- | ------------------------------------------------ |
| `id`             | string         | Id for this typing indicator, prefix `ti_`.      |
| `object`         | string         | Always `typing_indicator`.                       |
| `status`         | string         | `sent` or `skipped`.                             |
| `skipped_reason` | string or null | Set when `status` is `skipped`.                  |
| `channel`        | string         | `imessage` when sent, `null` when skipped.       |
| `from`           | string         | Line the indicator was sent from.                |
| `to`             | string         | Recipient number.                                |
| `line_id`        | string         | Id of the sending line.                          |
| `contact_id`     | string or null | Contact record matched to `to`, when one exists. |
| `external_id`    | string or null | Your reference, echoed back.                     |
| `expires_at`     | string         | ISO 8601 time the bubble clears on its own.      |
| `created_at`     | string         | ISO 8601 time the request was accepted.          |

### Example request

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/send-typing-indicator \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+13105550142",
    "to": "+14155550198",
    "duration": 8,
    "external_id": "seq-4471-typing"
  }'
```

### Example response

```json theme={null}
{
  "id": "ti_9d4f1a2c",
  "object": "typing_indicator",
  "status": "sent",
  "skipped_reason": null,
  "channel": "imessage",
  "from": "+13105550142",
  "to": "+14155550198",
  "line_id": "line_7c2a9d41",
  "contact_id": "ct_4a91be03",
  "external_id": "seq-4471-typing",
  "expires_at": "2026-07-24T18:22:17Z",
  "created_at": "2026-07-24T18:22:09Z"
}
```

### Errors

| Status | Code                | When                                                                               |
| ------ | ------------------- | ---------------------------------------------------------------------------------- |
| 400    | `invalid_request`   | Missing sender or recipient, both sender fields set, or `duration` outside 1 to 60 |
| 403    | `line_not_owned`    | The line does not belong to your account                                           |
| 403    | `contact_opted_out` | The recipient has opted out on this line                                           |
| 404    | `line_not_found`    | No line matches `from` or `line_id`                                                |
| 429    | `rate_limited`      | More than 60 indicators per minute on one line                                     |

## POST /mark-read

Sends a read receipt for inbound messages in a thread. Your contact sees "Read" with a timestamp under the last message they sent.

```
POST https://api.bluereacher.com/functions/v1/mark-read
```

Requires `Authorization: Bearer bluereacher_your_api_key` and `Content-Type: application/json`.

### Request fields

| Field         | Type   | Required    | Description                                                                                    |
| ------------- | ------ | ----------- | ---------------------------------------------------------------------------------------------- |
| `from`        | string | Conditional | Your line in E.164 format. Required unless `line_id` is set.                                   |
| `line_id`     | string | Conditional | Your line by id. Required unless `from` is set.                                                |
| `to`          | string | Conditional | Recipient in E.164 format. Required unless `contact_id` is set.                                |
| `contact_id`  | string | Conditional | Recipient by stored contact id. Required unless `to` is set.                                   |
| `message_id`  | string | No          | Marks this inbound message and every earlier one in the thread. Omit to mark the whole thread. |
| `external_id` | string | No          | Your own reference, max 128 characters.                                                        |

Read receipts only travel in one direction: this endpoint reports that your line read their message. It does not tell you whether they read yours. Delivery and read events on your outbound messages arrive by webhook.

### Response fields

| Field             | Type           | Description                                      |
| ----------------- | -------------- | ------------------------------------------------ |
| `id`              | string         | Id for this receipt, prefix `rr_`.               |
| `object`          | string         | Always `read_receipt`.                           |
| `status`          | string         | `sent` or `skipped`.                             |
| `skipped_reason`  | string or null | Set when `status` is `skipped`.                  |
| `channel`         | string         | `imessage` when sent, `null` when skipped.       |
| `from`            | string         | Line that sent the receipt.                      |
| `to`              | string         | Recipient number.                                |
| `line_id`         | string         | Id of the sending line.                          |
| `contact_id`      | string or null | Contact record matched to `to`, when one exists. |
| `message_id`      | string or null | Newest inbound message covered by the receipt.   |
| `messages_marked` | integer        | How many inbound messages moved to read.         |
| `read_at`         | string         | ISO 8601 timestamp shown to the recipient.       |
| `external_id`     | string or null | Your reference, echoed back.                     |
| `created_at`      | string         | ISO 8601 time the request was accepted.          |

### Example request

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/mark-read \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "line_id": "line_7c2a9d41",
    "to": "+14155550198",
    "message_id": "msg_2b81f6c30ad4"
  }'
```

### Example response

```json theme={null}
{
  "id": "rr_5e70b3c9",
  "object": "read_receipt",
  "status": "sent",
  "skipped_reason": null,
  "channel": "imessage",
  "from": "+13105550142",
  "to": "+14155550198",
  "line_id": "line_7c2a9d41",
  "contact_id": "ct_4a91be03",
  "message_id": "msg_2b81f6c30ad4",
  "messages_marked": 2,
  "read_at": "2026-07-24T18:24:02Z",
  "external_id": null,
  "created_at": "2026-07-24T18:24:02Z"
}
```

### Errors

| Status | Code                     | When                                                      |
| ------ | ------------------------ | --------------------------------------------------------- |
| 400    | `invalid_request`        | Missing sender or recipient, or both recipient fields set |
| 403    | `line_not_owned`         | The line does not belong to your account                  |
| 404    | `message_not_found`      | No inbound message with that `message_id` on this line    |
| 404    | `conversation_not_found` | No thread between that line and that recipient            |
| 429    | `rate_limited`           | More than 60 receipts per minute on one line              |

A thread with no unread inbound messages returns `200` with `"messages_marked": 0` and `"status": "skipped"`.

## POST /send-reaction

Adds or removes a tapback on a specific message. You can react to messages you received and to messages you sent.

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

Requires `Authorization: Bearer bluereacher_your_api_key` and `Content-Type: application/json`.

### Supported tapbacks

| `reaction`  | What the recipient sees | Fallback text      |
| ----------- | ----------------------- | ------------------ |
| `love`      | Heart                   | `Loved "..."`      |
| `like`      | Thumbs up               | `Liked "..."`      |
| `dislike`   | Thumbs down             | `Disliked "..."`   |
| `laugh`     | Haha                    | `Laughed at "..."` |
| `emphasize` | Double exclamation mark | `Emphasized "..."` |
| `question`  | Question mark           | `Questioned "..."` |

Fallback text quotes the target message and truncates it at 120 characters followed by an ellipsis. For media messages the quote is replaced by the file name.

### Request fields

| Field         | Type   | Required    | Description                                                         |
| ------------- | ------ | ----------- | ------------------------------------------------------------------- |
| `from`        | string | Conditional | Your line in E.164 format. Required unless `line_id` is set.        |
| `line_id`     | string | Conditional | Your line by id. Required unless `from` is set.                     |
| `to`          | string | Conditional | Recipient in E.164 format. Required unless `contact_id` is set.     |
| `contact_id`  | string | Conditional | Recipient by stored contact id. Required unless `to` is set.        |
| `message_id`  | string | Yes         | Message to react to. Must be in a thread on the same line.          |
| `reaction`    | string | Yes         | One of `love`, `like`, `dislike`, `laugh`, `emphasize`, `question`. |
| `action`      | string | No          | `add` (default) or `remove`.                                        |
| `fallback`    | string | No          | `none` (default) or `sms`.                                          |
| `external_id` | string | No          | Your own reference, max 128 characters.                             |

One reaction per message per line. Sending a second reaction to the same message replaces the first, and the recipient sees the change in place. `"action": "remove"` clears your reaction and requires the same `reaction` value you sent originally.

### Response fields

| Field               | Type           | Description                                                    |
| ------------------- | -------------- | -------------------------------------------------------------- |
| `id`                | string         | Id for this reaction, prefix `rx_`.                            |
| `object`            | string         | Always `reaction`.                                             |
| `status`            | string         | `sent`, `skipped`, or `removed`.                               |
| `skipped_reason`    | string or null | Set when `status` is `skipped`.                                |
| `channel`           | string         | `imessage`, or `sms` / `rcs` when a fallback carried it.       |
| `message_id`        | string         | Message the reaction is attached to.                           |
| `reaction`          | string         | Tapback that was applied or removed.                           |
| `action`            | string         | `add` or `remove`.                                             |
| `replaced_reaction` | string or null | Tapback this one replaced, when there was one.                 |
| `from`              | string         | Line the reaction was sent from.                               |
| `to`                | string         | Recipient number.                                              |
| `line_id`           | string         | Id of the sending line.                                        |
| `contact_id`        | string or null | Contact record matched to `to`, when one exists.               |
| `content`           | string or null | Text that was sent when `fallback` was used, otherwise `null`. |
| `external_id`       | string or null | Your reference, echoed back.                                   |
| `created_at`        | string         | ISO 8601 time the request was accepted.                        |

### Example request

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/send-reaction \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+13105550142",
    "to": "+14155550198",
    "message_id": "msg_2b81f6c30ad4",
    "reaction": "love",
    "fallback": "none",
    "external_id": "seq-4471-tapback"
  }'
```

### Example response

```json theme={null}
{
  "id": "rx_a13c8f52",
  "object": "reaction",
  "status": "sent",
  "skipped_reason": null,
  "channel": "imessage",
  "message_id": "msg_2b81f6c30ad4",
  "reaction": "love",
  "action": "add",
  "replaced_reaction": null,
  "from": "+13105550142",
  "to": "+14155550198",
  "line_id": "line_7c2a9d41",
  "contact_id": "ct_4a91be03",
  "content": null,
  "external_id": "seq-4471-tapback",
  "created_at": "2026-07-24T18:25:40Z"
}
```

### Skipped example

```json theme={null}
{
  "id": "rx_c7802e19",
  "object": "reaction",
  "status": "skipped",
  "skipped_reason": "recipient_not_imessage",
  "channel": null,
  "message_id": "msg_51d9ac7742be",
  "reaction": "like",
  "action": "add",
  "replaced_reaction": null,
  "from": "+13105550142",
  "to": "+12065550117",
  "line_id": "line_7c2a9d41",
  "contact_id": null,
  "content": null,
  "external_id": null,
  "created_at": "2026-07-24T18:26:11Z"
}
```

### Errors

| Status | Code                   | When                                                         |
| ------ | ---------------------- | ------------------------------------------------------------ |
| 400    | `unsupported_reaction` | `reaction` is not one of the six values                      |
| 400    | `invalid_request`      | Missing `message_id`, sender, or recipient                   |
| 403    | `line_not_owned`       | The line does not belong to your account                     |
| 404    | `message_not_found`    | No message with that id on this line                         |
| 404    | `reaction_not_found`   | `"action": "remove"` on a message your line never reacted to |
| 429    | `rate_limited`         | More than 60 reactions per minute on one line                |

## POST /send-voice-note

Sends an audio file that renders as an inline voice memo with a waveform and a play button, not as a file attachment.

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

Requires `Authorization: Bearer bluereacher_your_api_key` and `Content-Type: application/json`.

### Request fields

| Field         | Type   | Required    | Description                                                                                               |
| ------------- | ------ | ----------- | --------------------------------------------------------------------------------------------------------- |
| `from`        | string | Conditional | Your line in E.164 format. Required unless `line_id` is set.                                              |
| `line_id`     | string | Conditional | Your line by id. Required unless `from` is set.                                                           |
| `to`          | string | Conditional | Recipient in E.164 format. Required unless `contact_id` is set.                                           |
| `contact_id`  | string | Conditional | Recipient by stored contact id. Required unless `to` is set.                                              |
| `media_url`   | string | Yes         | HTTPS URL of the audio file. Use `/upload-media` or any host that serves the file without authentication. |
| `fallback`    | string | No          | `none` (default) or `sms`.                                                                                |
| `external_id` | string | No          | Your own reference, max 128 characters.                                                                   |

Audio is converted to mono AAC at 32 kbps before sending, which is what makes it render as a voice memo instead of an attachment. The file at `media_url` is left untouched.

Accepted formats: `.m4a`, `.aac`, `.mp3`, `.wav`, `.caf`. Maximum 16 MB and 300 seconds. Full limits are in [Supported formats and size limits](#supported-formats-and-size-limits).

Voice notes create a real message, so they appear in message history and emit the standard delivery webhooks.

One behavior to plan around: with default iMessage settings, audio messages disappear from the thread two minutes after the recipient plays them unless they tap Keep. Anything the recipient needs to refer back to, such as a link, a price, or a time, should also go out as text.

### Response fields

| Field            | Type           | Description                                              |
| ---------------- | -------------- | -------------------------------------------------------- |
| `id`             | string         | Message id, prefix `msg_`.                               |
| `object`         | string         | Always `message`.                                        |
| `type`           | string         | Always `voice_note`.                                     |
| `status`         | string         | `queued`, `sent`, or `skipped`.                          |
| `skipped_reason` | string or null | Set when `status` is `skipped`.                          |
| `channel`        | string         | `imessage`, or `sms` / `rcs` when a fallback carried it. |
| `media_url`      | string         | URL of the audio that was sent.                          |
| `duration`       | integer        | Length in whole seconds.                                 |
| `from`           | string         | Line the voice note was sent from.                       |
| `to`             | string         | Recipient number.                                        |
| `line_id`        | string         | Id of the sending line.                                  |
| `contact_id`     | string or null | Contact record matched to `to`, when one exists.         |
| `content`        | string or null | Always `null` for voice notes.                           |
| `external_id`    | string or null | Your reference, echoed back.                             |
| `created_at`     | string         | ISO 8601 time the request was accepted.                  |

### Example request

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/send-voice-note \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+13105550142",
    "to": "+14155550198",
    "media_url": "https://media.bluereacher.com/v1/media_6f0b2e9c7d13.m4a",
    "fallback": "sms",
    "external_id": "seq-4471-voice"
  }'
```

### Example response

```json theme={null}
{
  "id": "msg_c904bb17e2f3",
  "object": "message",
  "type": "voice_note",
  "status": "sent",
  "skipped_reason": null,
  "channel": "imessage",
  "media_url": "https://media.bluereacher.com/v1/media_6f0b2e9c7d13.m4a",
  "duration": 27,
  "from": "+13105550142",
  "to": "+14155550198",
  "line_id": "line_7c2a9d41",
  "contact_id": "ct_4a91be03",
  "content": null,
  "external_id": "seq-4471-voice",
  "created_at": "2026-07-24T18:27:35Z"
}
```

### Errors

| Status | Code                     | When                                                                       |
| ------ | ------------------------ | -------------------------------------------------------------------------- |
| 400    | `invalid_request`        | Missing `media_url`, sender, or recipient                                  |
| 403    | `line_not_owned`         | The line does not belong to your account                                   |
| 413    | `media_too_large`        | Audio file over 16 MB                                                      |
| 415    | `unsupported_media_type` | File is not one of the accepted audio formats                              |
| 422    | `media_unreachable`      | The URL did not return the file within 10 seconds, or needs authentication |
| 422    | `audio_too_long`         | Audio longer than 300 seconds                                              |
| 429    | `rate_limited`           | Over your plan's message rate for that line                                |

## POST /send-contact-card

Sends a vCard that renders as a tappable contact card. The recipient can add it to their contacts in one tap, which is the cleanest way to hand over a rep's details or a calendar contact.

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

Requires `Authorization: Bearer bluereacher_your_api_key` and `Content-Type: application/json`.

### Request fields

| Field         | Type   | Required    | Description                                                                    |
| ------------- | ------ | ----------- | ------------------------------------------------------------------------------ |
| `from`        | string | Conditional | Your line in E.164 format. Required unless `line_id` is set.                   |
| `line_id`     | string | Conditional | Your line by id. Required unless `from` is set.                                |
| `to`          | string | Conditional | Recipient in E.164 format. Required unless `contact_id` is set.                |
| `contact_id`  | string | Conditional | Recipient by stored contact id. Required unless `to` is set.                   |
| `vcard`       | string | Conditional | Raw vCard text, max 32 KB. Required unless `media_url` is set.                 |
| `media_url`   | string | Conditional | HTTPS URL of a hosted `.vcf` file, max 256 KB. Required unless `vcard` is set. |
| `fallback`    | string | No          | `none` (default) or `sms`.                                                     |
| `external_id` | string | No          | Your own reference, max 128 characters.                                        |

Card rules:

* vCard 3.0 renders most reliably. vCard 4.0 is accepted and converted to 3.0 before sending.
* `BEGIN:VCARD`, `VERSION`, `FN`, and `END:VCARD` are required. Cards without `FN` are rejected.
* In JSON, write line breaks as `\n`. BlueReacher normalizes them to the CRLF the vCard format expects.
* `PHOTO` may be base64 JPEG or PNG. Keep it under 20 KB so the card stays inside the 32 KB inline limit.
* One card per request. To send several, send several messages.

When you pass `vcard` inline, BlueReacher hosts a copy and returns its `media_url`, so you can reuse the same card without rebuilding it.

### Response fields

| Field            | Type           | Description                                              |
| ---------------- | -------------- | -------------------------------------------------------- |
| `id`             | string         | Message id, prefix `msg_`.                               |
| `object`         | string         | Always `message`.                                        |
| `type`           | string         | Always `contact_card`.                                   |
| `status`         | string         | `queued`, `sent`, or `skipped`.                          |
| `skipped_reason` | string or null | Set when `status` is `skipped`.                          |
| `channel`        | string         | `imessage`, or `sms` / `rcs` when a fallback carried it. |
| `media_url`      | string         | Hosted `.vcf` that was sent.                             |
| `card_name`      | string         | Value of `FN` on the card.                               |
| `byte_size`      | integer        | Size of the card in bytes.                               |
| `from`           | string         | Line the card was sent from.                             |
| `to`             | string         | Recipient number.                                        |
| `line_id`        | string         | Id of the sending line.                                  |
| `contact_id`     | string or null | Contact record matched to `to`, when one exists.         |
| `content`        | string or null | Always `null` for contact cards.                         |
| `external_id`    | string or null | Your reference, echoed back.                             |
| `created_at`     | string         | ISO 8601 time the request was accepted.                  |

### Example request

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/send-contact-card \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+13105550142",
    "to": "+14155550198",
    "vcard": "BEGIN:VCARD\nVERSION:3.0\nN:Reyes;Dana;;;\nFN:Dana Reyes\nORG:Northwind Logistics\nTITLE:Account Executive\nTEL;TYPE=CELL:+13105550142\nEMAIL:dana@northwindlogistics.com\nURL:https://northwindlogistics.com\nEND:VCARD",
    "external_id": "seq-4471-card"
  }'
```

### Example response

```json theme={null}
{
  "id": "msg_7ae4103bd58c",
  "object": "message",
  "type": "contact_card",
  "status": "sent",
  "skipped_reason": null,
  "channel": "imessage",
  "media_url": "https://media.bluereacher.com/v1/media_1c47ba90e5f2.vcf",
  "card_name": "Dana Reyes",
  "byte_size": 268,
  "from": "+13105550142",
  "to": "+14155550198",
  "line_id": "line_7c2a9d41",
  "contact_id": "ct_4a91be03",
  "content": null,
  "external_id": "seq-4471-card",
  "created_at": "2026-07-24T18:29:04Z"
}
```

### Errors

| Status | Code                     | When                                                                       |
| ------ | ------------------------ | -------------------------------------------------------------------------- |
| 400    | `invalid_request`        | Neither `vcard` nor `media_url` set, or both set                           |
| 400    | `invalid_vcard`          | Missing `BEGIN`, `VERSION`, `FN`, or `END`, or the file is not parseable   |
| 403    | `line_not_owned`         | The line does not belong to your account                                   |
| 413    | `vcard_too_large`        | Over 32 KB inline or 256 KB hosted                                         |
| 415    | `unsupported_media_type` | `media_url` does not point at a `.vcf` file                                |
| 422    | `media_unreachable`      | The URL did not return the file within 10 seconds, or needs authentication |
| 429    | `rate_limited`           | Over your plan's message rate for that line                                |

## POST /upload-media

Hosts a file and returns a reusable `media_url`. Upload once, send to as many recipients and from as many lines as you want.

```
POST https://api.bluereacher.com/functions/v1/upload-media
```

Requires `Authorization: Bearer bluereacher_your_api_key` and `Content-Type: application/json`.

### Request fields

| Field          | Type   | Required    | Description                                                                                                               |
| -------------- | ------ | ----------- | ------------------------------------------------------------------------------------------------------------------------- |
| `file`         | string | Conditional | Raw base64 of the file, no `data:` prefix. Required unless `source_url` is set.                                           |
| `source_url`   | string | Conditional | HTTPS URL BlueReacher fetches and stores. Required unless `file` is set.                                                  |
| `filename`     | string | Conditional | Name with extension, for example `intro.m4a`. Required with `file`. With `source_url` it defaults to the name in the URL. |
| `content_type` | string | No          | MIME type. Sniffed from the bytes when omitted. A declared type that does not match the bytes returns `415`.              |
| `purpose`      | string | No          | `voice_note`, `image`, `video`, `contact_card`, or `file`. Default `file`. Sets which format and size limits apply.       |
| `external_id`  | string | No          | Your own reference, max 128 characters.                                                                                   |

Request bodies over 20 MB are rejected, which caps `file` at roughly 14 MB of source bytes because base64 adds about a third. Use `source_url` for anything larger.

Uploading identical bytes with the same `purpose` returns the existing record instead of storing a second copy, so retrying an upload is safe.

`media_url` is stable and reusable. It expires 12 months after the last send that used it, and every new send resets that clock.

### Response fields

| Field          | Type            | Description                                              |
| -------------- | --------------- | -------------------------------------------------------- |
| `id`           | string          | Media id, prefix `media_`.                               |
| `object`       | string          | Always `media`.                                          |
| `status`       | string          | `stored`.                                                |
| `media_url`    | string          | Hosted URL to pass to any send endpoint.                 |
| `filename`     | string          | Stored file name.                                        |
| `content_type` | string          | Detected MIME type.                                      |
| `purpose`      | string          | Purpose the file was validated against.                  |
| `byte_size`    | integer         | Size in bytes.                                           |
| `duration`     | integer or null | Length in seconds for audio and video, `null` otherwise. |
| `checksum`     | string          | SHA-256 of the stored bytes, hex encoded.                |
| `deduplicated` | boolean         | `true` when the response returned an existing record.    |
| `external_id`  | string or null  | Your reference, echoed back.                             |
| `expires_at`   | string          | ISO 8601 expiry, extended by each send.                  |
| `created_at`   | string          | ISO 8601 time the file was stored.                       |

### Example request

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/upload-media \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "source_url": "https://cdn.northwindlogistics.com/audio/intro-dana.m4a",
    "filename": "intro-dana.m4a",
    "purpose": "voice_note",
    "external_id": "asset-intro-dana-v3"
  }'
```

### Example response

```json theme={null}
{
  "id": "media_6f0b2e9c7d13",
  "object": "media",
  "status": "stored",
  "media_url": "https://media.bluereacher.com/v1/media_6f0b2e9c7d13.m4a",
  "filename": "intro-dana.m4a",
  "content_type": "audio/mp4",
  "purpose": "voice_note",
  "byte_size": 431208,
  "duration": 27,
  "checksum": "9f2c1d7e5a4b83c06de1f4a92b77c0d5e83a1f6b24c9d70e5ab318f4c62d9071",
  "deduplicated": false,
  "external_id": "asset-intro-dana-v3",
  "expires_at": "2027-07-24T18:20:55Z",
  "created_at": "2026-07-24T18:20:55Z"
}
```

### Errors

| Status | Code                     | When                                                                             |
| ------ | ------------------------ | -------------------------------------------------------------------------------- |
| 400    | `invalid_request`        | Neither `file` nor `source_url` set, both set, or `filename` missing with `file` |
| 400    | `invalid_base64`         | `file` is not valid base64, or still carries a `data:` prefix                    |
| 413    | `payload_too_large`      | Body over 20 MB, or the file is over the limit for its `purpose`                 |
| 415    | `unsupported_media_type` | Extension or sniffed type is not allowed for that `purpose`                      |
| 422    | `source_unreachable`     | `source_url` did not return the file within 30 seconds, or needs authentication  |
| 429    | `rate_limited`           | More than 120 uploads per minute on one account                                  |

## Supported formats and size limits

| Class         | `purpose`      | Formats                                          | Max size                    | Other limits          |
| ------------- | -------------- | ------------------------------------------------ | --------------------------- | --------------------- |
| Audio         | `voice_note`   | `.m4a`, `.aac`, `.mp3`, `.wav`, `.caf`           | 16 MB                       | 300 seconds           |
| Images        | `image`        | `.jpg`, `.png`, `.gif`, `.heic`, `.webp`         | 16 MB                       | Longest edge 8,000 px |
| Video         | `video`        | `.mp4`, `.mov`                                   | 100 MB                      | 600 seconds           |
| Contact cards | `contact_card` | `.vcf` (vCard 3.0 or 4.0)                        | 256 KB hosted, 32 KB inline | One card per message  |
| Other files   | `file`         | `.pdf`, `.csv`, `.txt`, `.docx`, `.xlsx`, `.zip` | 100 MB                      |                       |

Notes:

* Every JSON request body is capped at 20 MB across all endpoints.
* `media_url` and `source_url` must be HTTPS and must serve the file without authentication.
* HEIC and WebP images send as they are over iMessage. On an SMS or RCS fallback they are converted to JPEG so every recipient can open them.
* Audio is converted to mono AAC at 32 kbps for voice note sends. Files stored through `/upload-media` keep their original bytes.

## Rate limits

| Endpoint                 | Limit                                  |
| ------------------------ | -------------------------------------- |
| `/send-typing-indicator` | 60 per minute per line                 |
| `/mark-read`             | 60 per minute per line                 |
| `/send-reaction`         | 60 per minute per line                 |
| `/send-voice-note`       | Your plan's message rate for that line |
| `/send-contact-card`     | Your plan's message rate for that line |
| `/upload-media`          | 120 per minute per account             |

Over the limit returns `429` with a `Retry-After` header in seconds. Retry after that many seconds with a small random delay added so parallel workers do not all come back at once.

## Idempotency with external\_id

`external_id` is unique per account. Sending the same `external_id` twice within 24 hours returns the original record with the original `created_at` instead of performing the action again. Use it on any call you might retry after a timeout.

## Error format

Every error returns a JSON body with the same shape.

```json theme={null}
{
  "error": {
    "type": "invalid_request",
    "code": "unsupported_reaction",
    "message": "reaction must be one of love, like, dislike, laugh, emphasize, question.",
    "param": "reaction"
  }
}
```

| Field     | Type           | Description                                                                                                                  |
| --------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `type`    | string         | Broad class: `invalid_request`, `authentication_error`, `permission_error`, `not_found`, `rate_limit_error`, `server_error`. |
| `code`    | string         | Specific code, listed per endpoint above.                                                                                    |
| `message` | string         | Plain description safe to log.                                                                                               |
| `param`   | string or null | Field that caused the error, when one field is at fault.                                                                     |

Codes shared by every endpoint on this page:

| Status | Code                      | When                                                |
| ------ | ------------------------- | --------------------------------------------------- |
| 401    | `invalid_api_key`         | Missing, malformed, or revoked key                  |
| 403    | `feature_not_enabled`     | Rich messaging is not enabled on your plan          |
| 403    | `contact_opted_out`       | The recipient opted out on that line                |
| 404    | `line_not_found`          | No line matches `from` or `line_id`                 |
| 404    | `contact_not_found`       | No contact matches `contact_id`                     |
| 500    | `internal_error`          | Retry once after a short delay                      |
| 503    | `temporarily_unavailable` | Line is briefly unavailable, retry after 30 seconds |

Retry `500`, `503`, and `429`. Do not retry `400`, `401`, `403`, `404`, `413`, `415`, or `422` without changing the request.

## Webhooks

`/send-voice-note` and `/send-contact-card` create messages, so they emit the same `message.sent`, `message.delivered`, and `message.failed` webhooks as any other send, keyed by `id` and `external_id`.

Typing indicators, read receipts, and reactions do not create messages and do not emit outbound webhooks. Inbound tapbacks from your contacts do arrive, as `reaction.received` events carrying `message_id`, `reaction`, `from`, and `created_at`.

## Sending a natural voice note reply

Upload once, show the typing bubble, then send.

```javascript theme={null}
const BASE = "https://api.bluereacher.com/functions/v1";
const headers = {
  Authorization: "Bearer bluereacher_your_api_key",
  "Content-Type": "application/json",
};

const post = (path, body) =>
  fetch(`${BASE}/${path}`, {
    method: "POST",
    headers,
    body: JSON.stringify(body),
  }).then((r) => r.json());

const media = await post("upload-media", {
  source_url: "https://cdn.northwindlogistics.com/audio/intro-dana.m4a",
  filename: "intro-dana.m4a",
  purpose: "voice_note",
});

await post("send-typing-indicator", {
  from: "+13105550142",
  to: "+14155550198",
  duration: 6,
});

await new Promise((resolve) => setTimeout(resolve, 6000));

const message = await post("send-voice-note", {
  from: "+13105550142",
  to: "+14155550198",
  media_url: media.media_url,
  fallback: "sms",
  external_id: "seq-4471-voice",
});

console.log(message.status, message.channel, message.duration);
```

## Reacting to an inbound message

Handle a `message.received` webhook and tapback when the reply is a yes.

```python theme={null}
import requests

BASE = "https://api.bluereacher.com/functions/v1"
HEADERS = {
    "Authorization": "Bearer bluereacher_your_api_key",
    "Content-Type": "application/json",
}

def acknowledge(event):
    payload = {
        "line_id": event["line_id"],
        "to": event["from"],
        "message_id": event["id"],
        "reaction": "like",
        "fallback": "none",
        "external_id": f"ack-{event['id']}",
    }
    r = requests.post(f"{BASE}/send-reaction", headers=HEADERS, json=payload, timeout=10)
    r.raise_for_status()
    body = r.json()

    if body["status"] == "skipped":
        print("no tapback:", body["skipped_reason"])
    return body
```

The webhook payload carries `channel`, so you can check for `"imessage"` before calling and skip the round trip on SMS threads.

## Sending on your lines

Rich messaging runs on your dedicated iMessage lines, managed for you, with no A2P registration required. The lines behave like any other conversation thread, which is why tapbacks, read receipts, and voice memos land the way your contacts expect.
