Skip to the page

Sending and receiving

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:

POST/v1/messagesSend a text →
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."
  }'

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 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 says how to pick keys.

Who to text: to

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

You sendBlueBot 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.comThe 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

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

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

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:

POST/v1/messagesSend a text →
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"
    }
  }'

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:

Response400A variable the template needs
{
  "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 covers writing them.

iMessage or SMS: service

serviceWhat 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.
imessageiMessage only. If the person isn’t on iMessage, the text fails with failure.reason: "not_on_imessage" and nothing else is tried.
smsA 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

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.

POST/v1/messagesSend a text →
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"
    ]
  }'

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

POST/v1/messagesSend a text →
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."
  }'

Find a chat’s id with GET /v1/chats. BlueBot can’t start a new group. 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:

POST/v1/messagesSend a text →
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"
  }'

The answer comes back "state": "scheduled". A send_at less than 5 seconds away, or in the past, sends now. Scheduled texts

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:

POST/v1/messagesSend a text →
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"
    }
  }'

Metadata has the limits.

The answer

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

Response201A text that is next in line
{
  "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"
}
FieldWhat it tells you
idThe message’s id (msg_…). Keep it to read the text later, cancel it, or match webhooks to it.
statequeued when it’s waiting for the Mac, scheduled for a later time, canceled for somebody on your stop list. Message states
holdnull when nothing holds it back. Otherwise why it’s waiting and until when.
expires_atWhen BlueBot stops waiting for it.
contact_id, conversation_idThe person and the conversation, the same ids every later text with them carries.
sourceapi 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:

Response201A text waiting for quiet hours to end
{
  "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 lists every reason.

You gotIt meansDo next
201 with "state": "queued" and "hold": nullIt’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 holdAccepted 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_requestA field is wrong. error.details names each one.Fix it and send again.
402 plan_lapsedYour plan isn’t active, so BlueBot isn’t taking new texts.Update your card on the Billing page.
403 insufficient_scopeThe key can’t send.Use a key with messages:send. 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_foundA chat_id, a template_id or a file id isn’t on your account.Check the id named in error.details.
409 idempotency_conflictYou used this Idempotency-Key in the last 24 hours with a different request.Use a new key for a new text.
429 rate_limitedMore than 120 requests this minute with this key.Wait Retry-After seconds. 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 draws the whole path, and Change, cancel or settle a text covers what you can do to a text before and after it goes.

Every field

FieldTypeWhat it does
tostringA phone number or an email address. Send to or chat_id.
chat_idstringAn existing group chat (chat_…). Send to or chat_id.
textstringThe words, up to 2,000 characters. Optional when files or template_id has something.
template_idstringA saved template (tpl_…) to send in place of text.
variablesobjectValues for the template’s {{variables}}, by name. Only with template_id.
filesarrayUp to 10 file ids from POST /v1/files, sent after the words.
servicestringauto (default), imessage or sms.
send_atdate-timeSchedule it.
expires_atdate-timeGive up on it if it’s still waiting at this time.
metadataobjectUp to 20 string keys of your own.

The reference page for Send a text lists every field of the answer and lets you try the call.