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:
/v1/messagescurl -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. 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. 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.
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.
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.
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:
/v1/messagescurl -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:
{
"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. 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.
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.
/v1/messagescurl -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). 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.
/v1/messagescurl -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.
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:
/v1/messagescurl -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.
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:
/v1/messagescurl -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"
}
}'has the limits.
The answer
A send is answered 201 with the whole message. These are the fields you’ll read most:
{
"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. |
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:
{
"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. 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. |
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. |
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:
message.queued: accepted and waiting (sent for every due text, held or not).message.sent: Messages shows it sent.servicesays whether it went as an iMessage or a regular text.message.deliveredandmessage.read: iMessage only, andreadonly for people who share read receipts.- Or
message.failed,message.expired,message.canceled, ormessage.unconfirmedwhen the Mac couldn’t tell whether it went.
draws the whole path, and 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 lists every field of the answer and lets you try the call.