# Send a text

> Queues a text from your own number.

`POST /v1/messages`

Queues a text from your own number. The answer is the message, usually `queued`; a text that can’t go right now is accepted and carries a `hold` saying why (quiet hours, the Mac offline, a daily limit, its turn in the pacing) and goes on its own once the hold ends, unless it expires first.

Send `to` for one person or `chat_id` for an existing group chat. `send_at` schedules it; a scheduled text can be changed with `PATCH /v1/messages/{id}` until it is due. A text to somebody on your stop list is accepted and canceled at once with `cancel_reason: "opted_out"`.

Send `text` for your own words, or `template_id` with `variables` to send a saved template. A template is filled from `variables`, then from the person’s contact and your business name; a variable left with no value and no fallback refuses the send with `invalid_request`, naming it under `details`, and a template that isn’t yours answers `not_found`. The text records the template’s id as its `source_ref`.

Send an `Idempotency-Key` so a retry never sends twice: the first answer is kept 24 hours.

Needs a key with the `messages:send` scope. [Keys and scopes](https://usebluebot.com/docs/concepts/api-keys)

## Headers

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | string | No | Any string of up to 255 characters, new for each request. A retry with the same key gets the first answer back for 24 hours, with `Idempotent-Replayed: true`. Up to 255 characters |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `to` | string | No | Who to text. Send `to` or `chat_id`, not both. 3 to 320 characters |
| `chat_id` | string | No | An existing group chat to text into, from `GET /v1/chats`. Send `to` or `chat_id`, not both. Starts with `chat_` |
| `text` | string | No | The words, up to 2000 characters. Send `text` or `template_id`, not both; may be left out when `files` has something. Up to 2,000 characters |
| `template_id` | string | No | Send a saved template instead of `text`. Its `{{variables}}` are filled from `variables`, then from the person’s contact (`first_name`, `last_name`, `company`, custom fields) and your business name (`business`). A variable with no value and no fallback refuses the send, naming it. Starts with `tpl_` |
| `variables` | map of string | No | Values for the template’s `{{variables}}`, by name. They win over the contact’s own fields. Only with `template_id`. |
| `files` | array of strings | No | Up to 10 files from `POST /v1/files`, sent after the words in this order. Up to 10 items |
| `service` | string | No | `auto` (the default) uses whatever the newest chat with the person used, else iMessage, and sends an iMessage that never went once more as a regular text. `imessage` never falls back; `sms` sends a regular text through your iPhone. One of `auto`, `imessage`, `sms`. Default `"auto"` |
| `send_at` | timestamp | No | Schedule it. A scheduled text is a draft you can change with `PATCH /v1/messages/{id}` until it is due. |
| `expires_at` | timestamp | No | Give up on it if it is still waiting at this time. Default: 24 hours after it is due (a setting). |
| `metadata` | map of string | No | Your own data, echoed on the message and on every webhook about it: up to 20 keys of up to 40 characters, each a string of up to 500. |

## Example

```bash
curl -X POST "https://usebluebot.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 0f6d8c2e-5b1a-4c7e-9d3f-2a8b6e1c4d70" \
  -d '{
    "to": "+15555550123",
    "text": "Hi Maria, the showing at 412 Oak St is confirmed for 3 PM.",
    "metadata": {
      "crm_id": "48213"
    }
  }'
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const message = await client.messages.send({
  to: '+15555550123',
  text: 'Hi Maria, the showing at 412 Oak St is confirmed for 3 PM.',
  metadata: {
    crm_id: '48213',
  },
}, { idempotencyKey: '0f6d8c2e-5b1a-4c7e-9d3f-2a8b6e1c4d70' });
console.log(message);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

message = client.messages.send(
    to="+15555550123",
    text="Hi Maria, the showing at 412 Oak St is confirmed for 3 PM.",
    metadata={
        "crm_id": "48213",
    },
    idempotency_key="0f6d8c2e-5b1a-4c7e-9d3f-2a8b6e1c4d70",
)
print(message)
```

## Response

`201`: The text, accepted.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | A msg_ id. Starts with `msg_` |
| `object` | "message" | Yes |   |
| `direction` | string | Yes | `out` for a text from your number, `in` for one to it. One of `out`, `in` |
| `state` | string | Yes | Out: `scheduled`, `queued`, `sending`, `sent`, `delivered`, `read`, `failed`, `unconfirmed`, `expired`, `canceled`. In: `received`. One of `scheduled`, `queued`, `sending`, `sent`, `delivered`, `read`, `failed`, `unconfirmed`, `expired`, `canceled`, `received` |
| `to` | string or null | Yes | Who it went to. For a text in, your own number when the Mac knows it. Null in a group chat. |
| `from` | string or null | Yes | Who sent it. For a text out, your own number when the Mac knows it. |
| `chat_id` | string or null | Yes | The group chat it is in; null for a one-to-one text. Starts with `chat_` |
| `contact_id` | string or null | Yes | The person, or for a group text in, the member who sent it. Starts with `ct_` |
| `conversation_id` | string | Yes | A conv_ id. Starts with `conv_` |
| `text` | string or null | Yes | The words. Null for a files-only text, an unsent one, and after 30 days, when BlueBot deletes the words and keeps the facts. |
| `text_deleted` | boolean | Yes | True once the words were deleted after 30 days. |
| `files` | array of File | Yes | Pictures and files on the text, in order. |
| `files[].id` | string | Yes | A file_ id. Starts with `file_` |
| `files[].object` | "file" | Yes |   |
| `files[].name` | string or null | Yes | The file name, as it was uploaded or as Messages named it. |
| `files[].content_type` | string | Yes | The media type. |
| `files[].size` | integer | Yes | Bytes. |
| `files[].kind` | string | Yes | What the file is, for showing it: an image, a video, audio, a voice memo, a PDF, a contact card or another file. One of `image`, `video`, `audio`, `voice_memo`, `pdf`, `contact_card`, `file` |
| `files[].status` | string | Yes | `ready` when BlueBot has the bytes; `waiting` while the Mac uploads them (usually seconds); `too_large` for an attachment over 25 MB, which the Mac never uploads; `deleted` once 30 days have passed. One of `ready`, `waiting`, `too_large`, `deleted` |
| `files[].url` | string or null | Yes | Reads the file for an hour. Null unless `status` is `ready`. |
| `files[].url_expires_at` | timestamp or null | Yes | When `url` stops working. |
| `files[].transcript` | string or null | Yes | For a voice memo, the words the Mac transcribed on the device, when the owner turned transcription on. |
| `files[].transcript_language` | string or null | Yes | The transcript’s language, such as `en-US`. |
| `files[].created_at` | timestamp | Yes | When BlueBot first knew of the file. |
| `service_requested` | string | Yes | What the caller asked for. `auto` uses the service of the newest chat with the person, else iMessage. One of `auto`, `imessage`, `sms` |
| `service` | string or null | Yes | What it went or came through. Null until it is sent. One of `imessage`, `sms` |
| `sms_retry` | boolean | Yes | True when an iMessage that never went was sent again once as a regular text. |
| `source` | string | Yes | What sent it. Texts read from Messages, including ones you typed by hand, are `mac`. One of `api`, `zapier`, `make`, `n8n`, `pipedream`, `cli`, `mcp`, `hook`, `broadcast`, `sequence`, `inbox`, `autopilot`, `keyword`, `away`, `widget`, `assistant`, `test`, `mac` |
| `source_ref` | string or null | Yes | The broadcast, sequence, hook, rule or template behind it, as an id. A text sent with `template_id` carries the template’s `tpl_` id here. |
| `metadata` | map of string | Yes | Your own data, echoed on the message and on every webhook about it: up to 20 keys of up to 40 characters, each a string of up to 500. |
| `hold` | Hold or null | Yes | A text that is accepted but can’t go yet carries a hold. Null when nothing holds it. |
| `hold.reason` | string | Yes | Why the text is waiting, checked in this order: `account_held` (BlueBot paused the account), `platform_stopped` (BlueBot stopped all sending), `plan_lapsed`, `paused` (you paused sending), `device_offline` (no check-in from the Mac for 2 minutes), `quiet_hours`, `daily_new_people_limit`, `platform_daily_limit` (1,000 a day) and `pacing` (its turn in the spacing between texts). One of `account_held`, `platform_stopped`, `plan_lapsed`, `paused`, `device_offline`, `quiet_hours`, `daily_new_people_limit`, `platform_daily_limit`, `pacing` |
| `hold.until` | timestamp or null | Yes | When it should stop waiting, when that is known: the end of quiet hours, the next local midnight, or the estimated slot. |
| `hold.description` | string | Yes | The hold in words. |
| `send_at` | timestamp or null | Yes | For a scheduled text, when it goes. |
| `expires_at` | timestamp or null | Yes | A text still queued at this time expires. |
| `failure` | object or null | Yes | Why a `failed` text didn’t go. Also set on a `sent` text when the words went and a file didn’t. |
| `failure.reason` | string | Yes | One of `messages_refused`, `not_on_imessage`, `file_unavailable` |
| `failure.detail` | string or null | Yes | What the Mac saw, in words. |
| `cancel_reason` | string or null | Yes | Why it was canceled: `requested`, `opted_out` (the person texted a stop word), `sequence_stopped`, `broadcast_canceled` or `staff`. One of `requested`, `opted_out`, `sequence_stopped`, `broadcast_canceled`, `staff` |
| `resolution` | string or null | Yes | How an `unconfirmed` text was settled: marked sent, or sent again as a new text. One of `marked_sent`, `resent` |
| `resolved_at` | timestamp or null | Yes | When it was settled. |
| `resent_message_id` | string or null | Yes | The new text, when this one was sent again. Starts with `msg_` |
| `reply_to_id` | string or null | Yes | The text this one answers inline, when Messages recorded one. Starts with `msg_` |
| `reactions` | array of Reaction | Yes | Tapbacks on this text that are still there. |
| `reactions[].id` | string | Yes | A rxn_ id. Starts with `rxn_` |
| `reactions[].kind` | string | Yes | The tapback: love, like, dislike, laugh, emphasize, question, or a custom emoji. One of `love`, `like`, `dislike`, `laugh`, `emphasize`, `question`, `emoji` |
| `reactions[].emoji` | string or null | Yes | The emoji, for `kind: "emoji"`. |
| `reactions[].from` | string or null | Yes | Who reacted; null when it was you, on your Mac or iPhone. |
| `reactions[].direction` | string | Yes | `in` from the other person, `out` from you. One of `in`, `out` |
| `reactions[].added_at` | timestamp | Yes | When the tapback was added. |
| `edited_at` | timestamp or null | Yes | When it was last edited in Messages. |
| `unsent_at` | timestamp or null | Yes | When it was unsent in Messages. |
| `created_at` | timestamp | Yes | When BlueBot accepted or first saw it. |
| `queued_at` | timestamp or null | Yes | When it became due. |
| `sending_at` | timestamp or null | Yes | When the Mac took it. |
| `sent_at` | timestamp or null | Yes | When Messages showed it sent. |
| `delivered_at` | timestamp or null | Yes | When iMessage showed it delivered. Regular texts are never delivered. |
| `read_at` | timestamp or null | Yes | When it was read, where the person shares read receipts. |
| `failed_at` | timestamp or null | Yes | When it failed. |
| `unconfirmed_at` | timestamp or null | Yes | When the Mac’s lease lapsed without saying whether it went. |
| `expired_at` | timestamp or null | Yes | When it expired. |
| `canceled_at` | timestamp or null | Yes | When it was canceled. |
| `received_at` | timestamp or null | Yes | For a text in, when it arrived. |

The answer, `201`:

```json
{
  "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a",
  "object": "message",
  "direction": "out",
  "state": "queued",
  "to": "+15555550123",
  "from": "+15555550100",
  "chat_id": null,
  "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a",
  "conversation_id": "conv_1Hq2LxW9cVbN3mKpRtYs0a",
  "text": "Hi Maria, the showing at 412 Oak St is confirmed for 3 PM.",
  "text_deleted": false,
  "files": [],
  "service_requested": "auto",
  "service": null,
  "sms_retry": false,
  "source": "api",
  "source_ref": null,
  "metadata": {
    "crm_id": "48213"
  },
  "hold": null,
  "send_at": null,
  "expires_at": "2026-11-14T19:41:00.000Z",
  "failure": null,
  "cancel_reason": null,
  "resolution": null,
  "resolved_at": null,
  "resent_message_id": null,
  "reply_to_id": null,
  "reactions": [],
  "edited_at": null,
  "unsent_at": null,
  "created_at": "2026-11-13T19:41:00.000Z",
  "queued_at": "2026-11-13T19:41:00.000Z",
  "sending_at": null,
  "sent_at": null,
  "delivered_at": null,
  "read_at": null,
  "failed_at": null,
  "unconfirmed_at": null,
  "expired_at": null,
  "canceled_at": null,
  "received_at": null
}
```

## Errors

Every refusal has the same envelope and a page of its own. These are the ones this operation can give.

| Code                                                            | Status | What it means                                                                                                                                    |
| --------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`unauthorized`](https://usebluebot.com/docs/errors/unauthorized)                     | 401    | The request carried no key, or a key BlueBot doesn’t know: mistyped, revoked, or a test key sent to the live API.                                |
| [`insufficient_scope`](https://usebluebot.com/docs/errors/insufficient_scope)         | 403    | The key doesn’t carry the scope the endpoint needs. Each endpoint’s reference names its scope.                                                   |
| [`rate_limited`](https://usebluebot.com/docs/errors/rate_limited)                     | 429    | Too many requests. A key may make 120 requests a minute. The `RateLimit` header says how many are left, and `Retry-After` how long to wait.      |
| [`internal`](https://usebluebot.com/docs/errors/internal)                             | 500    | Something went wrong on BlueBot’s side. It was reported. Send the request again; with an Idempotency-Key that is always safe.                    |
| [`invalid_request`](https://usebluebot.com/docs/errors/invalid_request)               | 400    | Something in the request doesn’t fit: a missing or malformed field, a bad id, or a combination the endpoint refuses. `details` names each field. |
| [`not_found`](https://usebluebot.com/docs/errors/not_found)                           | 404    | There is no such thing on this account. Ids are prefixed by kind, so a message id starts with `msg_`.                                            |
| [`plan_lapsed`](https://usebluebot.com/docs/errors/plan_lapsed)                       | 402    | The account’s plan isn’t active, so BlueBot isn’t taking new texts. Texts already queued wait and go once the plan is active again.              |
| [`idempotency_conflict`](https://usebluebot.com/docs/errors/idempotency_conflict)     | 409    | The Idempotency-Key was used in the last 24 hours with a different request. A key stands for one request.                                        |
| [`conflict`](https://usebluebot.com/docs/errors/conflict)                             | 409    | The request doesn’t fit the thing’s current state, or another request with the same Idempotency-Key is still running.                            |
| [`payload_too_large`](https://usebluebot.com/docs/errors/payload_too_large)           | 413    | The request is bigger than the endpoint takes.                                                                                                   |
| [`unsupported_media_type`](https://usebluebot.com/docs/errors/unsupported_media_type) | 415    | The body isn’t a type the endpoint reads: JSON for most, a multipart form for file uploads, and only the file types listed for files.            |

## Try it
