# Send a text

> Every field of a send, from who to text and the words to iMessage or SMS, files, a group chat, a time to send it and your own metadata, and how to read the answer.

One call sends a text from your own number: `POST /v1/messages`. BlueBot answers at once with the message, before anything has gone. Your Mac takes the text at its next check-in, which comes every 10 seconds, and hands it to Messages. The answer tells you whether the text is next in line or waiting, and webhooks tell you what happens after that.

## The smallest send

Dana runs a small real estate team. Her CRM marks Maria’s showing as confirmed, and her code sends this:

```bash
curl -X POST "https://usebluebot.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: showing-48213-confirmed" \
  -d '{
    "to": "+15555550123",
    "text": "Hi Maria, the showing at 412 Oak St is confirmed for 3 PM tomorrow. Reply here if anything changes."
  }'
```

```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 tomorrow. Reply here if anything changes.',
}, { idempotencyKey: 'showing-48213-confirmed' });
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 tomorrow. Reply here if anything changes.",
    idempotency_key="showing-48213-confirmed",
)
print(message)
```

Maria gets a text from Dana’s own number, in the same conversation as every other text Dana has sent her. When she replies, the reply arrives in Messages on Dana’s phone and Mac as usual, and BlueBot copies it so Dana’s code hears about it too. [Receive texts](https://usebluebot.com/docs/sending/receiving) explains that side.

Always send an `Idempotency-Key`. If the network drops after BlueBot accepted the text, sending the same request again with the same key returns the first answer instead of a second text. [Idempotency](https://usebluebot.com/docs/concepts/idempotency) says how to pick keys.

## Who to text: `to`

`to` takes a phone number or an email address, written however you have it:

| You send                                       | BlueBot texts                                                         |
| ---------------------------------------------- | --------------------------------------------------------------------- |
| `+15555550123`                                 | `+15555550123`                                                        |
| `5555550123`, `(555) 555-0123`, `555.555.0123` | `+15555550123`. A number with no country code is read as a US number. |
| `1 555 555 0123`, `tel:+15555550123`           | `+15555550123`                                                        |
| `+447700900123`                                | `+447700900123`. Write a number outside the US with its country code. |
| `maria@example.com`                            | The Apple ID with that address, over iMessage only.                   |

BlueBot stores every number in E.164 (`+15555550123`), and that is how `to`, `from` and every webhook write it. Match people in your own system by those digits.

An email address reaches only someone who uses it as their Apple ID, so a text to one always goes as an iMessage. If Messages can’t reach that address, the text fails with `not_on_imessage`.

When BlueBot can’t read `to` as a number or an address, the answer is `400 invalid_request` with `error.details` naming the `to` field.

The first text to somebody makes a contact for them (and fires `contact.created`). Until a text to them has actually gone, they count as a new person, and your sending rules allow only so many new people a day (50 unless you change it). People you’ve already had a text with, in either direction, don’t count against that limit. [Sending rules](https://usebluebot.com/docs/concepts/sending-rules)

If the person is on your stop list, the send isn’t refused. It’s answered `201` with `"state": "canceled"` and `"cancel_reason": "opted_out"`, and nothing is sent. Your code doesn’t need to check the stop list first. [Stop words](https://usebluebot.com/docs/concepts/stop-words)

## The words: `text`

Up to 2,000 characters. BlueBot sends them exactly as you write them, with line breaks (`\n` in JSON), emoji and links. `{{first_name}}` in `text` is sent as typed, so fill in names and times before you call, or send a saved template with `template_id` instead.

You may leave `text` out when the send has `files` or a `template_id`. A send with no words, no template and no files, or with only spaces, is `400 invalid_request`.

Messages turns a link into a preview card on the person’s phone when it can. Over SMS, a long text is split and joined again by the phone, as any long text is.

BlueBot deletes the words of every text after 30 days and keeps the facts (who, when, state, metadata). A message older than that has `"text": null` and `"text_deleted": true`. [Data retention](https://usebluebot.com/docs/concepts/data-retention)

## A saved template: `template_id`

Send `template_id` in place of `text` to send one of your saved templates, and `variables` to fill it in. Dana saved “Showing reminder” as `Hi {{first_name|there}}, see you at {{time}} at {{address}}.` and her code sends it like this:

```bash
curl -X POST "https://usebluebot.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: showing-48213-reminder" \
  -d '{
    "to": "+15555550123",
    "template_id": "tpl_1Hq2LxW9cVbN3mKpRtYs0a",
    "variables": {
      "time": "3 PM",
      "address": "412 Oak St"
    }
  }'
```

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

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

const message = await client.messages.send({
  to: '+15555550123',
  templateId: 'tpl_1Hq2LxW9cVbN3mKpRtYs0a',
  variables: {
    time: '3 PM',
    address: '412 Oak St',
  },
}, { idempotencyKey: 'showing-48213-reminder' });
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",
    template_id="tpl_1Hq2LxW9cVbN3mKpRtYs0a",
    variables={
        "time": "3 PM",
        "address": "412 Oak St",
    },
    idempotency_key="showing-48213-reminder",
)
print(message)
```

Each `{{variable}}` is filled from `variables` first, then from the person’s contact (`first_name`, `last_name`, `company`, `phone`, `email` and their custom fields), then `business`, your business name from **Settings**. Maria is a contact named Maria, so she gets “Hi Maria, see you at 3 PM at 412 Oak St.” Somebody with no name on file gets the fallback after the `|`: “Hi there, …”.

A variable with no value and no fallback refuses the send rather than sending a text with a hole in it. The answer is `400 invalid_request` naming each missing one, and nothing is sent:

The answer, `400`:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "The template “Showing reminder” needs `address`. Pass it in `variables`.",
    "hint": "Send each variable the template names in `variables`, or give it a fallback in the template, such as `{{first_name|there}}`.",
    "doc_url": "https://usebluebot.com/docs/errors/invalid_request",
    "details": [{ "field": "variables.address", "message": "Needed by the template." }],
    "request_id": "req_1Hq2LxW9cVbN3mKpRtYs0a"
  }
}
```

A `template_id` that isn’t one of your templates, or that you deleted, is `404 not_found`. Sending both `text` and `template_id`, or `variables` without a template, is `400 invalid_request`. The text you get back carries the filled-in words, and its `source_ref` is the template’s id, so you can tell later which template sent it. [Templates](https://usebluebot.com/docs/concepts/templates) covers writing them.

## iMessage or SMS: `service`

| `service`            | What happens                                                                                                                                                                                                                  |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auto` (the default) | Uses whatever the newest chat with the person used, and iMessage when there is none. If an iMessage never goes because the person isn’t on iMessage, BlueBot sends it once more as a regular text and sets `sms_retry: true`. |
| `imessage`           | iMessage only. If the person isn’t on iMessage, the text fails with `failure.reason: "not_on_imessage"` and nothing else is tried.                                                                                            |
| `sms`                | A regular text through your iPhone, even to somebody on iMessage.                                                                                                                                                             |

A regular text needs your iPhone nearby and on, with **Text Message Forwarding** turned on for your Mac. The Mac checks that for you, and `GET /v1/device` shows it as the `forwarding` check. Without it, texts to people who aren’t on iMessage can’t go.

In the answer, `service_requested` is what you asked for and `service` is what the text actually went as. `service` is `null` until the text is sent. [Services and SMS fallback](https://usebluebot.com/docs/concepts/services)

## Files: `files`

Upload each file with `POST /v1/files` first, then send up to 10 file ids. They go after the words in the order you list them.

```bash
curl -X POST "https://usebluebot.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: listing-412-oak-photos" \
  -d '{
    "to": "+15555550123",
    "text": "Here are the photos from today. The kitchen is the second one.",
    "files": [
      "file_1Hq2LxW9cVbN3mKpRtYs0a",
      "file_2Bf8QzD4kJmA6nXwEuTr1c"
    ]
  }'
```

```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: 'Here are the photos from today. The kitchen is the second one.',
  files: [
    'file_1Hq2LxW9cVbN3mKpRtYs0a',
    'file_2Bf8QzD4kJmA6nXwEuTr1c',
  ],
}, { idempotencyKey: 'listing-412-oak-photos' });
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="Here are the photos from today. The kitchen is the second one.",
    files=[
        "file_1Hq2LxW9cVbN3mKpRtYs0a",
        "file_2Bf8QzD4kJmA6nXwEuTr1c",
    ],
    idempotency_key="listing-412-oak-photos",
)
print(message)
```

A file id that isn’t yours, isn’t there any more, or was never uploaded is `404 not_found`, with `error.details` naming which one (`files.1`). [Files and voice memos](https://usebluebot.com/docs/sending/files) covers the types, sizes and what the person sees.

## A group chat: `chat_id`

To text into a group chat that already exists in Messages, send its `chat_id` in place of `to`. Send one or the other, never both.

```bash
curl -X POST "https://usebluebot.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: oak-st-closing-keys" \
  -d '{
    "chat_id": "chat_1Hq2LxW9cVbN3mKpRtYs0a",
    "text": "Keys are with the front desk. Congratulations, all of you."
  }'
```

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

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

const message = await client.messages.send({
  chatId: 'chat_1Hq2LxW9cVbN3mKpRtYs0a',
  text: 'Keys are with the front desk. Congratulations, all of you.',
}, { idempotencyKey: 'oak-st-closing-keys' });
console.log(message);
```

```python
import os
from bluebot import BlueBot

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

message = client.messages.send(
    chat_id="chat_1Hq2LxW9cVbN3mKpRtYs0a",
    text="Keys are with the front desk. Congratulations, all of you.",
    idempotency_key="oak-st-closing-keys",
)
print(message)
```

Find a chat’s id with `GET /v1/chats`. BlueBot can’t start a new group. [Group chats](https://usebluebot.com/docs/sending/group-chats)

## Later: `send_at`

A time in the future, with its zone, makes the text a scheduled draft that you can change or cancel until it’s due:

```bash
curl -X POST "https://usebluebot.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: reminder-48213-2026-11-20" \
  -d '{
    "to": "+15555550123",
    "text": "Reminder: your cleaning with Dr. Patel is tomorrow at 10:30 AM.",
    "send_at": "2026-11-19T17:00:00-05:00"
  }'
```

```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: 'Reminder: your cleaning with Dr. Patel is tomorrow at 10:30 AM.',
  sendAt: '2026-11-19T17:00:00-05:00',
}, { idempotencyKey: 'reminder-48213-2026-11-20' });
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="Reminder: your cleaning with Dr. Patel is tomorrow at 10:30 AM.",
    send_at="2026-11-19T17:00:00-05:00",
    idempotency_key="reminder-48213-2026-11-20",
)
print(message)
```

The answer comes back `"state": "scheduled"`. A `send_at` less than 5 seconds away, or in the past, sends now. [Scheduled texts](https://usebluebot.com/docs/sending/scheduled)

## Giving up: `expires_at`

A text that can’t go yet waits. `expires_at` is when BlueBot stops waiting: a text still queued at that time becomes `expired` and never goes. If you leave it out, it’s 24 hours after the text is due, or whatever your **Expire texts after** setting says (1 to 168 hours).

Set it when a late text would be worse than none. A reminder for a 10:30 appointment shouldn’t arrive at noon after the Mac woke up, so give it `"expires_at": "2026-11-20T10:30:00-05:00"`. It has to be after the time the text is due, or the send is `400 invalid_request`.

## Your own data: `metadata`

Up to 20 keys of your own, each a string, stored on the text and sent back on every webhook about it. Put your record’s id there and the `message.sent`, `message.failed` and other events tell you which record they’re about without a lookup:

```bash
curl -X POST "https://usebluebot.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: deal-48213-welcome" \
  -d '{
    "to": "+15555550123",
    "text": "Hi Maria, it’s Dana from Oak Realty. Thanks for reaching out about 412 Oak St.",
    "metadata": {
      "crm_id": "48213",
      "campaign": "new-lead"
    }
  }'
```

```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, it’s Dana from Oak Realty. Thanks for reaching out about 412 Oak St.',
  metadata: {
    crm_id: '48213',
    campaign: 'new-lead',
  },
}, { idempotencyKey: 'deal-48213-welcome' });
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, it’s Dana from Oak Realty. Thanks for reaching out about 412 Oak St.",
    metadata={
        "crm_id": "48213",
        "campaign": "new-lead",
    },
    idempotency_key="deal-48213-welcome",
)
print(message)
```

[Metadata](https://usebluebot.com/docs/concepts/metadata) has the limits.

## The answer

A send is answered `201` with the whole message. These are the fields you’ll read most:

The answer, `201`:

```json
{
  "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a",
  "object": "message",
  "direction": "out",
  "state": "queued",
  "to": "+15555550123",
  "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a",
  "conversation_id": "conv_1Hq2LxW9cVbN3mKpRtYs0a",
  "text": "Hi Maria, the showing at 412 Oak St is confirmed for 3 PM tomorrow. Reply here if anything changes.",
  "service_requested": "auto",
  "service": null,
  "source": "api",
  "metadata": {},
  "hold": null,
  "send_at": null,
  "expires_at": "2026-11-14T19:41:00.000Z",
  "created_at": "2026-11-13T19:41:00.000Z",
  "queued_at": "2026-11-13T19:41:00.000Z"
}
```

| Field                           | What it tells you                                                                                                                                                |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                            | The message’s id (`msg_…`). Keep it to read the text later, cancel it, or match webhooks to it.                                                                  |
| `state`                         | `queued` when it’s waiting for the Mac, `scheduled` for a later time, `canceled` for somebody on your stop list. [Message states](https://usebluebot.com/docs/concepts/message-states) |
| `hold`                          | `null` when nothing holds it back. Otherwise why it’s waiting and until when.                                                                                    |
| `expires_at`                    | When BlueBot stops waiting for it.                                                                                                                               |
| `contact_id`, `conversation_id` | The person and the conversation, the same ids every later text with them carries.                                                                                |
| `source`                        | `api` for a call with a key. The Zapier, Make, n8n and Pipedream apps and the CLI each send their own name.                                                      |

A text that can’t go right now is never refused. It’s accepted and carries a `hold`:

The answer, `201`:

```json
{
  "id": "msg_2Bf8QzD4kJmA6nXwEuTr1c",
  "object": "message",
  "state": "queued",
  "hold": {
    "reason": "quiet_hours",
    "until": "2026-11-14T13:00:00.000Z",
    "description": "Waiting for quiet hours to end at 8:00 AM."
  }
}
```

It goes on its own when the hold clears, unless it expires first. Don’t send it again and don’t treat a hold as an error. [Holds](https://usebluebot.com/docs/concepts/holds) lists every reason.

| You got                                                             | It means                                                                                   | Do next                                                                     |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| `201` with `"state": "queued"` and `"hold": null`                   | It’s next in line and usually goes within seconds.                                         | Wait for `message.sent`, or read it back with `GET /v1/messages/{id}`.      |
| `201` with a `hold`                                                 | Accepted and waiting: quiet hours, the Mac away, a daily limit, or its turn in the pacing. | Nothing. It goes when `hold.until` comes or the hold clears.                |
| `201` with `"state": "scheduled"`                                   | It goes at `send_at`.                                                                      | Change it with `PATCH` or cancel it until then.                             |
| `201` with `"state": "canceled"` and `"cancel_reason": "opted_out"` | The person is on your stop list, so nothing was sent.                                      | Leave it. Take them off the list only if they asked you to text them again. |
| `400 invalid_request`                                               | A field is wrong. `error.details` names each one.                                          | Fix it and send again.                                                      |
| `402 plan_lapsed`                                                   | Your plan isn’t active, so BlueBot isn’t taking new texts.                                 | Update your card on the **Billing** page.                                   |
| `403 insufficient_scope`                                            | The key can’t send.                                                                        | Use a key with `messages:send`. [API keys](https://usebluebot.com/docs/concepts/api-keys)         |
| `400 invalid_request` naming `variables.…`                          | The template needs a variable you didn’t send, and it has no fallback.                     | Send it in `variables`, or add a fallback to the template.                  |
| `404 not_found`                                                     | A `chat_id`, a `template_id` or a file id isn’t on your account.                           | Check the id named in `error.details`.                                      |
| `409 idempotency_conflict`                                          | You used this `Idempotency-Key` in the last 24 hours with a different request.             | Use a new key for a new text.                                               |
| `429 rate_limited`                                                  | More than 120 requests this minute with this key.                                          | Wait `Retry-After` seconds. [Rate limits](https://usebluebot.com/docs/concepts/rate-limits)       |

## What happens next

The Mac takes the text, sends it, and reads the result back from Messages. Each step is an event your webhook can receive:

1. `message.queued`: accepted and waiting (sent for every due text, held or not).
2. `message.sent`: Messages shows it sent. `service` says whether it went as an iMessage or a regular text.
3. `message.delivered` and `message.read`: iMessage only, and `read` only for people who share read receipts.
4. Or `message.failed`, `message.expired`, `message.canceled`, or `message.unconfirmed` when the Mac couldn’t tell whether it went.

[Message states](https://usebluebot.com/docs/concepts/message-states) draws the whole path, and [Change, cancel or settle a text](https://usebluebot.com/docs/sending/change-or-cancel) covers what you can do to a text before and after it goes.

## Every field

| Field         | Type      | What it does                                                                             |
| ------------- | --------- | ---------------------------------------------------------------------------------------- |
| `to`          | string    | A phone number or an email address. Send `to` or `chat_id`.                              |
| `chat_id`     | string    | An existing group chat (`chat_…`). Send `to` or `chat_id`.                               |
| `text`        | string    | The words, up to 2,000 characters. Optional when `files` or `template_id` has something. |
| `template_id` | string    | A saved template (`tpl_…`) to send in place of `text`.                                   |
| `variables`   | object    | Values for the template’s `{{variables}}`, by name. Only with `template_id`.             |
| `files`       | array     | Up to 10 file ids from `POST /v1/files`, sent after the words.                           |
| `service`     | string    | `auto` (default), `imessage` or `sms`.                                                   |
| `send_at`     | date-time | Schedule it.                                                                             |
| `expires_at`  | date-time | Give up on it if it’s still waiting at this time.                                        |
| `metadata`    | object    | Up to 20 string keys of your own.                                                        |

The reference page for [Send a text](https://usebluebot.com/docs/api/messages/send-message) lists every field of the answer and lets you try the call.
