# BlueBot docs > BlueBot sends texts (iMessage, or SMS through your iPhone) from your own phone number. It needs a Mac you own that stays on with Messages signed in. It tells you about every text in and out with signed webhooks. It has a REST API, SDKs for TypeScript and Python, a CLI, an MCP server for AI assistants, and apps for Zapier, Make, n8n and Pipedream. ## What BlueBot is not - Anyone who wants to text people who didn’t ask to hear from them. BlueBot is not a bulk SMS gateway and has no cold outreach. - Anyone who needs a number they don’t own. BlueBot sends from your own number and provides none. - Anyone without a Mac that can stay on with Messages signed in. --- Source: https://usebluebot.com/docs # BlueBot docs > Send texts from your own number with one API call and get every reply as a signed webhook. These docs also cover Zapier, Make, n8n, Pipedream and AI assistants. BlueBot sends iMessages and SMS from your own phone number. It needs a Mac you own that stays on with Messages signed in. When your app, your Zap or your AI assistant asks BlueBot to send a text, the person gets it from you in their existing conversation with you. Every reply, delivery and read comes back to you as a signed webhook. - [Send your first text](https://usebluebot.com/docs/quickstart): Get a key and text yourself in four steps. It takes about five minutes. - [API reference](https://usebluebot.com/docs/api): Every operation, its fields, its errors, and a Try it panel. **What are you building?** - [Texts from my own app](https://usebluebot.com/docs/quickstart): Send with one request and react to replies with webhooks. - [An AI that texts](https://usebluebot.com/docs/mcp): Give Claude, ChatGPT or Cursor tools to text, read and check your Mac. - [A Zap or a scenario](https://usebluebot.com/docs/zapier): Use Zapier, Make, n8n or Pipedream without writing code. - [Reminders and follow-ups](https://usebluebot.com/docs/guides/appointment-reminders): Scheduled texts that wait for the right time and stop when someone replies. - [Replies in my CRM](https://usebluebot.com/docs/guides/hubspot-timeline): Log every text in and out on the person's record. - [A webhook handler](https://usebluebot.com/docs/webhooks): Get a signed event for every text, with retries and replay. ## How it works ```mermaid flowchart LR App[Your app] -- POST /v1/messages --> BB[BlueBot: queue, rules, holds] BB -- claim every 10 s --> Mac[Your Mac] Mac --> IM[iMessage from Messages] Mac --> SMS[SMS through your iPhone] Mac -- replies and states --> BB BB -- webhooks --> App ``` Your app calls `POST /v1/messages`. BlueBot checks your sending rules and either queues the text or holds it with a reason. Your Mac checks in every 10 seconds, takes the texts that are ready and hands each one to Messages, which sends it as an iMessage or, through your iPhone, as an SMS. The Mac reads the result back from Messages, and BlueBot tells you with a webhook. Read [How BlueBot works](https://usebluebot.com/docs/concepts/how-it-works) for the whole path. ## What BlueBot is not - Anyone who wants to text people who didn’t ask to hear from them. BlueBot is not a bulk SMS gateway and has no cold outreach. - Anyone who needs a number they don’t own. BlueBot sends from your own number and provides none. - Anyone without a Mac that can stay on with Messages signed in. BlueBot is for texting people who know you and want to hear from you, such as clients, customers and patients. The [sending rules](https://usebluebot.com/docs/concepts/sending-rules) keep it that way, and they're why your number stays in good standing with Apple and the carriers. ## Where to go next - [Quickstart](https://usebluebot.com/docs/quickstart): a key, a test text to yourself, and what to know before you send in a loop. - [Holds](https://usebluebot.com/docs/concepts/holds): why a text waits instead of failing, and when it goes. - [Webhooks](https://usebluebot.com/docs/webhooks): hear about every text in and out. - [MCP](https://usebluebot.com/docs/mcp): let an AI assistant text for you under your sending rules. - [llms.txt](https://usebluebot.com/llms.txt): these docs for a language model, one link per page. --- Source: https://usebluebot.com/docs/quickstart # Quickstart > Make an API key and text yourself from your own number in four steps, then learn the five things to know before you send in a loop. This takes about five minutes. You need a BlueBot account with your Mac connected. If you haven't connected it yet, open **Connect your Mac** in the dashboard first; the app walks you through it. Every example below shows curl, the TypeScript and Python SDKs, Go, Ruby, PHP and plain `fetch`. Pick a language once and every example on every page follows it. ### Make an API key In the dashboard, open **Developers → API keys** and press **New key**. Name it after what will use it ("Quickstart" will do), keep the scopes `messages:send` and `messages:read` ticked, and copy the key. It starts with `bb_live_` and is shown once. Put it in your environment so the examples can read it: ```bash export BLUEBOT_API_KEY="bb_live_…" ``` Check it with `GET /v1/me`, which says which account and key a request acts as: ```bash curl "https://usebluebot.com/api/v1/me" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const me = await client.me(); console.log(me); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) me = client.me() print(me) ``` | You got | It means | Do next | | ------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | `200` with your account's email | The key works. | Go on to step 2. | | `401 unauthorized` | BlueBot doesn't know the key: mistyped, revoked, or not sent as `Bearer `. | Copy the key again, or make a new one. [unauthorized](https://usebluebot.com/docs/errors/unauthorized) | | `403 insufficient_scope` | The key can't read the account. | Tick `account:read` on the key, or skip this check. | ### Check your Mac BlueBot sends through your Mac, so make sure it's there: ```bash curl "https://usebluebot.com/api/v1/device" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const device = await client.device.get(); console.log(device); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) device = client.device.get() print(device) ``` | You got | It means | Do next | | -------------------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `"status": "online"` and `"checks_ok": true` | The Mac checked in within the last 2 minutes and every check passed. | Go on to step 3. | | `"status": "offline"` | The Mac hasn't checked in for 2 minutes: asleep, off, or off the internet. | Wake it. Texts you send now wait for it rather than fail. | | `"status": "never_connected"` | No Mac is connected to this account. | Open **Connect your Mac** in the dashboard. | | `"checks_ok": false` | A setup check needs you, such as Messages being signed out. | Open BlueBot from the Mac's menu bar; it says which check and how to fix it. | ### Text yourself Send a text to your own phone number. It arrives in Messages on your iPhone like any other text you send yourself. ```bash curl -X POST "https://usebluebot.com/api/v1/messages" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: quickstart-first-text" \ -d '{ "to": "+15555550123", "text": "Hello from BlueBot", "metadata": { "test": "quickstart" } }' ``` ```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: 'Hello from BlueBot', metadata: { test: 'quickstart', }, }, { idempotencyKey: 'quickstart-first-text' }); 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="Hello from BlueBot", metadata={ "test": "quickstart", }, idempotency_key="quickstart-first-text", ) print(message) ``` Put your own number in `to`. US numbers can leave off the `+1`, and `(555) 555-0123` works too. | You got | It means | Do next | | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `201` with `"state": "queued"` and `"hold": null` | BlueBot accepted the text and it's next in line. It goes within a few seconds. | Copy the message's `id` and go on to step 4. | | `201` with a `hold` | BlueBot accepted the text and it's waiting. `hold.reason` says why and `hold.until` says until when. | Read [Holds](https://usebluebot.com/docs/concepts/holds). A test at night usually meets `quiet_hours`. | | `201` with `"state": "canceled"` | Your number is on your own stop list. | Take it off with `DELETE /v1/stops/{person}` and send again. | | `400 invalid_request` | A field is wrong. `error.details` names it. | Fix the field and send again with a new `Idempotency-Key`. The old key keeps its first answer, and a changed request under it is refused as `idempotency_conflict`. | A text that can't go right now is never refused. It's accepted with a `hold` and goes on its own when the hold ends, so a loop never needs to retry a send. ### Watch it go Read the message back with the `id` from step 3: ```bash curl "https://usebluebot.com/api/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const message = await client.messages.get('msg_1Hq2LxW9cVbN3mKpRtYs0a'); console.log(message); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) message = client.messages.get("msg_1Hq2LxW9cVbN3mKpRtYs0a") print(message) ``` | You got | It means | Do next | | --------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------- | | `"state": "sent"` and `"service": "imessage"` | Messages on your Mac shows it sent as an iMessage. | Look at your phone. Soon `delivered_at` fills in too. | | `"state": "sent"` and `"service": "sms"` | It went as a regular text through your iPhone. | SMS never reports delivery, so `sent` is as far as it goes. | | `"state": "queued"` or `"sending"` | It hasn't gone yet. | Read it again in a few seconds, or check `hold`. | | `"state": "failed"` | Messages couldn't send it. `failure.reason` says why. | Read [Message states](https://usebluebot.com/docs/concepts/message-states). | | `"state": "unconfirmed"` | The Mac handed it to Messages and couldn't confirm it went. | Look in Messages, then settle it with `POST /v1/messages/{id}/resolve`. | Polling is fine for a test. In an app, let BlueBot tell you instead: point a [webhook](https://usebluebot.com/docs/webhooks) at your server, or run `bluebot listen --forward-to http://localhost:3000/webhooks` while you build. ## Before you loop You've sent one text. Before you send a hundred from a loop, a Zap or an agent, know these five things. ### Holds A send is answered at once, and a text that can't go yet comes back `queued` with a `hold`. It goes by itself when the hold clears. Don't retry a held text and don't treat a hold as an error; read `hold.reason` if you want to tell someone why it's waiting. [Holds](https://usebluebot.com/docs/concepts/holds) lists every reason. The answer, `201`: ```json { "id": "msg_2Bf8QzD4kJmA6nXwEuTr1c", "object": "message", "state": "queued", "to": "+15555550123", "hold": { "reason": "quiet_hours", "until": "2026-11-14T13:00:00.000Z", "description": "Waiting for quiet hours to end at 8:00 AM." } } ``` ### Pacing The Mac leaves 20 seconds between texts by default, the way a person sends them one at a time. Send 30 texts in a loop and the 30th goes about 10 minutes later. You can set the pace from 5 to 300 seconds; a faster pace is the quickest way to get a number flagged. [Sending rules](https://usebluebot.com/docs/concepts/sending-rules) ### Quiet hours From 9:00 PM to 8:00 AM in your time zone, texts wait. That's on by default and you can change the hours. A text sent at 11 PM goes at 8 AM, unless it expires first. ### Idempotency Send an `Idempotency-Key` header with every POST: any string that is new for each text you mean to send, such as your own order id. If your request times out and you send it again with the same key, you get the first answer back and nobody gets two texts. The SDKs make a key for you. [Idempotency](https://usebluebot.com/docs/concepts/idempotency) ### Expiry A text still waiting 24 hours after it was due expires and never goes, so "Your table is ready" can't arrive a day late. Set `expires_at` on a send to choose a shorter or longer wait, and listen for `message.expired`. [Scheduled sends](https://usebluebot.com/docs/sending/scheduled) ## Next - [Receive replies](https://usebluebot.com/docs/webhooks): a webhook for `message.received`. - [Send a picture](https://usebluebot.com/docs/sending/files): upload a file and send it with the words. - [Install an SDK](https://usebluebot.com/docs/sdks/typescript): retries, idempotency keys and paging are built in. - [Limits](https://usebluebot.com/docs/limits): every cap, why it's there, and what happens when you reach it. --- Source: https://usebluebot.com/docs/limits # Limits > Every cap BlueBot has, why it's there and what happens when you reach it. Most of them hold a text until it can go rather than refuse it. BlueBot sends from your own number through your own Mac, so its limits are there to keep that number in good standing with Apple and the carriers, and to keep one runaway loop from becoming a problem. They come in two kinds: - **Sending rules** are yours. Pace, new people per day, quiet hours and expiry each have a default you can change in **Settings → Sending rules** or with `PATCH /v1/settings`. - **Fixed limits** are the same for every account: the daily ceiling, sizes, the API's rate limit and how long things are kept. Almost nothing here refuses a send. A text that can't go yet is accepted and waits with a `hold` that says why and until when, then goes on its own. The few limits that do refuse a request answer with an error code before anything is queued, so nothing is half done. ## Every limit | Limit | Value | Why | When you reach it | A setting? | | ---------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | **Texts per day** | 1,000 per account | One person’s business never needs more, and a number that sends more than this looks like a bulk sender to Apple. | Texts over it wait with `platform_daily_limit` until midnight in your zone, and you get one email. | No | | **New people per day** | 50 by default | Apple flags accounts that message many strangers. Texts to people already in a conversation don’t count. | Texts to new people wait with `daily_new_people_limit` until midnight. Raise it in Settings; the dashboard warns above 200. | Yes | | **Pace** | 20 seconds between texts (5 to 300) | A person types one text at a time; a Mac that fires 50 in a second looks like a bot. | Texts queue in order with `pacing` and an estimated `until`. | Yes | | **Quiet hours** | 9:00 PM to 8:00 AM in your zone, on by default | Texts at 3 AM earn STOP replies. | Texts wait with `quiet_hours` until they end. | Yes | | **Expiry** | 24 hours by default; any time you set per text | “Your table is ready” is wrong a day later. A text that can’t go in time shouldn’t go late. | A text still waiting at `expires_at` becomes `expired` and fires `message.expired`. | Yes | | **Text length** | 2,000 characters | Long texts are split by carriers when they fall back to SMS, and read badly as iMessage. | The send is refused with `invalid_request`. | No | | **Files** | 25 MB each, 10 per text | Messages handles big files slowly, and SMS can’t carry them at all. | The upload is refused with `payload_too_large`. | No | | **Metadata** | 20 keys, 500 characters a value | It’s sent with every event for the text, so it’s meant for ids rather than documents. | The send is refused with `invalid_request`. | No | | **API requests** | 120 a minute per key | Keeps one runaway loop from slowing everyone. Sends that can’t go yet are held, never refused with a 429. | `rate_limited` (429) with `retry_after`. Every answer carries `RateLimit-*` headers. | No | | **Idempotency keys** | kept 24 hours | That covers any retry without holding a key forever. | After 24 hours the same key is treated as new. | No | | **API keys** | 10 live per account | One per app or person is plenty; more is usually keys nobody revoked. | `limit_reached` until you revoke one. | No | | **Broadcast size** | 500 recipients | Broadcasts are for people who asked to hear from you, and the Mac sends them at your pace. | The broadcast is refused with `limit_reached`. | No | | **Words kept** | 30 days | The words of a text are the private part, so BlueBot deletes them first. | 30 days after a text went or came, its `text` and files are deleted, as are a finished broadcast’s words; the facts (who, when, state) stay. | No | | **Events kept** | 30 days | Long enough to catch up after an outage on your side. | `GET /v1/events` reads back 30 days. | No | | **Webhook retries** | about 72 hours | Covers a weekend outage on your side. | After 72 hours of failures the webhook is paused and you get one email. Nothing is dropped silently. | No | | **Lookups** | answered within 15 seconds | The answer comes from your Mac, which may be busy or away. | After 15 seconds the answer is `service: "unknown"` with `basis: "timeout"` or `"device_offline"`. | No | ## Limits that hold a text These four never cause an error. The text is answered `201`, comes back `queued` with a `hold`, and goes by itself when the hold clears, unless its `expires_at` passes first. | Limit | `hold.reason` | It clears | | ------------------------------------------- | ------------------------ | ------------------------------------------------------------- | | Texts per day (1,000) | `platform_daily_limit` | At midnight in your time zone. | | New people per day (50 by default) | `daily_new_people_limit` | At midnight in your time zone, or when you raise the setting. | | Pace (20 seconds by default) | `pacing` | At the text's slot. `hold.until` is the estimate. | | Quiet hours (9:00 PM to 8:00 AM by default) | `quiet_hours` | When quiet hours end. | Here is how they add up. Dana runs a small cleaning company and sends 120 texts at 2:00 PM to remind clients of next week's visits. 80 of the clients have never texted with her through BlueBot. - Only 50 of the 80 new people can be texted today. The other 30 wait with `daily_new_people_limit` until midnight. Midnight is inside her quiet hours, so at midnight their hold becomes `quiet_hours`, and they go from 8:00 AM. - The 90 that can go today leave her Mac 20 seconds apart, so the last of them goes about 30 minutes after the first. While they wait their turn, their hold is `pacing`. - The 30 held overnight still have until 2:00 PM tomorrow before their 24-hour expiry, so they go in time. - None of this needs a retry. Her loop sent 120 requests, got 120 answers and is done. A text to someone you already have a conversation with never counts against new people, so a reply to a client who texted you a minute ago goes at once (after the pace). > **Don't retry a held text.** A hold is not an error. Sending the same text again makes a second text, which will also be held, and both will go. Read `hold.reason` if you want to say why it's waiting, or listen for `message.sent`. ## Limits that refuse a request These answer with an error and queue nothing. Fix the request and send it again. | Limit | Value | You get | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------- | | Words in one text | 2,000 characters | [`invalid_request`](https://usebluebot.com/docs/errors/invalid_request) (400), with `details` naming `text` | | One file | 25 MB | [`payload_too_large`](https://usebluebot.com/docs/errors/payload_too_large) (413) | | Files on one text | 10 | [`invalid_request`](https://usebluebot.com/docs/errors/invalid_request) (400) | | Metadata | 20 keys, names up to 40 characters, values up to 500 | [`invalid_request`](https://usebluebot.com/docs/errors/invalid_request) (400) | | A JSON body | 1 MB | [`payload_too_large`](https://usebluebot.com/docs/errors/payload_too_large) (413) | | Requests per key | 120 a minute | [`rate_limited`](https://usebluebot.com/docs/errors/rate_limited) (429) with `Retry-After` | | API keys per account | 10 | [`limit_reached`](https://usebluebot.com/docs/errors/limit_reached) (409) | | People in one broadcast | 500 | [`limit_reached`](https://usebluebot.com/docs/errors/limit_reached) (409) | The rate limit counts requests, not texts. A send that goes through but is held still counts as one request, and reading a message back counts as one too. If you poll for state, poll slowly or use [webhooks](https://usebluebot.com/docs/webhooks) instead. Every answer carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so a program can slow down before it's refused. [Rate limits](https://usebluebot.com/docs/concepts/rate-limits) has the details. ## Limits on time | What | How long | What happens after | | -------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------ | | A queued text | 24 hours after it was due, unless you set `expires_at` | It becomes `expired` and `message.expired` fires. It never goes. | | An `Idempotency-Key` | 24 hours | The same key is treated as new. | | The words and files of a text | 30 days | They're deleted. The message's facts stay. [Data retention](https://usebluebot.com/docs/concepts/data-retention) | | Events (`GET /v1/events`) | 30 days | Older events are gone from the feed. | | Webhook retries | About 72 hours | The webhook is paused and you get one email. [Retries](https://usebluebot.com/docs/webhooks/retries) | | A file's `url` | An hour | Ask `GET /v1/files/{id}` for a fresh one. | | A lookup | Up to 15 seconds | The answer is `service: "unknown"` with `basis: "timeout"`. | | A file you upload and never send | A day | It's deleted. | The expiry setting runs from 1 to 168 hours (a week). Per text, `expires_at` can be any time after the text is due. ## See where you stand `GET /v1/device` answers today's counts against the daily limits, and how many texts are waiting: ```bash curl "https://usebluebot.com/api/v1/device" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const device = await client.device.get(); console.log(device); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) device = client.device.get() print(device) ``` The answer, `200`: ```json { "object": "device", "status": "online", "held_count": 2, "queued_count": 5, "today": { "sent": 14, "new_people": 3, "new_people_limit": 50, "daily_limit": 1000, "resets_at": "2026-11-14T05:00:00.000Z" } } ``` | You got | It means | Do next | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `today.new_people` equal to `today.new_people_limit` | Texts to anyone new wait until `resets_at`. Texts to people you already talk with still go. | Wait, or raise `new_people_per_day` if you know these people asked to hear from you. | | `today.sent` equal to `today.daily_limit` | Every text waits until `resets_at`. You got one email about it. | Wait. The ceiling isn't a setting. | | `held_count` above 0 | Some queued texts are waiting on a hold. | List them with `GET /v1/messages?state=queued` and read each `hold`. | `resets_at` is the next midnight in your account's time zone, written in UTC. Today's counts start again then. ## Why the daily ceiling isn't a setting 1,000 texts a day is far more than one person's business sends, and a number that sends more than that looks like a bulk sender to Apple, whatever the reason. BlueBot is for texting people who know you. If you need to text thousands of people a day, a hosted service with its own numbers is the better tool, as [What BlueBot is not](https://usebluebot.com/docs#what-bluebot-is-not) explains. The new-people setting is yours, from 0 to 1,000. The dashboard warns above 100, because Apple flags accounts that message many strangers, and once an account is flagged no setting brings it back. --- Source: https://usebluebot.com/docs/concepts/how-it-works # How BlueBot works > The whole path of a text from your app to the person's phone, and of their reply back to you, with what happens at each step and how you hear about it. BlueBot has three parts you deal with: the API (and everything built on it, such as Zapier and MCP), a small app in your Mac's menu bar, and webhooks that tell you what happened. Your texts go out from Messages on your own Mac, so they come from your own number and appear in your existing conversations. BlueBot gives you no phone number and never sends your texts from anywhere but your Mac. ```mermaid flowchart LR App[Your app] -- POST /v1/messages --> BB[BlueBot: queue, rules, holds] BB -- claim every 10 s --> Mac[Your Mac] Mac --> IM[iMessage from Messages] Mac --> SMS[SMS through your iPhone] Mac -- replies and states --> BB BB -- webhooks --> App ``` ## A text out Dana runs a dog-grooming business. At 2:03 PM her booking system asks BlueBot to text Maria, a client, that Biscuit is ready for pickup. Here is everything that happens. ### 1. BlueBot accepts the text Her system calls `POST /v1/messages` with Maria's number and the words. In the same request BlueBot: - reads the number the way a person typed it and turns it into E.164 (`+15555550123`); - finds Maria among Dana's contacts, or adds her; - checks Dana's stop list. Had Maria texted STOP before, the text would be answered at once as `canceled` with `cancel_reason: "opted_out"`, and nothing more would happen; - makes the text `scheduled` if it has a `send_at` in the future, or `queued` if it's due now; - works out whether anything holds it right now (quiet hours, the Mac away, a daily limit, its turn in the pace) and writes that on the text as its `hold`. The answer is `201` with the message, and `message.queued` fires. The request is over in a fraction of a second, whether the text can go now or at 8:00 tomorrow morning. Nothing in the API waits for the Mac. ### 2. The Mac takes it The BlueBot app on Dana's Mac checks in every 10 seconds. Each answer says how many texts are ready, and the Mac claims them: up to five at a time, each under a 3-minute lease. At a slow pace it claims fewer, so the last one still goes before its lease runs out. A claimed text becomes `sending`. If the Mac hasn't checked in for 2 minutes, it's away. Texts then wait with `hold.reason: "device_offline"` and go when it comes back, unless they expire first. ### 3. Messages sends it For each text, the Mac hands the words to the Messages app through AppleScript, the same way a person would send them, and waits your pace (20 seconds by default) before the next. Pictures and files are downloaded first through links that last 10 minutes, then sent after the words. The service is chosen like this: - `auto` (the default) uses whatever the newest chat with Maria used, and iMessage when there's no chat yet. - `imessage` sends an iMessage and nothing else. - `sms` sends a regular text through Dana's iPhone, which needs Text Message Forwarding turned on. [Services and SMS fallback](https://usebluebot.com/docs/concepts/services) says how each one behaves. ### 4. The Mac reads it back The Mac counts a text as sent only when Messages shows it. It reads its own text back from Messages' database for up to 30 seconds and reports one of these: | What the Mac saw | What BlueBot does | | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Messages shows it sent | The text becomes `sent`, with `service` saying iMessage or SMS. `message.sent` fires. | | An iMessage failed and never went, on an `auto` text | BlueBot queues it once more as a regular text through the iPhone (`sms_retry: true`). It keeps its place in today's counts. | | Messages refused it, or an iMessage-only text failed | The text becomes `failed` with a `failure.reason`. `message.failed` fires. | | It can't tell | The Mac says nothing. When the lease runs out the text becomes `unconfirmed` and `message.unconfirmed` fires. It may have gone, so BlueBot never sends it again on its own. | Most texts go in a few seconds. The last case is rare and is the price of never sending anyone the same text twice. [Message states](https://usebluebot.com/docs/concepts/message-states) covers each state and how to settle an `unconfirmed` text. ### 5. Delivered and read For seven days after a text goes, the Mac watches it in Messages. When Messages records it delivered, and later read, the next check-in says so: the text becomes `delivered`, then `read`, and `message.delivered` and `message.read` fire. Only iMessage reports delivery, and only people who share read receipts report reading. A text that went as SMS stays `sent`. ## A reply in Maria texts back, "On my way!" Here is that path. 1. **The Mac reads it.** On its next check-in the Mac reads new messages from Messages' database (it never changes that database) and posts them to BlueBot. Which conversations it reads is your **inbound mode**: `replies` (the default) reads only conversations with people you've texted through BlueBot, and group chats where everyone else is such a person; `everything` reads every conversation with a phone number or an email address. Short codes and business chats are never read. 2. **BlueBot stores it** as a message with `direction: "in"` and `state: "received"`, on Maria's contact and in her conversation. 3. **Stop words.** If the whole reply is a stop word such as STOP, Maria goes on the stop list and every text still waiting for her is canceled. [Stop words](https://usebluebot.com/docs/concepts/stop-words) 4. **Files.** A picture or voice memo comes up from the Mac separately, usually within seconds. 5. **`message.received` fires** once the files are up, or after 2 minutes without them, so your webhook can always fetch what the message names. The Mac copies both directions of a conversation. A text Dana types herself on her iPhone, in a conversation BlueBot reads, also arrives, as `direction: "out"` with `source: "mac"`, and fires `message.sent`. Tapbacks, edits and unsends Messages records come through too, as `reaction.added`, `reaction.removed`, `message.edited` and `message.unsent`. So your app, your CRM or your AI assistant sees the whole conversation, including texts it didn't send. When you first connect a Mac, it also copies those conversations from the 7 days before. ## How you hear about it Every step above fires an event. A [webhook](https://usebluebot.com/docs/webhooks) posts each one to your server, signed, within seconds, and retries for about 72 hours if your server doesn't answer. The same events are in `GET /v1/events` for 30 days, so a program that was down can catch up. Each event carries the message as the API returns it, with the `metadata` you sent, so you can match it to your own record without another request. | You want to know | Listen for | | ------------------------------- | ---------------------------------------------------------- | | A text went | `message.sent` | | It reached their phone | `message.delivered` (iMessage only) | | They replied | `message.received` | | It didn't go | `message.failed`, `message.expired`, `message.unconfirmed` | | They asked you to stop | `contact.stopped` | | Your Mac went away or came back | `device.offline`, `device.online` | | Something on the Mac needs you | `device.check_failed` | The [event catalog](https://usebluebot.com/docs/webhooks/events) has every event with an example. ## Where things are kept - **On BlueBot's servers:** the queue, your sending rules, your contacts and stop list, and every message. The words and files of a message are deleted after 30 days; its facts (who, when, its state and service) stay so your history and counts stay right. [Data retention](https://usebluebot.com/docs/concepts/data-retention) - **On your Mac:** the app's sign-in token, in the Keychain, and the files of texts waiting to send, which it deletes once each text is reported, and within an hour otherwise. The app never writes to Messages' database and never changes anything in Messages but sending. - **In your logs and analytics:** never the words of a text. BlueBot keeps them out of its own logs, analytics and error reports too. ## What it needs from you BlueBot can only send while your Mac is on, awake, online and signed in to Messages. That's the trade for using your own number with no monthly line fee. A Mac mini on a shelf is the easiest way to meet it; a MacBook works if it stays plugged in and open. [The Mac app](https://usebluebot.com/docs/concepts/mac-app) lists everything it checks and how to fix each one. --- Source: https://usebluebot.com/docs/concepts/mac-app # The Mac app > What the BlueBot app in your menu bar does, what your Mac needs, the five checks it runs and how to fix each, what it means when the Mac is away, and how to keep it awake. BlueBot sends through a small app in your Mac's menu bar. It takes the texts that are ready, hands each to the Messages app, reads back what Messages did and tells BlueBot. It also copies the conversations you chose back to BlueBot, so your dashboard, your webhooks and your AI assistant see every reply. You set it up once and then leave the Mac alone. ## What your Mac needs - **macOS Sonoma (macOS 14) or later.** - **Messages signed in** with the Apple ID your number uses, the same one on your iPhone. - **To stay on, awake and online.** BlueBot can't send while the Mac sleeps. A Mac mini or an iMac is the easiest; a MacBook works if it stays plugged in and open, since closing the lid puts it to sleep. - **Your iPhone, for regular texts.** An SMS to someone who isn't on iMessage goes from your iPhone, so Text Message Forwarding must be on for this Mac (on the iPhone, **Settings → Apps → Messages → Text Message Forwarding**) and the iPhone must be on and connected. - **Full Disk Access for BlueBot**, so it can read Messages' database to confirm each text and copy replies. It reads that database and never writes to it. One account has one Mac and one number. Connecting a second Mac disconnects the first. ## Set it up 1. Download BlueBot from **Download** at the top of usebluebot.com, drag it to Applications and open it. Its icon, two speech bubbles, appears in the menu bar. 2. Press **Sign in to BlueBot**. Your browser opens BlueBot's dashboard and asks you to confirm **Connect this Mac**; the browser then hands the Mac a one-time code. The app keeps the token it gets in your Mac's Keychain, and that token is its only credential. 3. The app's window walks you through each check below and fixes what it can for you. 4. Send yourself a test text from the window. The first send is when macOS asks whether BlueBot may control Messages; say **OK**. The app adds itself to Login Items, so a Mac that restarts after a power cut or a macOS update goes on sending without anyone opening it. You can take it out in **System Settings → General → Login Items**, and that choice stays. ## The checks Every 10 seconds the app checks in with BlueBot and reports five checks. Each is `ok`, `needs_you` or `unknown`, and you can read them with `GET /v1/device`, in the dashboard and in the app's window. When one turns `needs_you`, BlueBot fires `device.check_failed` and the menu bar icon shows a dot. | Check (`checks.…`) | What it checks | What the app saw, when it needs you | How to fix it | | ------------------ | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `messages` | Messages is open, signed in to iMessage, and BlueBot may control it. | "Messages isn't signed in to iMessage." or "BlueBot isn't allowed to control Messages." | Press **Open Messages**, sign in with your Apple Account and turn on iMessage in its settings. For the second, turn BlueBot on for Messages in **System Settings → Privacy & Security → Automation**. | | `full_disk_access` | BlueBot can open Messages' database to read sends and replies. | "Messages' database didn't open." | Turn BlueBot on in **System Settings → Privacy & Security → Full Disk Access**. The window's **Open the setting** button takes you there. | | `forwarding` | Messages on the Mac has an account for regular texts, which Text Message Forwarding adds. | "Messages has no account for regular texts." | On your iPhone, turn on **Text Message Forwarding** for this Mac. Without it, iMessages still go, and texts to people without iMessage fail. | | `awake` | The Mac never sleeps, and (on a desktop Mac) starts up again after a power cut. | "This Mac sleeps after 10 minutes." or "This Mac doesn't start up after a power cut." | Press **Keep it awake** in the window's Setup page. See below. | | `filevault` | Whether FileVault is on. | "FileVault is on." | This check only warns you. After a restart, a Mac with FileVault waits at the login screen for your password, and Messages can't open until someone types it. Leave it on and sign in after each restart, or turn it off in **System Settings → Privacy & Security**. | `unknown` means the app couldn't tell, usually because Messages isn't open yet. `checks_ok` on the device is `true` when no check needs you. ```bash curl "https://usebluebot.com/api/v1/device" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const device = await client.device.get(); console.log(device); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) device = client.device.get() print(device) ``` The answer, `200`: ```json { "object": "device", "status": "online", "name": "Dana’s MacBook Air", "number": "+15555550100", "app_version": "1.0.0", "macos_version": "15.1", "last_seen_at": "2026-11-13T19:41:00.000Z", "checks": { "messages": { "state": "needs_you", "detail": "Messages isn’t signed in to iMessage." }, "full_disk_access": { "state": "ok", "detail": null }, "forwarding": { "state": "ok", "detail": null }, "awake": { "state": "ok", "detail": null }, "filevault": { "state": "ok", "detail": null } }, "checks_ok": false } ``` ## Keeping the Mac awake A sleeping Mac sends nothing, so this is the check that matters most. **Keep it awake**, on the Setup page of the app's window, changes the Mac's power settings so it never sleeps and, on a desktop Mac, starts up again by itself after a power cut. macOS asks for your administrator password before anything changes; you type it into macOS, and BlueBot never sees it or keeps it. The display can still turn off; only the Mac itself has to stay awake. A MacBook is read while it's plugged in, has no setting to start after a power cut, and goes to sleep when its lid closes. ## When the Mac is away The Mac is **away** when it hasn't checked in for 2 minutes: it's asleep, off, signed out, or off the internet. Then: - `GET /v1/device` answers `"status": "offline"` with `offline_since`, and `device.offline` fires. - Every queued text waits with `hold.reason: "device_offline"`. Nothing fails and nothing is lost, and new sends are still accepted. - If the Mac stays away 10 minutes, you get one email about it. - When it checks in again, `device.online` fires and the waiting texts go in order, at your pace. - A text still waiting when its `expires_at` passes (24 hours after it was due, by default) expires and never goes, so a Mac that's away all weekend doesn't send Friday's "Your table is ready" on Monday. `"status": "never_connected"` means no Mac has been connected to the account yet. ## Pausing The menu has **Pause for an hour** and **Pause until tomorrow morning** (8:00 AM in your account's time zone). While paused, texts are still accepted and wait with `hold.reason: "paused"`, and the menu bar icon dims. Resume from the same menu, the dashboard, or by setting `paused_until` to `null` with `PATCH /v1/settings`, which can also pause until any time you choose. ## What it copies The app copies conversations from Messages to BlueBot, in both directions, so replies reach your webhooks and texts you type yourself on your Mac or iPhone show up too (as `source: "mac"`). Which conversations is your **inbound mode**, in **Settings** or `inbound_mode` on `PATCH /v1/settings`: - **`replies`** (the default) copies conversations with people you've texted through BlueBot, and group chats where everyone else is such a person. Your personal conversations stay on your Mac. - **`everything`** copies every conversation with a phone number or an email address, group chats included. Conversations with short codes and businesses are never copied. When you first connect, the app also copies those conversations from the 7 days before. It copies tapbacks, edits and unsends, and pictures and voice memos up to 25 MB. **Transcribe voice memos**, in the menu, turns incoming voice memos into words on the Mac itself with Apple's speech recognition, and the transcript arrives with the memo. It's off until you turn it on. ## What it keeps The app keeps its token in the Keychain and nothing else of yours on disk, apart from the files of texts waiting to send. Those wait in `~/Pictures/BlueBot Outbox/`, where Messages can read them, and are deleted once each text is reported, and within an hour otherwise. Nothing the app logs carries the words of a message. ## Updates The app updates itself. It checks for a new version every six hours and installs it only between sends, once every report has reached BlueBot, so an update never leaves BlueBot unsure whether a text went. ## What it never does - It never asks you to turn off System Integrity Protection and never uses Apple's private frameworks. That's why it can't send a tapback, show typing or unsend. [Capabilities](https://usebluebot.com/docs/concepts/capabilities) - It never writes to Messages' database or changes your conversations. - It never sends a text twice when it can't tell whether the first one went. That text becomes `unconfirmed` and waits for you. [Message states](https://usebluebot.com/docs/concepts/message-states) --- Source: https://usebluebot.com/docs/concepts/message-states # Message states > Every state a text can be in, every move between them and the webhook each move fires, why a text failed or was canceled, and how to settle one BlueBot couldn't confirm. Every message has a `state`. A text you send moves forward through a few of them and stops at one that's settled; a text that comes in is `received` and stays there. Each move fires a webhook, so you never need to poll to find out what happened. ```mermaid stateDiagram-v2 [*] --> scheduled: send_at in the future [*] --> queued scheduled --> queued: send_at reached scheduled --> canceled queued --> sending: the Mac claims it queued --> canceled queued --> expired: expires_at passed sending --> sent sending --> failed sending --> unconfirmed: lease ran out unconfirmed --> sent: mark_sent sent --> delivered: iMessage only delivered --> read: read receipts on [*] --> received: a text in ``` ## The states | State | What it means | What can come next | Settled | | ------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | ------- | | `scheduled` | Has a `send_at` in the future. You can edit or cancel it. | `queued` at `send_at`, or `canceled`. | No | | `queued` | Accepted and waiting for the Mac. `hold` says why when it can’t go yet. | `sending`, `expired` or `canceled`. | No | | `sending` | The Mac claimed it and is handing it to Messages. | `sent`, `failed` or `unconfirmed`. | No | | `sent` | Messages shows it sent. `service` says iMessage or SMS. | `delivered` (iMessage only). | Yes | | `delivered` | iMessage reported it on the person’s device. SMS never reaches this state. | `read`, if they share read receipts. | Yes | | `read` | The person opened it. | Nothing; this is as far as a text goes. | Yes | | `failed` | It didn’t go. `failure_reason` says why. | Nothing. Send a new text if you want to try again. | Yes | | `unconfirmed` | The Mac handed it to Messages but couldn’t confirm it went. BlueBot won’t send it again on its own. | `sent` when you mark it sent, or a new text when you send it again. | No | | `expired` | Still waiting when `expires_at` passed. | Nothing. | Yes | | `canceled` | Canceled while waiting. `cancel_reason` says by whom. | Nothing. | Yes | | `received` | A text that came in. | Nothing. | Yes | "Settled" means the text won't move again on its own, apart from `sent` going on to `delivered` and `read`. `unconfirmed` isn't settled: it waits for you to say what happened. ## Every move and its webhook | From | To | When | Event | | ------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | | (new) | `queued` | You send a text that's due now. A `hold` may say why it can't go yet. | `message.queued` | | (new) | `scheduled` | You send a text with a `send_at` more than 5 seconds ahead. | None | | (new) | `canceled` | The person is on your stop list. `cancel_reason` is `opted_out`. | `message.canceled` | | `scheduled` | `queued` | Its `send_at` arrives, or you move `send_at` to now with `PATCH`. | `message.queued` | | `scheduled` | `canceled` | You cancel it, the person stops, or its sequence or broadcast stops. | `message.canceled` | | `queued` | `sending` | The Mac claims it and starts handing it to Messages. | None | | `queued` | `canceled` | You cancel it, the person stops, or its sequence or broadcast stops. | `message.canceled` | | `queued` | `expired` | It's still waiting when `expires_at` passes. | `message.expired` | | `sending` | `sent` | Messages shows it sent. | `message.sent` | | `sending` | `queued` | An `auto` iMessage failed and never went, so BlueBot queues it once more as SMS (`sms_retry: true`). Or sending was paused before the Mac handed it to Messages. | None | | `sending` | `failed` | Messages refused it, or an `imessage` text went to someone not on iMessage. | `message.failed` | | `sending` | `unconfirmed` | The Mac's 3-minute lease ran out with no report. | `message.unconfirmed` | | `unconfirmed` | `sent` | You mark it sent, the Mac later finds it in Messages, or it's reported delivered. | `message.sent` | | `sent` | `delivered` | iMessage shows it on the person's device. | `message.delivered` | | `delivered` | `read` | The person read it, and they share read receipts. | `message.read` | | (new, in) | `received` | Somebody texted you. | `message.received` | A text that goes straight from `sent` to `read` fires both `message.delivered` and `message.read`, in that order. Tapbacks, edits and unsends don't change a text's state: they fire `reaction.added`, `reaction.removed`, `message.edited` and `message.unsent`. The message on every event is the whole message as `GET /v1/messages/{id}` would answer at that moment, with your `metadata`. A handler can act on the event alone. ## When each happened Every move stamps a time on the message, in UTC: `created_at`, `queued_at`, `sending_at`, `sent_at`, `delivered_at`, `read_at`, `failed_at`, `unconfirmed_at`, `expired_at`, `canceled_at`, and `received_at` for a text in. A stamp is `null` until its move happens. `send_at` and `expires_at` are when a text is due and when it gives up. ## Why a text failed A `failed` text has `failure.reason`, a code to program against, and `failure.detail`, what the Mac saw in words. | `failure.reason` | What happened | What to do | | ------------------ | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `messages_refused` | Messages on the Mac refused to send it, or showed an error. | Read `failure.detail`. Often Messages was signed out; check `GET /v1/device`, fix it, then send a new text. | | `not_on_imessage` | It was sent as `service: "imessage"` and the person isn't on iMessage, or an SMS retry failed too. | Send it again with `service: "auto"` or `"sms"`, which goes through your iPhone. [Services](https://usebluebot.com/docs/concepts/services) | | `file_unavailable` | A file couldn't be sent. On a `sent` text this means the words went and a file didn't. | Check the file, upload it again and send the file on its own. | A failed text is never retried by BlueBot. If you want to try again, send a new text. ## Why a text was canceled | `cancel_reason` | Who canceled it | | -------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `requested` | You, with `POST /v1/messages/{id}/cancel`, the dashboard or a tool. | | `opted_out` | The person is on your stop list, because they texted a stop word or you added them. [Stop words](https://usebluebot.com/docs/concepts/stop-words) | | `sequence_stopped` | Its sequence stopped, usually because the person replied. | | `broadcast_canceled` | You canceled the broadcast it was part of. | | `staff` | A person at BlueBot canceled it. You'll have heard from them about why. | Only a `scheduled` or `queued` text can be canceled. Once the Mac has claimed it (`sending`), it's in Messages' hands and the cancel answers [`conflict`](https://usebluebot.com/docs/errors/conflict). [Change or cancel a text](https://usebluebot.com/docs/sending/change-or-cancel) ## Settle an unconfirmed text Now and then the Mac hands a text to Messages and can't tell whether it went: Messages didn't show it sent within 30 seconds, or the Mac lost its connection mid-send. BlueBot never guesses and never sends it again on its own, because the person might get it twice. The text becomes `unconfirmed` and waits for you. Often it settles itself: if the Mac later finds the text in Messages, or iMessage reports it delivered, it becomes `sent` and `message.sent` fires. Otherwise, look at the conversation in Messages and tell BlueBot what happened: ```bash curl -X POST "https://usebluebot.com/api/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a/resolve" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: resolve-msg_1Hq2LxW9cVbN3mKpRtYs0a" \ -d '{ "action": "mark_sent" }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const message = await client.messages.resolve('msg_1Hq2LxW9cVbN3mKpRtYs0a', { action: 'mark_sent', }, { idempotencyKey: 'resolve-msg_1Hq2LxW9cVbN3mKpRtYs0a' }); console.log(message); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) message = client.messages.resolve( "msg_1Hq2LxW9cVbN3mKpRtYs0a", action="mark_sent", idempotency_key="resolve-msg_1Hq2LxW9cVbN3mKpRtYs0a", ) print(message) ``` - `mark_sent` when the text is there in the conversation. It becomes `sent` with `resolution: "marked_sent"`, and `message.sent` fires. - `send_again` when it isn't. BlueBot queues a new text with the same words, files, service and metadata. The old one stays `unconfirmed` with `resolution: "resent"` and points to the new one in `resent_message_id`. | You got | It means | Do next | | -------------------------------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------- | | `200` with `"state": "sent"` and `"resolution": "marked_sent"` | It's settled as sent. | Nothing. | | `200` with `"resolution": "resent"` and a `resent_message_id` | A new text is queued. | Follow the new id, or wait for its `message.sent`. | | `409 conflict` | The text isn't `unconfirmed`, or it was already settled. | Read it with `GET /v1/messages/{id}`. It may have settled itself. | The dashboard's home page lists every unconfirmed text, and `GET /v1/device` counts them in `unconfirmed_count`. ## Texts you typed yourself The Mac copies both directions of the conversations it reads. A text you type by hand in Messages on your Mac or iPhone arrives as a message with `direction: "out"`, `state: "sent"` and `source: "mac"`, and fires `message.sent`. It was never queued, so it has no hold, no `metadata` and no `queued_at`. Your handlers can tell it apart by its `source`. ## Watch state without polling Reading a text back with `GET /v1/messages/{id}` is fine while you test. In an app, a [webhook](https://usebluebot.com/docs/webhooks) for `message.sent`, `message.failed`, `message.expired` and `message.unconfirmed` tells you when anything settles, and costs nothing against your rate limit. If your server was down, `GET /v1/events` has the last 30 days. --- Source: https://usebluebot.com/docs/concepts/holds # Holds > Why a text that can't go yet is accepted and waits instead of failing, every reason it can wait, the order they're checked in, and when each one clears. A text that can't go right now isn't refused. BlueBot accepts it, answers `201`, and puts a `hold` on it that says why it's waiting and, when it knows, until when. When the hold clears, the text goes by itself. Your code never needs to retry a send because of quiet hours, a sleeping Mac or a daily limit. ## What a hold looks like Every outgoing message has a `hold` field. It's `null` when nothing holds the text, and an object when something does: The answer, `201`: ```json { "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "object": "message", "state": "queued", "to": "+15555550123", "hold": { "reason": "quiet_hours", "until": "2026-11-14T14:00:00.000Z", "description": "Waiting for quiet hours to end at 8:00 AM tomorrow." }, "expires_at": "2026-11-15T04:40:00.000Z" } ``` | Field | What it is | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `reason` | Why it waits, as a stable code. Program against this. | | `until` | When it should stop waiting, when that's known: the end of quiet hours, the next midnight, or the text's estimated slot in the pace. `null` when nobody can say, such as while the Mac is away. | | `description` | The hold in words, in your time zone, ready to show a person. | A hold is only ever on a `queued` text. A `scheduled` text has none until it's due. ## Every reason | `hold.reason` | What it means | When it clears | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `account_held` | BlueBot’s operator paused this account’s sending. Nothing sends. | Only BlueBot, after you write to support. Meanwhile the API still accepts sends, and they wait with this hold. | | `platform_stopped` | BlueBot stopped all sending for every account, for an incident. | BlueBot turning sending back on. Texts go in their order, subject to their expiry. | | `plan_lapsed` | The account’s plan isn’t active. | An active plan again (update the card on the Billing page). | | `paused` | You paused sending from the Mac’s menu or the dashboard. | Resuming, or the pause’s own end (`until`): an hour, or 8:00 AM in your zone. | | `device_offline` | Your Mac hasn’t checked in for 2 minutes: asleep, off, or off the internet. | The Mac checking in again. If it stays away past the text’s `expires_at`, the text expires. | | `quiet_hours` | It’s inside your quiet hours (9:00 PM to 8:00 AM in your zone unless you changed them). | `until`: the end of quiet hours. The text then takes its place in the pace. | | `daily_new_people_limit` | You’ve texted as many new people today as your setting allows (50 by default). People you already have a conversation with don’t count. | `until`: the next midnight in your zone, or raising the setting. | | `platform_daily_limit` | The account sent 1,000 texts today, BlueBot’s ceiling for one account. | `until`: the next midnight in your zone. This one isn’t a setting. | | `pacing` | Waiting its turn: the Mac leaves your pace (20 seconds by default) between texts. | `until`: its estimated slot. It moves as texts ahead of it go or are canceled. | ## The order they're checked BlueBot checks the reasons in a fixed order and shows the first that applies, so a text has one hold at a time. The first six apply to the whole account, so every queued text shares them: 1. `account_held` 2. `platform_stopped` 3. `plan_lapsed` 4. `paused` 5. `device_offline` 6. `quiet_hours` The last three apply text by text, in the order the texts were queued: 7. `daily_new_people_limit`, only for a text to someone you've never texted with through BlueBot 8. `platform_daily_limit` 9. `pacing` When one hold clears, the next may show. A text queued at 11:00 PM waits on `quiet_hours`; at 8:00 AM, if twenty texts went in ahead of it, it waits on `pacing` for a few minutes more, then goes. A text held by a daily limit doesn't use up a place in the pace, so texts behind it that can go still go. Holds are worked out again every time anything changes: a send, a cancel, a settings change, the Mac checking in or going away. Raise `new_people_per_day` and the texts waiting on it are ready on the Mac's next check-in. ## What a hold doesn't change - **Expiry still runs.** A held text that's still waiting at its `expires_at` (24 hours after it was due, by default) becomes `expired` and never goes. Choose `expires_at` for texts that only make sense soon, and longer for ones that can wait. [Limits](https://usebluebot.com/docs/limits) - **You can still cancel it.** A held text is `queued`, and `POST /v1/messages/{id}/cancel` cancels it. - **The stop list isn't a hold.** A text to someone on your stop list doesn't wait; it's `canceled` at once with `cancel_reason: "opted_out"`. [Stop words](https://usebluebot.com/docs/concepts/stop-words) ## New sends while the account is held Most holds let new sends in: they're accepted and wait with everything else. Two refuse new work instead, because nothing will move until a person acts: - **A lapsed plan** answers new sends, uploads and webhook creation with [`plan_lapsed`](https://usebluebot.com/docs/errors/plan_lapsed) (402). Texts already queued wait with `hold.reason: "plan_lapsed"` and go once the plan is active again, if they haven't expired. Reading, canceling and changing settings keep working. - **BlueBot pausing your account** shows as `account_held` on every queued text. Write to [support@usebluebot.com](mailto:support@usebluebot.com); a person at BlueBot releases it. ## Don't retry a held text The answer to a held send is a success. Sending the same text again makes a second text with its own hold, and both go when the hold clears. If your code sends in a loop, send each text once with an `Idempotency-Key`, and let the holds do the waiting. [Idempotency](https://usebluebot.com/docs/concepts/idempotency) ## Find what's held - `GET /v1/device` counts them: `held_count` is how many queued texts are waiting on a hold, and `queued_count` is every queued text. - `GET /v1/messages?state=queued` lists them, each with its `hold`. - `message.queued` fires once when a text is queued and carries the hold it had at that moment. A hold changing doesn't fire an event. Listen for `message.sent`, `message.expired` and `message.canceled` to learn how each one ends. ```bash curl "https://usebluebot.com/api/v1/messages?state=queued&limit=50" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const page = await client.messages.list({ state: 'queued', limit: 50, }); console.log(page.data); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) page = client.messages.list(state="queued", limit=50) print(page.data) ``` | You got | It means | Do next | | --------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | | Every text with the same `hold.reason` | An account-wide hold: the Mac is away, it's quiet hours, or sending is paused. | Fix the cause, or wait for `hold.until`. | | `pacing` with times spread over the next hour | A long line at your pace. | Nothing; they go in order. Lower `pace_seconds` only if you understand the risk. [Sending rules](https://usebluebot.com/docs/concepts/sending-rules) | | `daily_new_people_limit` on some texts | You've texted as many new people today as your setting allows. | Wait for midnight, or raise `new_people_per_day`. | | `device_offline` with `until: null` | The Mac hasn't checked in for 2 minutes. | Wake it, or check its power and network. [The Mac app](https://usebluebot.com/docs/concepts/mac-app) | ## Tell people why If your app shows what it sent, show `hold.description`. It's written for a person and set in your account's time zone: "Waiting for quiet hours to end at 8:00 AM tomorrow.", "Waiting for your Mac to come back online.", "Waiting its turn. It should go around 2:41 PM." The dashboard, the Mac's menu and BlueBot's MCP tools all use the same words. --- Source: https://usebluebot.com/docs/concepts/services # Services and SMS fallback > How BlueBot chooses between iMessage and a regular text, how a failed iMessage falls back to SMS through your iPhone, and when to ask for one or the other. A text from BlueBot goes the way a text you send by hand goes: as an iMessage (a blue bubble) to someone on iMessage, or as a regular text (SMS, a green bubble) through your iPhone to anyone else. You choose how much say BlueBot has with `service` on a send. ## The three choices | `service` | What it sends | If it can't | | -------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `auto` (the default) | Whatever the newest chat with the person used. iMessage when you've never texted them. | An iMessage that fails and never goes is sent once more as SMS through your iPhone. | | `imessage` | An iMessage and nothing else. | The text fails with `failure.reason: "not_on_imessage"`. | | `sms` | A regular text through your iPhone. | The text fails with `failure.reason: "messages_refused"`. | ```bash curl -X POST "https://usebluebot.com/api/v1/messages" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: open-house-reminder-48213" \ -d '{ "to": "+15555550123", "text": "Hi Maria, the open house at 412 Oak St starts at 1 PM tomorrow. See you there.", "service": "auto" }' ``` ```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 open house at 412 Oak St starts at 1 PM tomorrow. See you there.', service: 'auto', }, { idempotencyKey: 'open-house-reminder-48213' }); 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 open house at 412 Oak St starts at 1 PM tomorrow. See you there.", service="auto", idempotency_key="open-house-reminder-48213", ) print(message) ``` Leave `service` out and you get `auto`, which is right for almost everything. The message records what you asked for in `service_requested` and what it went as in `service`, which stays `null` until it's sent. ## How the SMS fallback works Sam texts a lead, Priya, for the first time. BlueBot has no chat with her, so `auto` tries iMessage. Priya has an Android phone, so Messages shows the iMessage failed and never went. Then: 1. The Mac reports that the iMessage didn't go. 2. BlueBot queues the same text once more, this time as SMS, and sets `sms_retry: true` on it. It keeps its place in today's counts, so it doesn't use up a second new person. 3. The Mac sends it through Sam's iPhone, as a green bubble. 4. It becomes `sent` with `service: "sms"`, and `message.sent` fires. Only one event fires for the whole thing. Next time Sam texts Priya, the newest chat is SMS, so `auto` goes straight to SMS. The fallback happens once, only for an `auto` text, only to one person (never into a group), and only when Messages shows the iMessage never went. If the SMS fails too, the text fails with `not_on_imessage` and the detail "The regular text didn't go either." > **Fallback needs your iPhone.** An SMS from your number leaves from your iPhone, so Text Message Forwarding must be on for the Mac, and your iPhone must be on and connected. Without it iMessages still go, and texts to people who aren't on iMessage fail. The Mac's `forwarding` check tells you. [The Mac app](https://usebluebot.com/docs/concepts/mac-app) ## When to choose `imessage` or `sms` - **`imessage`** when a regular text would be wrong: an email address (which can only get iMessage), a picture that has to arrive at full size, or a message you'd rather not send at all than send green. Expect `not_on_imessage` for people on Android. - **`sms`** when you know the person isn't on iMessage, or they've asked for regular texts. It skips the iMessage attempt. - **`auto`** for everything else. To guess before you send, `GET /v1/lookup` answers what the newest chat with a person used. It's a best guess from your own history, since Apple offers no way to ask about a stranger. [Lookups](https://usebluebot.com/docs/sending/lookups) ## What reports what | | iMessage | SMS | | ------------------- | --------------------------------- | ------------------------------------------------------------------------------------- | | `sent` | Yes | Yes | | `delivered` | Yes, when it reaches their device | Never. An SMS stays `sent`. | | `read` | Only if they share read receipts | Never | | Pictures and files | Yes, up to 25 MB | Through your iPhone as the carrier allows, which often means smaller pictures or none | | To an email address | Yes | No | Messages treats RCS as a regular text, and BlueBot reports it as `service: "sms"`. ## Group chats A text into a group chat (`chat_id` in place of `to`) goes as the group's own service: an iMessage group stays iMessage and an SMS group stays SMS. There's no fallback in a group. If Messages can't send to the group, the text fails with "Messages couldn't send it to this group." [Group chats](https://usebluebot.com/docs/sending/group-chats) ## Replies A text that comes in has `service` set to what it came by. A contact's `last_service` is the service of the newest text with them either way, which is what `auto` uses next time. --- Source: https://usebluebot.com/docs/concepts/capabilities # What BlueBot can and can't do > What a Mac running BlueBot can and can't do with Messages, the reason for each limit, and how to design around the gaps before you build. BlueBot uses Messages only in the ways Apple allows any app to: it sends through AppleScript, and with your permission it reads Messages' database without changing it. It never uses Apple's private frameworks and never asks you to turn off System Integrity Protection, the Mac's protection against changes to the system. Your Mac stays as secure as it was before, and these choices decide what BlueBot can and can't do. ## The list | What | BlueBot | How | | --------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------- | | Send a text (iMessage or SMS, chosen or automatic) | Yes | AppleScript | | Send pictures, files, voice memos and a contact card (.vcf) | Yes | AppleScript `send POSIX file`, staged into a folder Messages can read | | Send into an existing group chat | Yes | AppleScript by chat GUID | | Start a new group chat | No | Messages has no reliable scripting for it | | Read every message, including ones typed by hand on the Mac or iPhone | Yes | chat.db | | Delivered and read receipts on sent texts | Yes, where the recipient shares read receipts | chat.db `date_delivered`, `date_read` | | Tapbacks received | Yes | chat.db `associated_message_type` 2000–2006 added, 3000–3006 removed | | Edits and unsends received | Yes, on macOS 13 and later | chat.db `date_edited`, `date_retracted` | | Send a tapback, a typing indicator, mark as read, edit or unsend | No | Needs System Integrity Protection turned off, and BlueBot won't ask that of anyone | | Voice memo transcription | Yes, on the Mac, free | Apple's Speech framework, on the device, if you turn it on | | Is this number on iMessage? | A best guess with its basis stated | The newest chat with that handle; `unknown` when there is none | ## What BlueBot can't do ### Start a group chat Messages has no reliable way for an app to start a new group. BlueBot can send into a group that already exists, with `chat_id` from `GET /v1/chats`, and it reads every group it copies. **Instead:** start the group once by hand, in Messages on your iPhone or Mac. Send one text in it, and the Mac sees it (`chat.created` fires). From then on your code can send into it. [Group chats](https://usebluebot.com/docs/sending/group-chats) ### Send a tapback, show typing, mark as read, edit or unsend All of these need System Integrity Protection turned off, and BlueBot won't ask anyone to do that. **Instead:** BlueBot reads all of them when the other person does them. A tapback on your text fires `reaction.added`, and an edit or unsend fires `message.edited` or `message.unsent`. To answer a tapback, send a text. ### Know for sure whether someone is on iMessage Apple offers no way to ask about a stranger's number. `GET /v1/lookup` answers from your own history: what the newest chat with that person used, with `basis: "history"`, or `service: "unknown"` with `basis: "none"` when you've never texted them. **Instead:** send with `service: "auto"` and let the SMS fallback handle it. [Services and SMS fallback](https://usebluebot.com/docs/concepts/services) ### Send while your Mac is asleep or off Every text leaves from Messages on your Mac, and SMS from your iPhone. A Mac that sleeps, loses power or loses its network sends nothing until it's back, and its texts wait with `device_offline`. **Instead:** keep the Mac awake (the app does it for you), choose an `expires_at` that matches how long a text stays useful, and listen for `device.offline`. [The Mac app](https://usebluebot.com/docs/concepts/mac-app) ### Give you a number BlueBot sends from the number your Mac's Messages is signed in with, and provides none. You can't send from two numbers with one account, choose a sender name, or text from a number you don't own. ### Send at volume BlueBot sends one text at a time at your pace, 20 seconds apart by default, and at most 1,000 a day. BlueBot is for texting people who know you. Bulk sending, cold outreach and texting lists you bought are against the acceptable use policy, and they're also how a personal Apple account gets flagged, which no setting undoes. [Limits](https://usebluebot.com/docs/limits) ### Work outside Messages BlueBot doesn't make phone or FaceTime calls and doesn't work with WhatsApp or other apps. At launch it can't text numbers outside the US. ## What it reads that hosted services can't Because BlueBot reads your own Messages, it sees things a hosted iMessage service can't: - **The whole conversation.** Texts you type by hand on your Mac or iPhone come through as `source: "mac"`, so your CRM and your AI assistant also see what you typed. - **Your existing threads.** Texts go into your existing conversations and come from your own number. - **Receipts and reactions.** Delivered and read times where the person shares them, tapbacks, edits and unsends. - **Voice memo transcripts.** When you turn on transcription, the Mac transcribes voice memos itself with Apple's speech recognition, at no extra cost. ## The risk to your Apple account Apple can flag an Apple account that messages many people who don't know it. The flag isn't public, Apple doesn't announce its rules, and BlueBot can lower the risk but never remove it. The [sending rules](https://usebluebot.com/docs/concepts/sending-rules) (a pace between texts, a cap on new people a day, quiet hours and stop words) are there for this, and their defaults are set so your sending looks like a busy person's. --- Source: https://usebluebot.com/docs/concepts/sending-rules # Sending rules > The settings that control how fast and how often your number sends, each with its default, what it does, how to read and change it through the API, and the warnings BlueBot gives when you loosen one. BlueBot sends from your own number, so it sends the way a careful person would. A handful of settings decide how: how far apart texts go, how many new people you text in a day, when the quiet hours are, how long a text may wait, and whether STOP works. Each one starts at a safe default, and each one is yours to change, under **Settings → Sending rules** in the dashboard or with `PATCH /v1/settings`. A text that a rule holds back isn't refused. It's accepted and waits with a [hold](https://usebluebot.com/docs/concepts/holds) that says why, then goes by itself. ## The settings | Setting | Default | Range | What it does | | -------------------- | --------------------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pace_seconds` | 20 | 5 to 300 | The gap between two texts leaving your Mac. | | `new_people_per_day` | 50 | 0 to 1,000 | How many people you've never texted can be texted in one day. | | `quiet_hours` | On, 21:00 to 08:00 | Any start and end, `HH:MM` | Texts wait while it's quiet hours where you are. | | `time_zone` | Your browser's zone when you signed up, else `America/New_York` | Any IANA zone | The zone quiet hours and "today" are read in. | | `stop_words` | On | On or off | A reply of only STOP (or another stop word) puts the person on your stop list. | | `expiry_hours` | 24 | 1 to 168 | A text still waiting this long after it was due expires and never goes. | | `paused_until` | `null` | Any future time, or `null` | While it's in the future, nothing goes. | | `inbound_mode` | `replies` | `replies` or `everything` | Which conversations the Mac copies to BlueBot. It's here because it's a setting, not a rule. [The Mac app](https://usebluebot.com/docs/concepts/mac-app) | | `business_name` | `null` | Up to 120 characters, or `null` | What `{{business}}` fills in. Until you set it, the widget's first text leaves out who it's from, and a template that needs `{{business}}` with no fallback can't be sent through the API. [Templates](https://usebluebot.com/docs/concepts/templates) | One more number isn't yours to change: `platform_daily_limit`, BlueBot's ceiling of 1,000 texts a day from one account. It's there to protect your number, and [Limits](https://usebluebot.com/docs/limits) says why. ## Pace `pace_seconds` is the time between two texts leaving the Mac. At the default of 20 seconds, a hundred texts take about 33 minutes. Each text waiting its turn has `hold.reason: "pacing"` with an estimated time in `hold.until`, and the estimate moves as texts ahead of it go. People don't send texts a second apart. Texts that go out at a machine's rhythm are what Apple and the carriers look for, so the default is slower than you might expect. Anything under 10 seconds gets a warning. Pace is one queue for the whole account. A text you send now goes after the texts already waiting, unless you [schedule](https://usebluebot.com/docs/sending/scheduled) the others for later. ## New people per day `new_people_per_day` caps how many people you text for the first time each day, counted from midnight in your `time_zone`. A new person is someone with no text in BlueBot yet, in either direction. Five texts to the same new person count once. When the day's count is reached, texts to more new people wait with `daily_new_people_limit` until midnight. Texts to people you already know keep going. Group chats never count. Set it to `0` to text only people you already know. Over 200 gets a warning, because a personal number that reaches hundreds of strangers a day is the pattern that gets numbers flagged. ## Quiet hours While it's quiet hours in your `time_zone`, every text waits with `quiet_hours` and goes when they end. The default is 9:00 PM to 8:00 AM. [Quiet hours](https://usebluebot.com/docs/concepts/quiet-hours) covers windows that cross midnight, daylight saving time and turning them off. ## Expiry Every text has an `expires_at`. Unless you set one on the send, it's the time the text was due plus `expiry_hours`. A text still waiting at that moment becomes `expired`, `message.expired` fires, and it never goes. A Mac that's away for a weekend won't send Friday's "Your table is ready" on Monday. Choose a short `expires_at` on texts that only make sense soon, and a long one for texts that can wait. `expiry_hours` sets the default for texts made after you change it; texts already waiting keep the `expires_at` they were given. ## Stop words With stop words on, a reply of only STOP, STOPALL, UNSUBSCRIBE, CANCEL, END or QUIT puts that person on your stop list, and BlueBot cancels every text still waiting for them. [Stop words](https://usebluebot.com/docs/concepts/stop-words) has the rules and the stop list API. ## Pausing Set `paused_until` to a time to stop all sending until then, and to `null` to resume. While paused, texts are accepted and wait with `paused`. The Mac's menu has **Pause for an hour** and **Pause until tomorrow morning**, and the dashboard can pause until you resume; a pause with no end reads back as a date far in the future. ## Read the settings ```bash curl "https://usebluebot.com/api/v1/settings" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const settings = await client.settings.get(); console.log(settings); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) settings = client.settings.get() print(settings) ``` The answer, `200`: ```json { "object": "settings", "business_name": "Dana's Salon", "time_zone": "America/Chicago", "pace_seconds": 20, "new_people_per_day": 50, "quiet_hours": { "enabled": true, "start": "21:00", "end": "08:00" }, "smart_quiet_hours": false, "stop_words": { "enabled": true, "words": ["stop", "stopall", "unsubscribe", "cancel", "end", "quit"], "start_words": ["start", "unstop", "yes"] }, "expiry_hours": 24, "inbound_mode": "replies", "paused_until": null, "platform_daily_limit": 1000, "warnings": [] } ``` Reading needs a key with the `account:read` scope. ## Change them Send only the settings you're changing. Everything you leave out stays as it is. Changing needs the `messages:send` scope, since these settings decide what goes out. Dana runs a small salon in Austin. She names her business, moves her account to Central time, slows the pace to 30 seconds, and starts quiet hours an hour earlier: ```bash curl -X PATCH "https://usebluebot.com/api/v1/settings" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "business_name": "Dana'\''s Salon", "time_zone": "America/Chicago", "pace_seconds": 30, "quiet_hours": { "start": "20:00" } }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const settings = await client.settings.update({ businessName: 'Dana\'s Salon', timeZone: 'America/Chicago', paceSeconds: 30, quietHours: { start: '20:00', }, }); console.log(settings); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) settings = client.settings.update( business_name="Dana's Salon", time_zone="America/Chicago", pace_seconds=30, quiet_hours={ "start": "20:00", }, ) print(settings) ``` The answer is the whole settings object as it now stands. A change takes effect at once: BlueBot works out every waiting text's hold again in the same step, so a text waiting on `daily_new_people_limit` after you raise the limit is ready on the Mac's next check-in, about 10 seconds later. | You got | It means | Do next | | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | `200` with your new values and `"warnings": []` | The change is saved. | Nothing. Read `hold` on a waiting text to see the new timing. | | `200` with something in `warnings` | Saved, but a setting is looser than is safe. | Read the warning, and change it back if you didn't mean it. | | `400` [`invalid_request`](https://usebluebot.com/docs/errors/invalid_request) with `details` | A value is out of range, a time isn't `HH:MM`, a zone isn't one BlueBot knows, or the body was empty. | Fix the field named in `details`. | | `403` [`insufficient_scope`](https://usebluebot.com/docs/errors/insufficient_scope) | The key can't send texts, so it can't change what goes out. | Use a key with `messages:send`. | ## Warnings Loosening a rule is allowed, and BlueBot says what it costs. `warnings` holds a sentence for each setting past the safe line: | When | The warning | | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | | `pace_seconds` under 10 | Texts less than 10 seconds apart can look automated to Apple and the carriers. 20 seconds or more is safer. | | `new_people_per_day` over 200 | Texting more than 200 new people a day from one number is how numbers get flagged as spam. | | Quiet hours off | Quiet hours are off, so texts can go at night. Several states limit when marketing texts may be sent. | | Stop words off | Stop words are off. Someone who replies STOP will keep getting texts until you stop them by hand. | The same warnings show in the dashboard beside each setting. ## What the rules can't do The rules lower the chance that Apple or a carrier flags your number. They can't remove it, and Apple doesn't publish what it looks for. The rules also don't decide whether you may text someone. That's up to the law where they live and BlueBot's acceptable use policy, which [Capabilities](https://usebluebot.com/docs/concepts/capabilities#send-at-volume) sums up. Text people who know you and expect to hear from you, and keep the defaults unless you have a reason. --- Source: https://usebluebot.com/docs/concepts/quiet-hours # Quiet hours > How BlueBot keeps texts from going out at night, how the window is read in your time zone, what happens to texts sent during it, and how to change or turn it off. Nobody wants a text from a business at 11:40 PM. While it's quiet hours, BlueBot accepts every text you send and holds it until morning, then sends them in order at your pace. Quiet hours are on from the start, 9:00 PM to 8:00 AM in your account's time zone. ## What happens to a text sent at night Dana's booking app sends a reminder at 10:40 PM. BlueBot answers `201` with the text `queued` and a hold: The answer, `201`: ```json { "state": "queued", "hold": { "reason": "quiet_hours", "until": "2026-11-14T14:00:00.000Z", "description": "Waiting for quiet hours to end at 8:00 AM tomorrow." } } ``` At 8:00 AM it goes, with any other texts that waited overnight, one after another at your pace. Fourteen texts that waited go between 8:00 and about 8:04, at the default of 20 seconds apart. Nothing about the text changes while it waits, and you don't need to send it again. A [scheduled](https://usebluebot.com/docs/sending/scheduled) text whose `send_at` falls inside quiet hours does the same: it becomes `queued` at its `send_at` and waits for the morning. To have it go at a set time, schedule it for after quiet hours end. ## How the window is read - **Start and end are clock times**, `HH:MM` on a 24-hour clock, in your account's `time_zone`. - **A start later than the end crosses midnight.** `21:00` to `08:00` is 9 PM tonight to 8 AM tomorrow. - **A start earlier than the end stays within one day.** `12:00` to `13:30` holds texts over lunch and nowhere else. - **The start is inside the window and the end isn't.** With `21:00` to `08:00`, a text at 9:00 PM waits and a text at 8:00 AM goes. - **The same start and end means no quiet hours.** To turn them off, set `enabled` to `false` instead, which keeps your times for when you turn them on again. - **Daylight saving time doesn't move them.** The end is 8:00 AM by the clock on the day it falls, so the night the clocks change is an hour longer or shorter, and the morning still starts at 8:00. ## Whose time zone Quiet hours follow your account's `time_zone` unless you turn on smart quiet hours. Your zone starts as your browser's zone when you signed up, and `America/New_York` if BlueBot couldn't tell. "Today" for the daily limits always uses your zone. ## Smart quiet hours If the people you text live in other zones, turn on `smart_quiet_hours`. BlueBot then reads your window in each person's own zone, from the area code of their number. Sam's account is in New York and most of his clients are in California. With smart quiet hours on, a text to a Los Angeles number waits until 8:00 AM Pacific, while a text to a Boston number goes at 8:00 AM Eastern: ```bash curl -X PATCH "https://usebluebot.com/api/v1/settings" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "smart_quiet_hours": true }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const settings = await client.settings.update({ smartQuietHours: true, }); console.log(settings); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) settings = client.settings.update(smart_quiet_hours=True) print(settings) ``` Some area codes cover two zones, like Florida's panhandle (850) or western Kentucky (270). A text to one of those waits until it's outside quiet hours in both, so it never arrives early in one half or late in the other. BlueBot uses your own zone for a person whose zone it can't tell from the number: a number outside the US and Canada, a toll-free number, an email address, and every group chat. Smart quiet hours only moves when a text goes; the daily limits still count by your own day. If you'd rather keep one window for everyone, leave it off and choose a window that's quiet for all of them. With his account in New York and clients in California, Sam would end quiet hours at `11:00`, which is 8:00 AM Pacific. ## Change them Send only what changes. To start quiet hours at 8:30 PM and end them at 9:00 AM: ```bash curl -X PATCH "https://usebluebot.com/api/v1/settings" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "quiet_hours": { "start": "20:30", "end": "09:00" } }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const settings = await client.settings.update({ quietHours: { start: '20:30', end: '09:00', }, }); console.log(settings); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) settings = client.settings.update( quiet_hours={ "start": "20:30", "end": "09:00", }, ) print(settings) ``` The change applies at once. Texts that were waiting get a new `hold.until`, and texts outside the new window go on the Mac's next check-in. In the dashboard it's **Settings → Sending rules**. | You got | It means | Do next | | ------------------------------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------ | | `200` with your new `quiet_hours` | Saved. | Read a waiting text with `GET /v1/messages?state=queued` and check its `hold.until`. | | `400` [`invalid_request`](https://usebluebot.com/docs/errors/invalid_request) on `quiet_hours.start` | The time isn't `HH:MM` on a 24-hour clock. | Send `"20:30"`, not `"8:30 PM"` or `"20:30:00"`. | | `400` on `time_zone` | BlueBot doesn't know that zone. | Use an IANA name such as `America/Denver`. | ## Turn them off ```bash curl -X PATCH "https://usebluebot.com/api/v1/settings" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "quiet_hours": { "enabled": false } }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const settings = await client.settings.update({ quietHours: { enabled: false, }, }); console.log(settings); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) settings = client.settings.update( quiet_hours={ "enabled": False, }, ) print(settings) ``` Texts then go at any hour, and the settings answer with this warning: "Quiet hours are off, so texts can go at night. Several states limit when marketing texts may be sent." Turn them off only when every text you send is one the person asked for at that moment, such as a code or an answer to their own text, and even then think about who's asleep. ## What quiet hours don't cover - **Texts you type yourself.** A text you send by hand in Messages goes when you send it. Quiet hours only hold the texts BlueBot sends. - **Other holds come first.** BlueBot shows one hold at a time. A paused account or a Mac that's away shows `paused` or `device_offline` overnight, and `quiet_hours` only once those clear. [Holds](https://usebluebot.com/docs/concepts/holds) - **Expiry keeps running.** A text with an `expires_at` an hour away, sent at 10:00 PM, expires before 8:00 AM and never goes. Give night-time texts an expiry that reaches past the morning, or leave it at the 24-hour default. --- Source: https://usebluebot.com/docs/concepts/stop-words # Stop words and the stop list > How a reply of STOP puts a person on your stop list, what BlueBot does with their waiting texts, how START undoes it, and how to read and change the stop list through the API. Anyone you text can tell you to stop, and when they do, BlueBot stops. A reply of only STOP puts the person on your **stop list**: every text still waiting for them is canceled at once, and every text you send them after that is canceled too, until they or you take them off. ## The words - **Stop words:** `STOP`, `STOPALL`, `UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT` - **Start words:** `START`, `UNSTOP`, `YES` A reply counts only when the whole text is one of these words. BlueBot ignores capitals, spaces around the word and a period or exclamation mark at the end, so "Stop", " STOP " and "stop." all count. "Please stop texting me" doesn't, and neither does "Stop by at 3?" If someone writes a sentence asking you to stop, put them on the stop list yourself (below). Stop words are read only in one-to-one conversations. A STOP in a group chat is a message to the whole group, so BlueBot doesn't act on it. > **CANCEL, END and YES are on the lists.** A reminder that says "Reply CANCEL to cancel your appointment" will put everyone who replies CANCEL on your stop list, and you won't be able to text them again until they text START or you take them off by hand. Ask for a word that isn't a stop word, such as "Reply C to cancel" or "Reply CHANGE to move it." YES is a START word, so it only matters for someone who has already stopped. ### Your own list You can replace the list with your own under **Settings → Stop words**, or with `stop_words.words` on `PATCH /v1/settings`. Your list is the whole list: a word you add, such as BASTA, stops people, and a word you take off no longer does. A keyword rule or Autopilot never answers a word on your list, and can answer one you took off, so taking CANCEL off lets a rule for CANCEL handle appointment cancellations. Send `null` to go back to the defaults. [Sending rules](https://usebluebot.com/docs/concepts/sending-rules#stop-words) ## What happens when someone stops Maria gets a text from Dana's salon and replies "STOP." When the Mac copies the reply: 1. Maria goes on the stop list with `source: "reply"`. 2. Every text still `queued` or `scheduled` for her is canceled with `cancel_reason: "opted_out"`, and `message.canceled` fires for each. 3. `contact.stopped` fires, with Maria as a contact whose `stopped` is `true`. 4. Any text sent to her later is accepted with `201` and canceled at once, again with `opted_out`, so your code needs no special case. The answer, `201`: ```json { "id": "msg_4Tz8RkW1qPnB6yLcVd2e3A", "object": "message", "state": "canceled", "cancel_reason": "opted_out", "to": "+15555550123", "hold": null } ``` A text the Mac had already taken (`sending`) when she replied isn't stopped, because it was already in Messages. BlueBot doesn't text her back to confirm; if you want to, type it yourself in Messages. Her reply still arrives as `message.received`, like any other. Group chats she's in aren't affected. Texts into a group go to everyone in it, and the stop list applies only to texts sent to her alone. ## Starting again - **She texts START, UNSTOP or YES.** That takes her off the list, but only if she stopped by replying. `contact.unstopped` fires. - **You take her off by hand.** In the dashboard, or with `DELETE /v1/stops/{person}`. This works whoever put her there. A START word never lifts a stop that you or an import made. If you stopped someone by hand, only you can undo it. Texts that were canceled while she was stopped stay canceled. Send them again if they still make sense. ## The stop list API The stop list is people, by phone number or email address. A key needs `contacts:read` to read it and `contacts:write` to change it. ### Read the list ```bash curl "https://usebluebot.com/api/v1/stops?limit=50" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const page = await client.stops.list({ limit: 50, }); console.log(page.data); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) page = client.stops.list(limit=50) print(page.data) ``` The answer, `200`: ```json { "object": "list", "data": [ { "object": "stop", "handle": "+15555550123", "contact_id": "ct_6Mn3QbX8rYv1KdPw5Tz0sH", "stopped_at": "2026-11-13T19:41:00.000Z", "source": "reply" } ], "has_more": false, "next_cursor": null } ``` `source` says how each person got there: `reply` (they texted a stop word), `dashboard`, `api`, `import` (a contact list you uploaded marked them) or `admin` (a person at BlueBot). The list pages like every other list. [Pagination](https://usebluebot.com/docs/concepts/pagination) ### Stop texting someone Priya emails Sam's office to say she doesn't want texts. Sam's CRM adds her to the stop list: ```bash curl -X POST "https://usebluebot.com/api/v1/stops" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: stop-priya-20261113" \ -d '{ "person": "+15555550188" }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const stop = await client.stops.add({ person: '+15555550188', }, { idempotencyKey: 'stop-priya-20261113' }); console.log(stop); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) stop = client.stops.add(person="+15555550188", idempotency_key="stop-priya-20261113") print(stop) ``` The answer is `201` with the stop. Her waiting texts are canceled, and `contact.stopped` fires, exactly as if she'd replied STOP, except that `source` is `api` and only a person can undo it. Adding someone already on the list changes nothing and keeps the first stop's `source`. A US number may leave off the `+1`, and `(555) 555-0188` works too. | You got | It means | Do next | | ------------------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | | `201` with `"object": "stop"` | She's on the list. | Check that `GET /v1/stops` lists her. A text to her now comes back `canceled` with `opted_out` and never goes. | | `400` [`invalid_request`](https://usebluebot.com/docs/errors/invalid_request) on `person` | BlueBot can't read it as a phone number or an email address. | Send the number with its country code, such as `+15555550188`. | | `403` [`insufficient_scope`](https://usebluebot.com/docs/errors/insufficient_scope) | The key can't change contacts. | Use a key with `contacts:write`. | ### Text someone again ```bash curl -X DELETE "https://usebluebot.com/api/v1/stops/%2B15555550188" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const result = await client.stops.remove('+15555550188'); console.log(result); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) result = client.stops.remove("+15555550188") print(result) ``` Put the person in the path, with `+` written as `%2B`. The answer is `200` with `"deleted": true`, and `contact.unstopped` fires. If they weren't on the list, it's `404` [`not_found`](https://usebluebot.com/docs/errors/not_found). Only take someone off when they've asked to hear from you again. A person who replied STOP and then gets texts anyway is the complaint that gets numbers reported. ## Turning stop words off ```bash curl -X PATCH "https://usebluebot.com/api/v1/settings" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "stop_words": { "enabled": false } }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const settings = await client.settings.update({ stopWords: { enabled: false, }, }); console.log(settings); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) settings = client.settings.update( stop_words={ "enabled": False, }, ) print(settings) ``` With stop words off, a reply of STOP is an ordinary message: nobody goes on the list and nothing is canceled. The stop list itself still works, so people already on it stay stopped, and adding someone by hand still cancels their texts. The settings answer with this warning: "Stop words are off. Someone who replies STOP will keep getting texts until you stop them by hand." Keep them on unless your own code reads every reply and keeps its own list. If you turn them off, listen for `message.received` and add people to the stop list yourself when they ask. --- Source: https://usebluebot.com/docs/concepts/data-retention # Data retention > What BlueBot keeps about your texts and for how long, what the API shows once the words are gone, how to export your data, and what happens when you delete your account. BlueBot keeps the words of your texts for 30 days and the facts about them for as long as you have an account. The facts are who, when, which state and which service. The words are deleted first because they're the private part. The facts stay because your counts, limits and history depend on them. The 30 days cover every copy of words to or from a person, not only the text itself: a broadcast's words, the values a list or a sequence run was given for each person, Autopilot's replies and what a website visitor typed. What you build to send, such as templates, sequence steps and broadcast drafts, stays until you delete it. ## How long BlueBot keeps data | What | How long | | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | The words of texts, their files and voice memo transcripts | 30 days, then deleted | | A broadcast's words, and the values its list gave each person | 30 days after the broadcast finished. A draft keeps its words. | | The values a sequence was given for one person (`variables`) | 30 days after their run ended | | Autopilot's suggestions and replies | 30 days | | A website visitor's name and question | 30 days. The text it became follows the rule for texts. | | The facts of each text: who it was to or from, when, its state, its service and what sent it | The life of your account | | Contacts and the stop list | The life of your account | | Events and webhook deliveries, which can carry the words of a text | 30 days | | The API request log: the route, the status and the time, never the full address, the query or the body | 30 days | | The first answer to a request with an `Idempotency-Key` | 24 hours | | A file you upload and never send | A day | | A file waiting on your Mac to be sent | Until its text is reported, and never more than an hour | | What a Text Hook received | The field names of the last 20 posts to each hook, never their values, and none older than 30 days | | Conversations with the integration assistant | 30 days from each message | | Emails and Slack messages BlueBot sends you, such as a forwarded text or a new-text notification | Who, when, what kind and the subject, which can name who texted you. Never the body. | | Emails to support, our replies and the support agent's notes | No set end, since an answer often depends on an earlier one. Anything you paste into a support email is kept with it. | | Settings, templates, sequence steps, broadcast drafts, rules, Text Hooks and the rest of what you build | Until you delete them or your account | | A data export | Its download link lasts a week | | Your account and everything in it, after you delete it | Deleted within 30 days | The 30 days for a text's words count from when it was sent or received. A text still waiting to go keeps its words until it goes, so a text scheduled for two months from now goes with its words intact. BlueBot deletes once a day, at about 3 AM UTC, so anything kept 30 days goes in the first clean-up after its 30th day. A broadcast whose words were deleted reads `"text": null` and `"text_deleted": true` from `GET /v1/broadcasts/{id}`. Its name, counts and the people it went to stay. ## What the API shows once the words are gone The message is still there, with every fact, and it says plainly that its words were deleted: The answer, `200`: ```json { "id": "msg_9Kd2VbR7xLqN4mWsTy1c8E", "object": "message", "direction": "in", "state": "received", "from": "+15555550123", "service": "imessage", "text": null, "text_deleted": true, "received_at": "2026-10-09T15:12:00.000Z" } ``` - `text` is `null` and `text_deleted` is `true`. A text that never had words, such as a picture on its own, has `text: null` and `text_deleted: false`. - Each file on it has `status: "deleted"`, and its `url` and `transcript` are `null`. - Its events are gone from `GET /v1/events`, since events last 30 days too. Counts, limits and conversation history keep working, because they read the facts. The new-people count still knows Maria isn't new. **Keep what you need.** If your app needs the words longer, store them yourself when `message.received` or `message.sent` reaches your webhook. That copy is yours, under your own policy. [Webhooks](https://usebluebot.com/docs/webhooks) ## Where the words never go - **Logs.** Nothing BlueBot logs on the server or on your Mac carries the words of a message. The API request log keeps the route, the status and the time, never the body. - **Your Mac's disk.** The app keeps its token in the Keychain and nothing else of yours, apart from the files of texts waiting to send, which are deleted once each text is reported and within an hour regardless. It reads Messages' database and never writes to it. - **Your Messages.** BlueBot's 30 days apply to BlueBot's copy. Your conversations in Messages on your Mac and iPhone stay exactly as Apple keeps them, and deleting BlueBot's copy never touches them. ## Export your data The **Account** page in the dashboard makes a file of your data and gives you a link to download it. The link lasts a week. The file has what BlueBot still has, so words older than 30 days aren't in it. ## Delete your account Delete your account from the **Account** page in the dashboard. Then: - Your Mac is disconnected, your API keys stop working and your webhooks stop. - Everything in the account is deleted within 30 days, and it can't be undone. Export first if you want a copy. - A few records stay, no longer linked to the account: billing records, the record of what BlueBot's staff did on your account, the facts of emails BlueBot sent you or got from you, and support emails. Stripe keeps its own payment records under its own policy. Deleting a contact is different: it hides them, but their number stays with the facts of their texts, so your counts stay right and the stop list still works for them. ## Check it Read a text older than 30 days with `GET /v1/messages/{id}`. | You got | It means | Do next | | ----------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------- | | `"text_deleted": true` and `"text": null` | The words were deleted. | Nothing. | | The words, on a text sent more than 30 days ago | It hasn't been deleted yet. | Write to [support@usebluebot.com](mailto:support@usebluebot.com) with the message id. | | `404` [`not_found`](https://usebluebot.com/docs/errors/not_found) | There's no message with that id on this account. | Check the id, and that the key belongs to the account that sent or got the text. | BlueBot's privacy policy, linked at the foot of every page, has the legal version of all of this. --- Source: https://usebluebot.com/docs/concepts/templates # Templates > Save a text once with variables in it, then send it by id from the API, MCP, broadcasts, sequences and keyword replies, filled in for each person. A template is a text you save once and send many times, with `{{variables}}` where each person’s details go. Dana’s team sends the same showing reminder a dozen times a week, so she saved it: ```text Hi {{first_name|there}}, see you at {{time}} at {{address}}. ``` Make templates on the **Templates** page or with `POST /v1/templates`. Each has a name, unique among your templates, and an id that starts `tpl_`. ## Variables A variable is a name in double braces. When a template is sent, each one is filled from the first of these that has a value: 1. What the sender passed for this send: `variables` on `POST /v1/messages`, a column of a broadcast’s CSV, or a field a Text Hook received. 2. The person’s contact: `first_name`, `last_name`, `full_name`, `company`, `phone`, `email`, and any custom field by its name (`{{showing}}`, or `{{custom.showing}}` to be explicit). 3. `business`: your business name from **Settings** (`business_name` on `GET /v1/settings`). Until you set one it has no value, so use a fallback such as `{{business|us}}` or set the name first. `{{name|fallback}}` gives the words to use when a variable has no value. `Hi {{first_name|there}}` reads “Hi Maria” for Maria and “Hi there” for somebody with no name on file. A variable with no value and no fallback is left out, with the space before it when punctuation follows, so `Hi {{first_name}}, …` reads “Hi, …”. A broadcast, a sequence or a keyword reply sends it that way. A send through the API or MCP refuses instead, so your code finds out before anybody gets a text with a gap in it. ## Sending one from the API Send `template_id` in place of `text`, and `variables` for anything the contact doesn’t already have: ```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) ``` The text is filled in when BlueBot accepts it, so the answer carries the finished words, and `source_ref` is the template’s id. Changing the template afterwards doesn’t change texts already sent or scheduled. [Send a text](https://usebluebot.com/docs/sending/texts) has every field of a send. | You got | It means | Do next | | --------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | `201` | The template was filled in and the text accepted. | Read `text` in the answer to see the words that will go. | | `400 invalid_request` naming `variables.time` | The template needs `time`, and neither your `variables` nor the contact had it. | Send it in `variables`, or add a fallback such as `{{time\|soon}}`. | | `400 invalid_request` naming `template_id` | You sent `text` and `template_id` together. | Send one of them. | | `404 not_found` naming `template_id` | No template of yours has that id, or it was deleted. | List yours with `GET /v1/templates`. | ## Where else templates are used - **The inbox.** The composer’s template menu fills one in for the person you’re texting, and you edit it before sending. - **Broadcasts, sequences and keyword replies** can each send a template in place of their own words, filled in for each person. Sequences and keyword replies read the template each time they send, so an edit changes their next text. A broadcast copies the template’s words when you make it. - **MCP.** `send_template` fills one in for an AI assistant, with the same rules as the API. [MCP tools](https://usebluebot.com/docs/mcp/tools) ## Keeping them A template is your own content, so BlueBot keeps it for as long as you do, unlike the words of a sent text, which go after 30 days. [Data retention](https://usebluebot.com/docs/concepts/data-retention) --- Source: https://usebluebot.com/docs/sending/texts # 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. --- Source: https://usebluebot.com/docs/sending/receiving # Receive texts > How replies reach BlueBot, which chats your Mac copies, and how to hear about each text that comes in, with tapbacks, edits and unsends. Your Mac also reads Messages and copies texts to BlueBot: the replies people send you, the texts you type by hand on your Mac or iPhone, tapbacks, edits and unsends. Each text that comes in becomes a message with `"direction": "in"` and `"state": "received"`, and BlueBot tells your webhooks with `message.received`. Nothing changes on your phone. Replies still arrive in Messages on your iPhone and Mac the way they always have, and you can answer them there. BlueBot sees your answer too. ## Which chats are copied Your Mac copies some chats and leaves the rest alone. Which ones is the **Which chats to copy** setting: **People BlueBot has texted** or **Everything** in **Settings → Sending rules**, the same choice in the Mac app’s own settings, and `inbound_mode` in the API: | `inbound_mode` | The Mac copies | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `replies` (the default) | One-to-one chats with people you’ve texted through BlueBot, and group chats in which you’ve texted everyone else through BlueBot. | | `everything` | Every chat on the Mac: one-to-one and group, whoever started it. | “Texted through BlueBot” means any text BlueBot sent for you: from the API, the dashboard, Zapier, a sequence or anywhere else. A chat counts from the first text BlueBot sends into it, so the first reply to that text is copied. Under `replies`, somebody who texts you first is not copied. If people find your number on a sign, an ad or your website and text you before you’ve ever texted them, set **Which chats to copy** to **Everything**, or BlueBot never hears from them. Under `everything` your personal chats are copied as well. BlueBot deletes the words of every text after 30 days, but think about whose chats are on that Mac before you choose it. In either mode, the Mac never copies chats with short codes (the 5- and 6-digit numbers banks and airlines text from) or business chats, since those aren’t a phone number or an email address. Change it under **Settings → Sending rules** in the dashboard, in BlueBot’s window on the Mac, or with the API: ```bash curl -X PATCH "https://usebluebot.com/api/v1/settings" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "inbound_mode": "everything" }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const settings = await client.settings.update({ inboundMode: 'everything', }); console.log(settings); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) settings = client.settings.update(inbound_mode="everything") print(settings) ``` The Mac picks up the change at its next check-in, within 10 seconds. When you first connect a Mac, it copies the chats it should from the 7 days before, so a fresh account starts with recent history. ## What a text in looks like The answer, `200`: ```json { "id": "msg_3Lp3VsC7hGqE5tZyKbWn2d", "object": "message", "direction": "in", "state": "received", "to": "+15555550100", "from": "+15555550123", "chat_id": null, "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "conversation_id": "conv_1Hq2LxW9cVbN3mKpRtYs0a", "text": "Perfect, see you there at 3", "files": [], "service": "imessage", "source": "mac", "metadata": {}, "reply_to_id": null, "reactions": [], "edited_at": null, "unsent_at": null, "received_at": "2026-11-13T19:44:10.000Z" } ``` | Field | What it tells you | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `from` | Who sent it, in E.164 (`+15555550123`) or as an email address. Look the person up in your own system by this. | | `to` | Your own number, when the Mac can tell it. | | `contact_id`, `conversation_id` | The same ids the texts you sent this person carry, so a reply joins its conversation without any matching on your side. | | `text` | The words. `null` for a text of only pictures or files, for one that was unsent, and after 30 days. | | `files` | Pictures, voice memos and other files, each with a link that works for an hour. [Files and voice memos](https://usebluebot.com/docs/sending/files) | | `service` | `imessage` or `sms`: what the person texted you with. | | `reply_to_id` | The text this one answers, when the person used Messages’ inline reply. | | `chat_id` | Set for a text in a group chat. `contact_id` and `from` are then the member who sent it. [Group chats](https://usebluebot.com/docs/sending/group-chats) | | `metadata` | Always `{}` on a text that came in. Your metadata rides on the texts you send, so match a reply to your record by `contact_id` or `from`. | ## Hear about each one Point a webhook at your server for `message.received`, and BlueBot posts each text that comes in, signed so you can check it came from BlueBot: ```bash curl -X POST "https://usebluebot.com/api/v1/webhooks" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: crm-replies-webhook" \ -d '{ "url": "https://example.com/webhooks/bluebot", "events": [ "message.received" ], "description": "Replies into the CRM" }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const webhook = await client.webhooks.create({ url: 'https://example.com/webhooks/bluebot', events: ['message.received'], description: 'Replies into the CRM', }, { idempotencyKey: 'crm-replies-webhook' }); console.log(webhook); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) webhook = client.webhooks.create( url="https://example.com/webhooks/bluebot", events=["message.received"], description="Replies into the CRM", idempotency_key="crm-replies-webhook", ) print(webhook) ``` Each delivery is the event, with the message in `data`: ```json title="The body BlueBot posts" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "message.received", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:44:12.000Z", "data": { "id": "msg_3Lp3VsC7hGqE5tZyKbWn2d", "object": "message", "direction": "in", "state": "received", "from": "+15555550123", "text": "Perfect, see you there at 3" } } ``` A handler only has to check the signature, look at `type`, and answer `200` quickly: ```ts title="app/webhooks/bluebot/route.ts" import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); export async function POST(request: Request) { const payload = await request.text(); // Throws WebhookVerificationError when the signature doesn't match. const event = client.webhooks.verify(payload, request.headers, process.env.BLUEBOT_WEBHOOK_SECRET!); if (event.type === 'message.received') { const message = event.data; await saveReply({ from: message.from, text: message.text, contactId: message.contactId }); } return new Response(null, { status: 200 }); } ``` ```python title="webhooks.py" import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) def handle(payload: bytes, headers: dict) -> int: # Raises bluebot.WebhookVerificationError when the signature doesn't match. event = client.webhooks.verify(payload, headers, os.environ["BLUEBOT_WEBHOOK_SECRET"]) if event.type == "message.received": message = event.data save_reply(from_=message.from_, text=message.text, contact_id=message.contact_id) return 200 ``` [Webhooks](https://usebluebot.com/docs/webhooks) covers setting one up, [Verify signatures](https://usebluebot.com/docs/webhooks/verify) the check in every language, and [Test webhooks locally](https://usebluebot.com/docs/webhooks/testing) how to run a handler on your laptop with `bluebot listen`. ### When `message.received` fires BlueBot waits until the Mac has uploaded the text’s pictures and files, which usually takes seconds, so the event carries files you can download. If they haven’t come after 2 minutes, the event goes anyway with those files’ `status` set to `waiting`; read the message again later for them. A text arrives on your Mac when Messages gets it, and the Mac copies new texts at each check-in, every 10 seconds. A reply usually reaches your webhook within 10 to 20 seconds of reaching your phone. While the Mac is asleep or off, nothing is copied. When it comes back, it copies everything it missed, in order, and each text fires its event then. `received_at` is when the text really arrived, so use that rather than the time your webhook was called. ## List incoming texts To read texts in rather than be told, list them. Lists are newest first: ```bash curl "https://usebluebot.com/api/v1/messages?direction=in&since=2026-11-13T00%3A00%3A00Z" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const page = await client.messages.list({ direction: 'in', since: '2026-11-13T00:00:00Z', }); console.log(page.data); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) page = client.messages.list(direction="in", since="2026-11-13T00:00:00Z") print(page.data) ``` Narrow it with `person` (a number or an address), `contact_id`, `chat_id` or `until`, and page through with `cursor`. [Pagination](https://usebluebot.com/docs/concepts/pagination) To catch up on what a webhook missed, read the event feed, which keeps 30 days of events in the shape a webhook gets them: ```bash curl "https://usebluebot.com/api/v1/events?type=message.received&since=2026-11-13T00%3A00%3A00Z" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const page = await client.events.list({ type: 'message.received', since: '2026-11-13T00:00:00Z', }); console.log(page.data); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) page = client.events.list(type="message.received", since="2026-11-13T00:00:00Z") print(page.data) ``` ## Texts you type by hand The Mac copies both directions of every chat it copies, so a text you type on your Mac or iPhone shows up too, as `"direction": "out"` with `"source": "mac"`. It fires `message.sent` like a text BlueBot sent. Your code can tell the two apart by `source`, and the dashboard shows the whole conversation the way your phone does. Texts you type by hand don’t count against your daily limits and aren’t paced: BlueBot only records them. ## Tapbacks When somebody loves, likes or laughs at a text (a tapback), BlueBot records it on that text’s `reactions` and fires `reaction.added`. Taking one back fires `reaction.removed`. ```json title="data on reaction.added" { "id": "rxn_1Hq2LxW9cVbN3mKpRtYs0a", "object": "reaction", "kind": "love", "emoji": null, "from": "+15555550123", "direction": "in", "added_at": "2026-11-13T19:45:00.000Z", "message_id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "conversation_id": "conv_1Hq2LxW9cVbN3mKpRtYs0a", "chat_id": null, "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "removed_at": null } ``` `kind` is `love`, `like`, `dislike`, `laugh`, `emphasize`, `question`, or `emoji` with the emoji in `emoji`. `direction` is `in` for the other person’s and `out` for one you added on your own phone. BlueBot can read tapbacks but can’t send them. [Capabilities](https://usebluebot.com/docs/concepts/capabilities) A tapback is not a text, so it doesn’t fire `message.received` and isn’t in `GET /v1/messages`. ## Edits and unsends On macOS 13 or later, when somebody edits a text, BlueBot updates its `text`, sets `edited_at` and fires `message.edited`. When somebody unsends one, BlueBot clears its words, sets `unsent_at` and fires `message.unsent`. Both work for your own texts too. If your app showed the old words somewhere, update or remove them when these events come. ## Stop words When somebody texts you one-to-one with only a stop word, such as STOP or Unsubscribe, BlueBot still copies the text and fires `message.received`. While stop words are on (the default), it also puts them on your stop list, cancels any texts still waiting for them and fires `contact.stopped`. A reply of START, UNSTOP or YES takes them off again. Stop words in a group chat do nothing, since BlueBot can’t leave one person out of a group. [Stop words](https://usebluebot.com/docs/concepts/stop-words) ## What BlueBot keeps BlueBot keeps the words of a text, and the files on it, for 30 days. After that the message stays with its facts (who, when, which service, the tapbacks) and `"text_deleted": true`. If you need the words for longer, copy them into your own system when `message.received` comes. [Data retention](https://usebluebot.com/docs/concepts/data-retention) --- Source: https://usebluebot.com/docs/sending/scheduled # Scheduled texts > Send a text at a time you choose with send_at, change or cancel it until then, and see how quiet hours, pacing and expiry decide when it really goes. Add `send_at` to a send and the text waits as a draft until that time. Until it’s due you can change its words, its time or its metadata, or cancel it. When the time comes, it joins the queue like any text sent at that moment. Dr. Patel’s office books a cleaning for Maria on Thursday at 10:30 AM. The booking system schedules a reminder for Wednesday at 5 PM: ```bash curl -X POST "https://usebluebot.com/api/v1/messages" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: appt-7712-reminder" \ -d '{ "to": "+15555550123", "text": "Hi Maria, a reminder that your cleaning with Dr. Patel is tomorrow at 10:30 AM. Reply C to confirm.", "send_at": "2026-11-19T17:00:00-05:00", "expires_at": "2026-11-20T10:30:00-05:00", "metadata": { "appointment_id": "7712" } }' ``` ```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, a reminder that your cleaning with Dr. Patel is tomorrow at 10:30 AM. Reply C to confirm.', sendAt: '2026-11-19T17:00:00-05:00', expiresAt: '2026-11-20T10:30:00-05:00', metadata: { appointment_id: '7712', }, }, { idempotencyKey: 'appt-7712-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", text="Hi Maria, a reminder that your cleaning with Dr. Patel is tomorrow at 10:30 AM. Reply C to confirm.", send_at="2026-11-19T17:00:00-05:00", expires_at="2026-11-20T10:30:00-05:00", metadata={ "appointment_id": "7712", }, idempotency_key="appt-7712-reminder", ) print(message) ``` The answer, `201`: ```json { "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "object": "message", "state": "scheduled", "to": "+15555550123", "hold": null, "send_at": "2026-11-19T22:00:00.000Z", "expires_at": "2026-11-20T15:30:00.000Z", "queued_at": null, "metadata": { "appointment_id": "7712" } } ``` Write `send_at` as an ISO 8601 time with its zone: `2026-11-19T17:00:00-05:00` or `2026-11-19T22:00:00Z`. A time with no zone is refused, since BlueBot can’t guess whose 5 PM you meant. Answers always give times in UTC. A `send_at` less than 5 seconds away, or already past, isn’t an error: the text is sent now and comes back `queued`. A booking made after the reminder time still gets its reminder. Scheduling fires no webhook. The first event for a scheduled text is `message.queued`, when it becomes due. ## When it really goes `send_at` is when the text joins the queue, not the second it leaves your phone: 1. Within about a minute of `send_at`, BlueBot moves the text from `scheduled` to `queued` and fires `message.queued`. 2. The rules decide whether it can go now. If not, it gets a `hold` that says why and until when, exactly as a text sent at that moment would. 3. The Mac takes it at its next check-in (every 10 seconds) in its turn, leaving your pace between texts. So a reminder scheduled for 5:00 PM usually arrives a little after 5:00. Texts due at the same time leave in the order they became due, one pace apart: 200 reminders all scheduled for 9:00 AM leave over about an hour at the default pace of 20 seconds. Spread them out if the time matters, or lower the pace in your sending rules. [Sending rules](https://usebluebot.com/docs/concepts/sending-rules) ### Quiet hours A scheduled text obeys quiet hours like any other. With the default quiet hours, 9:00 PM to 8:00 AM in your zone, a text scheduled for 7:30 AM is held with `quiet_hours` and goes at 8:00 AM. If you want texts to go at 7:30, move the end of quiet hours, or turn them off, under **Settings → Sending rules**. [Quiet hours](https://usebluebot.com/docs/concepts/quiet-hours) The same goes for the other holds. If the Mac is asleep at 5 PM, the reminder waits for it to come back. If you’ve reached your new-people limit for the day, a scheduled text to somebody new waits until midnight. [Holds](https://usebluebot.com/docs/concepts/holds) ### Expiry A held text doesn’t wait forever. Each text has an `expires_at`. A text still waiting at that time becomes `expired`, fires `message.expired` and never goes. For a scheduled text, the default expiry counts from `send_at`: 24 hours after it, or whatever your **Expire texts after** setting says (1 to 168 hours). A text never expires while it’s still scheduled. Give a reminder its own `expires_at` when a late text would be wrong. In the example above, the reminder expires at 10:30 AM on Thursday: if Dr. Patel’s Mac is off from Wednesday afternoon until Thursday noon, Maria gets no reminder for an appointment she has already missed. `expires_at` has to be after `send_at`, or the send is `400 invalid_request`. ## Change it before it goes While the text is `scheduled`, `PATCH` it to change the words, the time, the expiry or the metadata. Maria moves her cleaning to Friday at 2 PM: ```bash curl -X PATCH "https://usebluebot.com/api/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Hi Maria, a reminder that your cleaning with Dr. Patel is tomorrow at 2 PM. Reply C to confirm.", "send_at": "2026-11-20T17:00:00-05:00", "expires_at": "2026-11-21T14: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.update('msg_1Hq2LxW9cVbN3mKpRtYs0a', { text: 'Hi Maria, a reminder that your cleaning with Dr. Patel is tomorrow at 2 PM. Reply C to confirm.', sendAt: '2026-11-20T17:00:00-05:00', expiresAt: '2026-11-21T14:00:00-05:00', }); console.log(message); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) message = client.messages.update( "msg_1Hq2LxW9cVbN3mKpRtYs0a", text="Hi Maria, a reminder that your cleaning with Dr. Patel is tomorrow at 2 PM. Reply C to confirm.", send_at="2026-11-20T17:00:00-05:00", expires_at="2026-11-21T14:00:00-05:00", ) print(message) ``` A new `send_at` without an `expires_at` moves the default expiry along with it. `metadata` replaces the whole object. A `send_at` that is now or past sends the text at once. Once a text is due it can’t be changed, only canceled. [Change, cancel or settle a text](https://usebluebot.com/docs/sending/change-or-cancel) ## Cancel it ```bash curl -X POST "https://usebluebot.com/api/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a/cancel" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Idempotency-Key: appt-7712-reminder-cancel" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const message = await client.messages.cancel('msg_1Hq2LxW9cVbN3mKpRtYs0a', { idempotencyKey: 'appt-7712-reminder-cancel' }); console.log(message); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) message = client.messages.cancel( "msg_1Hq2LxW9cVbN3mKpRtYs0a", idempotency_key="appt-7712-reminder-cancel", ) print(message) ``` A scheduled or queued text can be canceled. It comes back `"state": "canceled"` with `"cancel_reason": "requested"`, and `message.canceled` fires. Canceling a text that’s already canceled answers it again unchanged, so a retry is safe. Once the Mac has taken it, it can’t be called back. If the person texts STOP before the text is due, BlueBot cancels it for you with `cancel_reason: "opted_out"`. ## Find your scheduled texts ```bash curl "https://usebluebot.com/api/v1/messages?state=scheduled" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const page = await client.messages.list({ state: 'scheduled', }); console.log(page.data); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) page = client.messages.list(state="scheduled") print(page.data) ``` The list is newest first by when each text was made, not by `send_at`. Keep the message `id` in your own record when you schedule, next to the appointment or the order it’s for, so you can change or cancel the right text later. Putting your record’s id in `metadata` lets every event about the text find the record too. | You got | It means | Do next | | ------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------- | | `201` with `"state": "scheduled"` | It goes at `send_at`. | Keep the `id` to change or cancel it. | | `201` with `"state": "queued"` | `send_at` was less than 5 seconds away or in the past, so it’s going now. | Check that your clock and the zone in `send_at` are right. | | `400 invalid_request` on `send_at` | The time has no zone or isn’t a date. | Write it like `2026-11-19T17:00:00-05:00`. | | `400 invalid_request` on `expires_at` | It’s before `send_at`. | Give it a time after the text is due. | | `409 conflict` on a `PATCH` | The text is already due (`queued`) or gone. | Cancel it if it’s still `queued`, then send a new one. | ## In the SDKs The SDKs take the time as a string or a date: ```ts title="schedule.ts" const message = await client.messages.send( { to: '+15555550123', text: 'Hi Maria, a reminder that your cleaning with Dr. Patel is tomorrow at 10:30 AM. Reply C to confirm.', sendAt: '2026-11-19T17:00:00-05:00', expiresAt: '2026-11-20T10:30:00-05:00', metadata: { appointment_id: '7712' }, }, { idempotencyKey: 'appt-7712-reminder' }, ); ``` ```python title="schedule.py" from datetime import datetime from zoneinfo import ZoneInfo eastern = ZoneInfo("America/New_York") message = client.messages.send( to="+15555550123", text="Hi Maria, a reminder that your cleaning with Dr. Patel is tomorrow at 10:30 AM. Reply C to confirm.", send_at=datetime(2026, 11, 19, 17, 0, tzinfo=eastern), expires_at=datetime(2026, 11, 20, 10, 30, tzinfo=eastern), metadata={"appointment_id": "7712"}, idempotency_key="appt-7712-reminder", ) ``` A `Date` in TypeScript or an aware `datetime` in Python is sent with its zone. A naive Python `datetime`, with no `tzinfo`, is refused by the SDK before it reaches BlueBot. The [appointment reminders guide](https://usebluebot.com/docs/guides/appointment-reminders) builds this into a whole booking flow. --- Source: https://usebluebot.com/docs/sending/change-or-cancel # Change, cancel or settle a text > Change a scheduled text, cancel one that hasn’t gone, and settle an unconfirmed text the Mac couldn’t confirm, with which states allow which. A text can be changed while it’s scheduled, canceled while it’s waiting, and settled by you when the Mac couldn’t tell whether it went. After it goes, it’s in the person’s Messages and nobody can take it back. ## What each state allows | State | Change it (`PATCH`) | Cancel it | Settle it (`resolve`) | | ------------------------------------------------ | ------------------- | ------------------------ | --------------------- | | `scheduled` | Yes | Yes | No | | `queued` | No | Yes | No | | `sending` | No | No: the Mac has it | No | | `unconfirmed` | No | No | Yes | | `canceled` | No | Yes, and nothing changes | No | | `sent`, `delivered`, `read`, `failed`, `expired` | No | No | No | Asking for something a state doesn’t allow answers `409 conflict`, and `error.message` names the state the text is in. Texts that came in (`received`) can’t be changed, canceled or settled. [Message states](https://usebluebot.com/docs/concepts/message-states) ## Change a scheduled text `PATCH /v1/messages/{id}` changes the words, the time, the expiry or the metadata of a text that is still `scheduled`. Send only what changes: ```bash curl -X PATCH "https://usebluebot.com/api/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Hi Maria, your cleaning with Dr. Patel moved to Friday at 2 PM. Reply C to confirm.", "send_at": "2026-11-20T17: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.update('msg_1Hq2LxW9cVbN3mKpRtYs0a', { text: 'Hi Maria, your cleaning with Dr. Patel moved to Friday at 2 PM. Reply C to confirm.', sendAt: '2026-11-20T17:00:00-05:00', }); console.log(message); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) message = client.messages.update( "msg_1Hq2LxW9cVbN3mKpRtYs0a", text="Hi Maria, your cleaning with Dr. Patel moved to Friday at 2 PM. Reply C to confirm.", send_at="2026-11-20T17:00:00-05:00", ) print(message) ``` | Field | What it does | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `text` | New words, up to 2,000 characters. `null` removes them, which only works on a text with files. | | `send_at` | A new time. Now or in the past sends the text at once: it becomes `queued` and `message.queued` fires. | | `expires_at` | A new expiry, after the text is due. `null` puts back the default from your settings. A new `send_at` with no `expires_at` moves the default expiry along with it. | | `metadata` | Replaces the whole metadata object, so send every key you want to keep. | You can’t change who a text goes to, its service or its files. Cancel it and send a new one instead. The answer is the changed message. A scheduled text that becomes due fires `message.queued`; otherwise a change fires no event. | You got | It means | Do next | | --------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `200` with `"state": "scheduled"` | Changed. It goes at the new `send_at`. | Nothing. | | `200` with `"state": "queued"` | The new `send_at` was now or past, so it’s going. | Nothing, unless you meant a later time. | | `409 conflict` | It isn’t scheduled any more. | If it’s `queued`, cancel it and send the new words as a new text. | | `400 invalid_request` | An empty body, words over 2,000 characters, or an expiry before the text is due. | Fix the field `error.details` names. | | `404 not_found` | No text with that id on your account, or a text that came in. | Check the `msg_` id. | ## Cancel a text `POST /v1/messages/{id}/cancel` stops a text that is `scheduled` or `queued`: ```bash curl -X POST "https://usebluebot.com/api/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a/cancel" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Idempotency-Key: cancel-msg_1Hq2LxW9cVbN3mKpRtYs0a" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const message = await client.messages.cancel('msg_1Hq2LxW9cVbN3mKpRtYs0a', { idempotencyKey: 'cancel-msg_1Hq2LxW9cVbN3mKpRtYs0a' }); console.log(message); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) message = client.messages.cancel( "msg_1Hq2LxW9cVbN3mKpRtYs0a", idempotency_key="cancel-msg_1Hq2LxW9cVbN3mKpRtYs0a", ) print(message) ``` The answer, `200`: ```json { "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "object": "message", "state": "canceled", "cancel_reason": "requested", "canceled_at": "2026-11-13T19:42:00.000Z", "hold": null } ``` The text never goes, and `message.canceled` fires. Canceling a text that is already canceled answers it again with nothing changed, so it’s safe to retry. A text in `sending` can’t be canceled: the Mac took it, usually within the last few seconds, and it is already on its way to Messages. `cancel_reason` says who canceled a text: | `cancel_reason` | Who canceled it | | -------------------- | -------------------------------------------------------------------------------------------- | | `requested` | You, through the API, the dashboard or an integration. | | `opted_out` | BlueBot, because the person is on your stop list. | | `sequence_stopped` | BlueBot, because the sequence the text belonged to stopped, such as when the person replied. | | `broadcast_canceled` | You, by canceling the broadcast it belonged to. | | `staff` | Someone at BlueBot, such as support acting on your request. | To stop everything for a while rather than cancel texts one by one, pause sending instead. Paused texts wait with the `paused` hold and go when you resume, unless they expire first. [Holds](https://usebluebot.com/docs/concepts/holds) ## Settle an unconfirmed text When the Mac takes a text, it has 3 minutes to report what happened. Usually it reports `sent` within seconds. If those 3 minutes pass with no report, the text becomes `unconfirmed` and `message.unconfirmed` fires. That happens when the Mac lost its connection, went to sleep or restarted partway, or when Messages still hadn’t shown the text as sent 30 seconds after the Mac handed it over. A regular text going through an iPhone that’s out of reach is the usual case. The text may have gone, or it may not have. BlueBot never sends an unconfirmed text again on its own, because sending a text twice is worse than asking you. You settle it with `POST /v1/messages/{id}/resolve`: 1. Look at the conversation in Messages on your Mac or iPhone. 2. If the text is there and shows as sent, mark it sent. 3. If it isn’t, or it shows “Not Delivered”, send it again. ```bash curl -X POST "https://usebluebot.com/api/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a/resolve" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: settle-msg_1Hq2LxW9cVbN3mKpRtYs0a" \ -d '{ "action": "mark_sent" }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const message = await client.messages.resolve('msg_1Hq2LxW9cVbN3mKpRtYs0a', { action: 'mark_sent', }, { idempotencyKey: 'settle-msg_1Hq2LxW9cVbN3mKpRtYs0a' }); console.log(message); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) message = client.messages.resolve( "msg_1Hq2LxW9cVbN3mKpRtYs0a", action="mark_sent", idempotency_key="settle-msg_1Hq2LxW9cVbN3mKpRtYs0a", ) print(message) ``` | `action` | What happens | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mark_sent` | The text becomes `sent` with `"resolution": "marked_sent"`, and `message.sent` fires. | | `send_again` | BlueBot queues a new text with the same words, files, service and metadata. The original stays `unconfirmed` with `"resolution": "resent"`, and `resent_message_id` names the new text, which goes like any other and fires its own events. | ```bash curl -X POST "https://usebluebot.com/api/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a/resolve" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: settle-msg_1Hq2LxW9cVbN3mKpRtYs0a-again" \ -d '{ "action": "send_again" }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const message = await client.messages.resolve('msg_1Hq2LxW9cVbN3mKpRtYs0a', { action: 'send_again', }, { idempotencyKey: 'settle-msg_1Hq2LxW9cVbN3mKpRtYs0a-again' }); console.log(message); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) message = client.messages.resolve( "msg_1Hq2LxW9cVbN3mKpRtYs0a", action="send_again", idempotency_key="settle-msg_1Hq2LxW9cVbN3mKpRtYs0a-again", ) print(message) ``` The answer, `200`: ```json { "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "object": "message", "state": "unconfirmed", "resolution": "resent", "resolved_at": "2026-11-13T19:50:00.000Z", "resent_message_id": "msg_2Bf8QzD4kJmA6nXwEuTr1c" } ``` Settle a text once. Settling it again, or settling a text that isn’t `unconfirmed`, answers `409 conflict`. Send an `Idempotency-Key` with `send_again` so a retry can’t queue two new texts. If a late report comes in from the Mac saying the text went after all, BlueBot records that it was sent, so a text you haven’t settled yet may settle itself. ### Find texts that need you `GET /v1/device` counts them in `unconfirmed_count`, and BlueBot’s window on the Mac lists them under **Needs you**. To read them: ```bash curl "https://usebluebot.com/api/v1/messages?state=unconfirmed" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const page = await client.messages.list({ state: 'unconfirmed', }); console.log(page.data); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) page = client.messages.list(state="unconfirmed") print(page.data) ``` A program that sends many texts should listen for `message.unconfirmed` and either ask a person or follow a rule you choose. A reminder that has a later reminder coming can usually be marked sent. A one-time code or a payment link should be checked by a person. | You got | It means | Do next | | -------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------------- | | `200` with `"state": "sent"` and `"resolution": "marked_sent"` | Recorded as sent. | Nothing. | | `200` with `"resolution": "resent"` | A new text is queued. | Follow `resent_message_id` for its state. | | `409 conflict` | It was already settled, or it isn’t `unconfirmed`. | Read the text to see its state. | ## What you can’t do BlueBot can’t edit a text after it’s sent, unsend it, or send a tapback. Messages allows those only through private frameworks in macOS that need System Integrity Protection turned off, and BlueBot won’t ask that of anyone. You can still edit or unsend a text by hand in Messages on your Mac or iPhone, and BlueBot records the change with `message.edited` or `message.unsent`. [Capabilities](https://usebluebot.com/docs/concepts/capabilities) --- Source: https://usebluebot.com/docs/sending/files # Files and voice memos > Upload pictures, video, audio, PDFs and contact cards and send them on a text, read the pictures and voice memos people send you, and know the limits on each. A text can carry pictures, video, audio, a PDF or a contact card, and texts that come in carry whatever people send you, voice memos included. Sending a file takes two calls: upload it, then send its id. ## Send a file Dana wants to send Maria the photos of 412 Oak St. ### Upload it `POST /v1/files` takes the file as `multipart/form-data` in a field named `file`: ```bash curl -X POST "https://usebluebot.com/api/v1/files" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Idempotency-Key: upload-412-oak-kitchen" \ -F "file=@kitchen.jpg" ``` ```ts import BlueBot from '@usebluebot/sdk'; import { openAsBlob } from 'node:fs'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const file = await client.files.upload(await openAsBlob('kitchen.jpg'), { filename: 'kitchen.jpg' }, { idempotencyKey: 'upload-412-oak-kitchen' }); console.log(file); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) file = client.files.upload( open("kitchen.jpg", "rb"), idempotency_key="upload-412-oak-kitchen", ) print(file) ``` The answer, `201`: ```json { "id": "file_1Hq2LxW9cVbN3mKpRtYs0a", "object": "file", "name": "kitchen.jpg", "content_type": "image/jpeg", "size": 482113, "kind": "image", "status": "ready", "url": "https://usebluebot.com/files/f/2Bf8QzD4kJmA6nXwEuTr1c?sig=…", "url_expires_at": "2026-11-13T20:41:00.000Z", "transcript": null, "transcript_language": null, "created_at": "2026-11-13T19:40:00.000Z" } ``` The file goes by the name it was uploaded with. To send it under another name, add a `name` form field (`-F name=412-oak-kitchen.jpg` with curl). The person sees that name on a PDF or a contact card. BlueBot reads the file’s type from the `Content-Type` of the form part. curl and the SDKs set it from the file’s extension. If your code builds the form by hand, set it, or the upload is refused as `415 unsupported_media_type`. ### Send it Put the id in `files`, with words or without: ```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’s the kitchen at 412 Oak St, as promised.", "files": [ "file_1Hq2LxW9cVbN3mKpRtYs0a" ] }' ``` ```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’s the kitchen at 412 Oak St, as promised.', files: ['file_1Hq2LxW9cVbN3mKpRtYs0a'], }, { 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’s the kitchen at 412 Oak St, as promised.", files=["file_1Hq2LxW9cVbN3mKpRtYs0a"], idempotency_key="listing-412-oak-photos", ) print(message) ``` Up to 10 files go on one text. They follow the words in the order you list them. Each one arrives as its own picture or attachment in the conversation, the way it would if you dragged them into Messages one after another. ### Check it went `message.sent` fires once the words and every file went. If the words went and a file didn’t, the text is still `sent`, with `failure` set to say which file: The answer, `200`: ```json { "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "object": "message", "state": "sent", "service": "imessage", "failure": { "reason": "file_unavailable", "detail": "Messages didn’t send kitchen.jpg." } } ``` If the Mac couldn’t download a file to send it, nothing goes: the text fails with `failure.reason: "file_unavailable"`, so the person never gets words that point at a picture they don’t have. ## What you can send | Kind | Types | `kind` on the file | | ------------- | -------------------------------- | ------------------ | | Pictures | JPEG, PNG, GIF, HEIC, HEIF, WebP | `image` | | Video | MP4, QuickTime (`.mov`) | `video` | | Audio | MP3, M4A, AAC, WAV, CAF | `audio` | | Documents | PDF | `pdf` | | Contact cards | vCard (`.vcf`, `text/vcard`) | `contact_card` | Each file can be up to 25 MB. Anything else is `415 unsupported_media_type`, and a larger file is `413 payload_too_large`. An empty file is `400 invalid_request`. One upload can go on more than one text, so a logo or a menu you send to many people needs uploading once. An upload no text uses is deleted after a day, and a file that went out is kept for 30 days, the same as the words of a text. After that its `status` is `deleted`. Upload again for a text after that. ### iMessage and regular texts Over iMessage, files up to the 25 MB limit go as they are. Over a regular text, a file goes as a picture message (MMS) through your iPhone, and your carrier decides how large that may be: phones usually shrink a big photo to fit, and may refuse a long video. Send large files to people on iMessage, or send a link to the file instead. ## Contact cards Upload a `.vcf` file and send it like any other file. The person can tap it to save your details. A good first text to a new client carries one, so your name shows up when you text them next time: ```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-card" \ -d '{ "to": "+15555550123", "text": "Here’s my card so you have my number saved.", "files": [ "file_3Lp3VsC7hGqE5tZyKbWn2d" ] }' ``` ```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’s my card so you have my number saved.', files: ['file_3Lp3VsC7hGqE5tZyKbWn2d'], }, { idempotencyKey: 'deal-48213-card' }); 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’s my card so you have my number saved.", files=["file_3Lp3VsC7hGqE5tZyKbWn2d"], idempotency_key="deal-48213-card", ) print(message) ``` ## Voice memos ### Sending audio Upload an audio file (M4A, MP3, AAC, WAV or CAF) and send it. It arrives as audio the person can play in the conversation. BlueBot can’t record a voice memo in Messages for you, so a recording made elsewhere goes as an audio file. ### Voice memos you receive When somebody sends you a voice memo, it comes on the message as a file with `"kind": "voice_memo"` and `"content_type": "audio/x-caf"`. Safari plays CAF files and most other browsers don’t, so convert one before you play it on a web page. `ffmpeg -i memo.caf memo.m4a` does it. If you turn on transcription in BlueBot’s settings on the Mac, the Mac transcribes each voice memo with Apple’s own speech recognition, on the Mac itself, before it copies the message. The words come in `transcript`, with the language in `transcript_language`: ```json title="A voice memo with a transcript" { "id": "file_2Bf8QzD4kJmA6nXwEuTr1c", "object": "file", "name": "Audio Message.caf", "content_type": "audio/x-caf", "size": 41022, "kind": "voice_memo", "status": "ready", "url": "https://usebluebot.com/files/f/3Lp3VsC7hGqE5tZyKbWn2d?sig=…", "url_expires_at": "2026-11-13T20:44:00.000Z", "transcript": "Hey Dana, Thursday works, can we do 3 instead of 2?", "transcript_language": "en-US", "created_at": "2026-11-13T19:44:00.000Z" } ``` Transcription is off until you turn it on, costs nothing, and sends no audio anywhere but BlueBot. `GET /v1/device` says whether it’s on, in `transcription`. A memo that couldn’t be transcribed has `"transcript": null`. ## Files you receive Pictures, videos, voice memos and documents people send you are on the message’s `files`, in order. A file’s `status` says whether you can read it: | `status` | It means | Do next | | ----------- | --------------------------------------------------------- | --------------------------------------------------------------------------------- | | `ready` | BlueBot has it. `url` reads it for an hour. | Download it now if you want to keep it. | | `waiting` | The Mac is still uploading it, usually for a few seconds. | Read the message or the file again shortly. | | `too_large` | It’s over 25 MB, so the Mac didn’t upload it. | Open it in Messages on your Mac or iPhone. | | `deleted` | It’s more than 30 days old. | Nothing. Copy files into your own storage when they come if you need them longer. | `message.received` waits until the Mac has uploaded the text’s files, so the files on the event are usually `ready`. If they take more than 2 minutes, the event goes anyway and the slow ones are `waiting`. Each `url` is a signed link that works for an hour and needs no key, so anyone with it can read the file in that hour. Don’t paste it anywhere public. When a link has run out, ask for the file again and you get a fresh one: ```bash curl "https://usebluebot.com/api/v1/files/file_2Bf8QzD4kJmA6nXwEuTr1c" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const file = await client.files.get('file_2Bf8QzD4kJmA6nXwEuTr1c'); console.log(file); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) file = client.files.get("file_2Bf8QzD4kJmA6nXwEuTr1c") print(file) ``` The Mac uploads files only from the chats it copies. [Receive texts](https://usebluebot.com/docs/sending/receiving) explains which those are. ## Limits | Limit | Value | | -------------------------------- | ------------------- | | One file | 25 MB | | Files on one text | 10 | | An upload no text uses | Deleted after a day | | A file that was sent or received | Kept 30 days | | A file’s `url` | Works for an hour | [Limits](https://usebluebot.com/docs/limits) lists every limit in one place. --- Source: https://usebluebot.com/docs/sending/group-chats # Group chats > Find a group chat, text into it with chat_id, and read what its members send. BlueBot can text into a group that already exists but can’t start a new one. BlueBot can text into any group chat that already exists in Messages on your Mac, and read what everyone in it sends. It can’t start a new group: Messages offers no reliable way for an app to do that. Dana is selling 412 Oak St, and she has a group chat in Messages called “Oak St closing” with the buyers and their lender. Her code finds the chat once, then posts updates into it as the closing moves along. ## Find the chat `GET /v1/chats` lists the chats your Mac has copied, newest first. `is_group=true` leaves out the one-to-one chats: ```bash curl "https://usebluebot.com/api/v1/chats?is_group=true" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const page = await client.chats.list({ isGroup: true, }); console.log(page.data); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) page = client.chats.list(is_group=True) print(page.data) ``` The answer, `200`: ```json { "object": "list", "data": [ { "id": "chat_1Hq2LxW9cVbN3mKpRtYs0a", "object": "chat", "name": "Oak St closing", "is_group": true, "participants": ["+15555550123", "+15555550187"], "service": "imessage", "last_message_at": "2026-11-13T19:41:00.000Z", "created_at": "2026-11-02T14:00:00.000Z" } ], "has_more": false, "next_cursor": null } ``` | Field | What it tells you | | -------------- | --------------------------------------------------------------------- | | `id` | The chat’s id (`chat_…`). This is what `chat_id` takes on a send. | | `name` | The group’s name in Messages, or `null` when nobody named it. | | `participants` | Everyone in the chat except you. | | `service` | `imessage` for an iMessage group, `sms` for a group of regular texts. | To find a group somebody is in, add `person`: `GET /v1/chats?is_group=true&person=+15555550187`. A chat’s id stays the same for as long as the group exists, so look it up once and keep it. A group appears in the list once your Mac has copied it. Under the default **Which chats to copy** setting, that means a group in which you’ve already texted every other member through BlueBot. A new group of people you’ve never texted that way won’t show until you set **Which chats to copy** to **Everything**. [Receive texts](https://usebluebot.com/docs/sending/receiving) explains the setting. When the Mac copies a group BlueBot didn’t know, `chat.created` fires. ## Text into it Send `chat_id` in place of `to`: ```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-appraisal" \ -d '{ "chat_id": "chat_1Hq2LxW9cVbN3mKpRtYs0a", "text": "Good news: the appraisal came back at the asking price. Closing is still on for the 24th." }' ``` ```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: 'Good news: the appraisal came back at the asking price. Closing is still on for the 24th.', }, { idempotencyKey: 'oak-st-closing-appraisal' }); 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="Good news: the appraisal came back at the asking price. Closing is still on for the 24th.", idempotency_key="oak-st-closing-appraisal", ) print(message) ``` The answer, `201`: ```json { "id": "msg_2Bf8QzD4kJmA6nXwEuTr1c", "object": "message", "direction": "out", "state": "queued", "to": null, "chat_id": "chat_1Hq2LxW9cVbN3mKpRtYs0a", "contact_id": null, "hold": null } ``` Everyone in the group gets it, as if you had typed it in Messages. A group text has `"to": null` and `"contact_id": null`, since it goes to the chat rather than one person. Files, `send_at`, `expires_at` and `metadata` work as they do on any send. Send `to` or `chat_id`, never both, or the send is `400 invalid_request`. A `chat_id` that isn’t on your account is `404 not_found`. The id of a one-to-one chat works in `chat_id` too. BlueBot then sends to that person as if you had used `to`, so their stop list and their conversation apply. ## How groups differ | | One-to-one | Group | | ---------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------ | | Service with `auto` | The newest chat’s, else iMessage, with one SMS try if iMessage fails | The group’s own service. No SMS fallback. | | A failed text | `failed`, or sent again as SMS once with `auto` | `failed` with “Messages couldn’t send it to this group.” | | The stop list | A text to somebody on it is canceled | Doesn’t apply: BlueBot can’t leave one person out of a group | | New people a day | Counts the first text to somebody | Never counts | | Pacing, quiet hours, daily ceiling | Apply | Apply | The stop list covers one-to-one texts. If somebody in a group asks you to stop, take them out of the group on your phone, or stop texting the group. A reply of STOP inside a group doesn’t put anyone on your stop list. ## Read what the group sends Every text a member sends into a copied group comes in as a message with the group’s `chat_id`. `from` and `contact_id` are the member who sent it, and `to` is `null`: ```json title="A text in a group, as message.received carries it" { "id": "msg_3Lp3VsC7hGqE5tZyKbWn2d", "object": "message", "direction": "in", "state": "received", "to": null, "from": "+15555550187", "chat_id": "chat_1Hq2LxW9cVbN3mKpRtYs0a", "contact_id": "ct_2Bf8QzD4kJmA6nXwEuTr1c", "text": "Thanks Dana! Do we need to bring anything to closing?", "service": "imessage", "received_at": "2026-11-13T20:02:00.000Z" } ``` To read one group’s history: ```bash curl "https://usebluebot.com/api/v1/messages?chat_id=chat_1Hq2LxW9cVbN3mKpRtYs0a" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const page = await client.messages.list({ chatId: 'chat_1Hq2LxW9cVbN3mKpRtYs0a', }); console.log(page.data); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) page = client.messages.list(chat_id="chat_1Hq2LxW9cVbN3mKpRtYs0a") print(page.data) ``` ## Starting a group BlueBot can’t make a new group chat. To text a new group from your code, start it once by hand: 1. In Messages on your Mac or iPhone, start the group with everyone in it and send the first text. 2. If you haven’t texted them all through BlueBot, set **Which chats to copy** to **Everything**, at least until the group shows up. 3. Find the group with `GET /v1/chats?is_group=true` and keep its `id`. From then on your code can text into it whenever it needs to. ## What else BlueBot can’t do in a group BlueBot can’t add or remove people, rename the group, leave it, or send a tapback in it. Those all need Apple’s private frameworks. [Capabilities](https://usebluebot.com/docs/concepts/capabilities) lists what a Mac can and can’t do. --- Source: https://usebluebot.com/docs/sending/lookups # Lookups > Ask whether a person uses iMessage. The answer is a best guess from your own Messages history and says what it’s based on. This page also covers when a lookup helps and when it doesn’t. `GET /v1/lookup` answers “is this person on iMessage?” as well as anyone outside Apple can: by reading what the newest chat with them in your own Messages used. Apple offers no way to ask about somebody you’ve never texted, so for them the answer is `unknown`, and the answer always says what it rests on. ```bash curl "https://usebluebot.com/api/v1/lookup?number=%2B15555550123" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const lookup = await client.lookup('+15555550123'); console.log(lookup); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) lookup = client.lookup("+15555550123") print(lookup) ``` The answer, `200`: ```json { "object": "lookup", "handle": "+15555550123", "service": "imessage", "basis": "history", "last_seen_at": "2026-11-12T16:20:00.000Z", "checked_at": "2026-11-13T19:41:00.000Z" } ``` `number` takes a phone number or an email address, written any way `to` takes one on a send. Write the `+` as `%2B` in a URL you build by hand; the SDKs and the request examples here do it for you. A value BlueBot can’t read as either is `400 invalid_request`. ## What the answer means `service` is the guess and `basis` is what it rests on: | `service` | `basis` | It means | | ---------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------ | | `imessage` | `history` | The newest chat with this person in your Messages is an iMessage chat. They used iMessage as of `last_seen_at`. | | `sms` | `history` | The newest chat is a regular text (SMS, MMS or RCS) chat. They may have no iPhone, or they may have turned iMessage off. | | `unknown` | `none` | You’ve never had a chat with this person, so there’s nothing to go on. | | `unknown` | `device_offline` | Your Mac wasn’t online to ask. | | `unknown` | `timeout` | Your Mac didn’t answer within 15 seconds. | `history` is good evidence and nothing more. People switch phones, and somebody whose newest chat with you was over iMessage a year ago may be on Android now. Look at `last_seen_at`: an answer from last week is worth more than one from last year. | You got | It means | Do next | | --------------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `"service": "imessage"` | They used iMessage in your newest chat. | Send with `auto`, or `imessage` if the text only makes sense as an iMessage. | | `"service": "sms"` | Your newest chat with them is regular texts. | Send with `auto`. Keep files small; regular texts carry less. | | `"basis": "none"` | Nothing to go on. | Send with `auto`. It tries iMessage first and falls back to a regular text once. | | `"basis": "device_offline"` | The Mac is away. | Wake the Mac, or send anyway: a text waits for it. | | `"basis": "timeout"` | The Mac didn’t answer in time. | Try again in a minute. If it keeps happening, check the Mac’s Full Disk Access in BlueBot’s window. | ## How a lookup works Your Mac answers it. BlueBot asks at the Mac’s next check-in, the Mac reads your Messages history for the newest chat with that person, and BlueBot holds your request open for up to 15 seconds to hear back. Most lookups take one or two check-ins, a few seconds. If no answer comes in time, the API answers `unknown` with `timeout` rather than making you wait longer. The Mac needs Full Disk Access to read Messages’ history. Without it the Mac can’t answer any lookup, and every lookup times out. BlueBot’s window on the Mac shows the check and how to fix it, and `GET /v1/device` shows it as the `full_disk_access` check. An answer is reused for 10 minutes. Asking about the same person twice in a row costs one trip to the Mac, and the second answer’s `checked_at` is the first one’s. ## When to use it - **Showing it to a person.** A blue or green badge next to a contact in your CRM tells your team how a text will arrive. - **Before an iMessage-only send.** If a text only works as an iMessage (a long video, a group plan), look up first and send `service: "imessage"` only to people whose answer is `imessage`. Everyone else gets a different text, or none. - **Sorting a list once.** Before a campaign, look up each person once and keep the answers, rather than asking again for every send. ## When not to - **Before every send.** `service: "auto"` already uses the newest chat’s service at the moment the Mac sends, and falls back from iMessage to a regular text once on its own. A lookup first adds up to 15 seconds and a request for no better result. - **To find out about a stranger.** For anybody you’ve never texted, the answer is `unknown`. Nothing in BlueBot, or anywhere outside Apple, can say whether a number you’ve never texted is on iMessage. - **In a tight loop.** Each lookup can hold a request for 15 seconds and counts against your 120 requests a minute. Look up a few at a time, not hundreds at once. [Rate limits](https://usebluebot.com/docs/concepts/rate-limits) ## In the SDKs ```ts title="lookup.ts" const lookup = await client.lookup('+15555550123'); if (lookup.service === 'imessage') { // Safe to send the iMessage-only version. } ``` ```python title="lookup.py" lookup = client.lookup("+15555550123") if lookup.service == "imessage": ... # Safe to send the iMessage-only version. ``` The Zapier app has the same check as its **Check iMessage** search, and the MCP server as its `lookup_service` tool, so an assistant can ask before it sends. [Services and SMS fallback](https://usebluebot.com/docs/concepts/services) explains how BlueBot chooses between iMessage and a regular text on every send. --- Source: https://usebluebot.com/docs/concepts/api-keys # API keys and scopes > Make a key, send it with each request, give it only the scopes it needs, and revoke it when a program no longer needs it or the key leaks. Every request to the API carries a key, and the key decides two things: which account the request acts for, and what it may do there. Make one key for each program that uses BlueBot, give it the fewest scopes that program needs, and you can revoke any one of them without touching the others. ## Make a key In the dashboard, open **Developers → API keys** and press **New key**. 1. Name it after what will use it (“Website booking form”, “Zapier”, “Nightly sync”), up to 60 characters. The name is how you’ll tell keys apart later. 2. Tick its scopes. A new key starts with all of them; untick what the program won’t do. 3. Copy the key. BlueBot shows it this once and keeps only a hash of it, so nobody at BlueBot can show it to you again. If you lose it, revoke it and make another. Keep the key where your program reads its secrets: an environment variable, your host’s secret settings or a secret manager. Never put it in a web page, a mobile app or a public repository, where anyone could read it. ```bash export BLUEBOT_API_KEY="bb_live_…" ``` ## Send it with each request Send the key as a bearer token in the `Authorization` header: ```bash curl https://usebluebot.com/api/v1/me \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` The SDKs read `BLUEBOT_API_KEY` from the environment when you don’t pass a key, and `bluebot login` stores one for the CLI. `GET /v1/me` says which account and key a request acts as, which is the quickest check that a key works: ```bash curl "https://usebluebot.com/api/v1/me" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const me = await client.me(); console.log(me); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) me = client.me() print(me) ``` The answer, `200`: ```json { "object": "me", "account": { "id": "acct_1Hq2LxW9cVbN3mKpRtYs0a", "email": "dana@example.com", "name": "Dana Whitfield", "time_zone": "America/Chicago", "created_at": "2026-11-01T15:00:00.000Z" }, "key": { "id": "key_1Hq2LxW9cVbN3mKpRtYs0a", "name": "Website booking form", "prefix": "bb_live_7Hq2Lx", "mode": "live", "scopes": ["messages:send", "messages:read"], "created_at": "2026-11-13T19:00:00.000Z" }, "plan": { "status": "active", "active": true, "trial_end": null, "current_period_end": "2026-12-01T15:00:00.000Z" }, "device": { "status": "online" } } ``` | You got | It means | Do next | | ---------------------------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------- | | `200` | The key works. `key.scopes` says what it may do. | Use it. | | `401 unauthorized` with “That isn’t a BlueBot key” | The key is cut short, has a typo, or isn’t a BlueBot key. | Copy it again, whole. | | `401 unauthorized` with “That key isn’t valid” | The key was revoked. | Make a new key. | | `401 unauthorized` with “Send your API key as `Authorization: Bearer `” | The header is missing or isn’t `Bearer` followed by the key. | Fix the header. | | `403 insufficient_scope` | The key works but can’t read the account. | Add `account:read` to the key, or skip this check. | ## What a key looks like Every key is `bb_live_` or `bb_test_`, then 32 random letters and digits, then a 6-character checksum of everything before it, 46 characters in all: | Part | Example | | -------------------- | ---------------------------------- | | Prefix | `bb_live_` | | 32 random characters | `kV8yQ2nTmX4pLw7RzC9dHf3JsB6aGe1N` | | Checksum | `u0YtPq` | The shape does two things: - **A typo costs nothing.** BlueBot checks the shape and the checksum before it looks the key up, so a mistyped or made-up key is refused at once with `401 unauthorized`. - **A leaked key can be spotted.** The fixed prefix and the checksum let secret scanners, such as GitHub’s, recognise a BlueBot key that was pushed to a repository by mistake. The dashboard and `GET /v1/me` name a key by its first 14 characters (`bb_live_7Hq2Lx`), its `prefix`, which is enough to tell your keys apart and too little to use. ### Live and test keys Every key the dashboard at usebluebot.com makes is a live key, `bb_live_`, and sends real texts from your number. `bb_test_` keys belong to BlueBot’s own development and preview servers: usebluebot.com refuses a test key, and those servers refuse a live one, so a key copied into the wrong place can’t act on the wrong account. There is no test mode that pretends to send. To try something safely, send it to your own number: it arrives on your phone from your own number, and you can see every step. ## Scopes A scope lets a key use a group of operations. Give a key only the scopes its program needs, so a key that leaks can do as little as possible. | Scope | Lets a key | Operations | | ----------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `messages:send` | Send, schedule, edit, cancel and resolve texts; upload files. | [`POST /v1/messages`](https://usebluebot.com/docs/api/messages/send-message), [`PATCH /v1/messages/{id}`](https://usebluebot.com/docs/api/messages/update-message), [`POST /v1/messages/{id}/cancel`](https://usebluebot.com/docs/api/messages/cancel-message), [`POST /v1/messages/{id}/resolve`](https://usebluebot.com/docs/api/messages/resolve-message), [`POST /v1/files`](https://usebluebot.com/docs/api/files/upload-file), [`PATCH /v1/settings`](https://usebluebot.com/docs/api/settings/update-settings), [`POST /v1/templates`](https://usebluebot.com/docs/api/templates/create-template), [`PATCH /v1/templates/{id}`](https://usebluebot.com/docs/api/templates/update-template), [`DELETE /v1/templates/{id}`](https://usebluebot.com/docs/api/templates/delete-template), [`PATCH /v1/conversations/{id}`](https://usebluebot.com/docs/api/conversations/update-conversation), [`POST /v1/broadcasts`](https://usebluebot.com/docs/api/broadcasts/create-broadcast), [`POST /v1/broadcasts/{id}/cancel`](https://usebluebot.com/docs/api/broadcasts/cancel-broadcast), [`POST /v1/sequences`](https://usebluebot.com/docs/api/sequences/create-sequence), [`PATCH /v1/sequences/{id}`](https://usebluebot.com/docs/api/sequences/update-sequence), [`POST /v1/sequences/{id}/enrollments`](https://usebluebot.com/docs/api/sequences/enroll-in-sequence), [`POST /v1/enrollments/{id}/cancel`](https://usebluebot.com/docs/api/sequences/cancel-enrollment), [`POST /v1/hooks`](https://usebluebot.com/docs/api/text-hooks/create-text-hook), [`PATCH /v1/hooks/{id}`](https://usebluebot.com/docs/api/text-hooks/update-text-hook), [`DELETE /v1/hooks/{id}`](https://usebluebot.com/docs/api/text-hooks/delete-text-hook), [`POST /v1/automations/rules`](https://usebluebot.com/docs/api/automations/create-keyword-rule), [`PATCH /v1/automations/rules/{id}`](https://usebluebot.com/docs/api/automations/update-keyword-rule), [`DELETE /v1/automations/rules/{id}`](https://usebluebot.com/docs/api/automations/delete-keyword-rule), [`PATCH /v1/automations/away`](https://usebluebot.com/docs/api/automations/update-away-message) | | `messages:read` | Read texts, files, chats, lookups and the event feed. | [`GET /v1/messages`](https://usebluebot.com/docs/api/messages/list-messages), [`GET /v1/messages/{id}`](https://usebluebot.com/docs/api/messages/get-message), [`GET /v1/files/{id}`](https://usebluebot.com/docs/api/files/get-file), [`GET /v1/chats`](https://usebluebot.com/docs/api/chats/list-chats), [`GET /v1/chats/{id}`](https://usebluebot.com/docs/api/chats/get-chat), [`GET /v1/lookup`](https://usebluebot.com/docs/api/lookup/lookup-number), [`GET /v1/events`](https://usebluebot.com/docs/api/events/list-events), [`GET /v1/events/{id}`](https://usebluebot.com/docs/api/events/get-event), [`GET /v1/templates`](https://usebluebot.com/docs/api/templates/list-templates), [`GET /v1/templates/{id}`](https://usebluebot.com/docs/api/templates/get-template), [`GET /v1/analytics`](https://usebluebot.com/docs/api/analytics/get-analytics), [`GET /v1/conversations`](https://usebluebot.com/docs/api/conversations/list-conversations), [`GET /v1/conversations/{id}`](https://usebluebot.com/docs/api/conversations/get-conversation), [`GET /v1/conversations/{id}/messages`](https://usebluebot.com/docs/api/conversations/list-conversation-messages), [`GET /v1/broadcasts`](https://usebluebot.com/docs/api/broadcasts/list-broadcasts), [`POST /v1/broadcasts/preview`](https://usebluebot.com/docs/api/broadcasts/preview-broadcast), [`GET /v1/broadcasts/{id}`](https://usebluebot.com/docs/api/broadcasts/get-broadcast), [`GET /v1/broadcasts/{id}/recipients`](https://usebluebot.com/docs/api/broadcasts/list-broadcast-recipients), [`GET /v1/sequences`](https://usebluebot.com/docs/api/sequences/list-sequences), [`GET /v1/sequences/{id}`](https://usebluebot.com/docs/api/sequences/get-sequence), [`GET /v1/sequences/{id}/enrollments`](https://usebluebot.com/docs/api/sequences/list-enrollments), [`GET /v1/enrollments/{id}`](https://usebluebot.com/docs/api/sequences/get-enrollment), [`GET /v1/hooks`](https://usebluebot.com/docs/api/text-hooks/list-text-hooks), [`GET /v1/hooks/{id}`](https://usebluebot.com/docs/api/text-hooks/get-text-hook), [`GET /v1/automations/rules`](https://usebluebot.com/docs/api/automations/list-keyword-rules), [`GET /v1/automations/rules/{id}`](https://usebluebot.com/docs/api/automations/get-keyword-rule), [`GET /v1/automations/away`](https://usebluebot.com/docs/api/automations/get-away-message) | | `contacts:write` | Add people to the stop list and take them off it. | [`POST /v1/stops`](https://usebluebot.com/docs/api/stops/add-stop), [`DELETE /v1/stops/{person}`](https://usebluebot.com/docs/api/stops/remove-stop), [`POST /v1/contacts`](https://usebluebot.com/docs/api/contacts/create-contact), [`PATCH /v1/contacts/{id}`](https://usebluebot.com/docs/api/contacts/update-contact), [`DELETE /v1/contacts/{id}`](https://usebluebot.com/docs/api/contacts/delete-contact) | | `contacts:read` | Read the stop list. | [`GET /v1/stops`](https://usebluebot.com/docs/api/stops/list-stops), [`GET /v1/contacts`](https://usebluebot.com/docs/api/contacts/list-contacts), [`GET /v1/contacts/{id}`](https://usebluebot.com/docs/api/contacts/get-contact) | | `webhooks:manage` | Make, change, test and delete webhooks; read deliveries and replay them. | [`GET /v1/webhooks`](https://usebluebot.com/docs/api/webhooks/list-webhooks), [`POST /v1/webhooks`](https://usebluebot.com/docs/api/webhooks/create-webhook), [`GET /v1/webhooks/{id}`](https://usebluebot.com/docs/api/webhooks/get-webhook), [`PATCH /v1/webhooks/{id}`](https://usebluebot.com/docs/api/webhooks/update-webhook), [`DELETE /v1/webhooks/{id}`](https://usebluebot.com/docs/api/webhooks/delete-webhook), [`POST /v1/webhooks/{id}/test`](https://usebluebot.com/docs/api/webhooks/test-webhook), [`POST /v1/webhooks/{id}/rotate-secret`](https://usebluebot.com/docs/api/webhooks/rotate-webhook-secret), [`GET /v1/webhooks/{id}/deliveries`](https://usebluebot.com/docs/api/webhooks/list-deliveries), [`POST /v1/webhooks/{id}/deliveries/{delivery_id}/replay`](https://usebluebot.com/docs/api/webhooks/replay-delivery) | | `account:read` | Read the Mac’s status, the account and its sending rules. | [`GET /v1/device`](https://usebluebot.com/docs/api/device/get-device), [`GET /v1/me`](https://usebluebot.com/docs/api/account/get-me), [`GET /v1/settings`](https://usebluebot.com/docs/api/settings/get-settings) | `GET /v1/status`, which says whether BlueBot itself is up, needs no key at all. A key without the scope an operation needs is refused with `403 insufficient_scope`, and `error.message` names the scope. Each operation’s reference page names its scope too. A few common choices: | What the program does | Scopes | | ------------------------------------------------ | --------------------------------------------------- | | Sends texts and reads back what happened | `messages:send`, `messages:read` | | A dashboard or report that only reads | `messages:read`, `contacts:read`, `account:read` | | Sets up its own webhooks, like a CRM integration | `messages:send`, `messages:read`, `webhooks:manage` | | Keeps a stop list in step with another system | `contacts:read`, `contacts:write` | A key with every scope is a full key. A key with only `messages:read`, `contacts:read` and `account:read` is a read-only key, which can look at everything and change nothing. You can’t change a key’s scopes after it’s made. Make a new key with the scopes you want, switch your program to it, then revoke the old one. ## How many keys an account can have An account can have 10 keys at once. Making an eleventh is refused with `409 limit_reached`. Revoked keys don’t count, so revoke the ones you no longer use. ## Revoke a key On the **Developers → API keys** page, press **Revoke** next to the key. It stops working at once: the next request with it is `401 unauthorized`. Revoking can’t be undone. Revoking a key also deletes the webhooks a program made with that key through the API, so an integration you remove stops receiving events. Webhooks you made in the dashboard stay. To replace a key without a gap, make the new key, put it in your program, check the program works with `GET /v1/me`, then revoke the old one. The **Developers → API keys** page shows when each key was last used, so you can see when the old one has gone quiet. ### If a key leaks Revoke it straight away, then make a new one. A leaked key with `messages:send` can text people from your number until it’s revoked. If you see texts in your dashboard you didn’t send, revoke every key that could have sent them, and write to [support@usebluebot.com](mailto:support@usebluebot.com). ## Keys and sign-ins AI assistants that connect to BlueBot’s MCP server, such as Claude Desktop or ChatGPT, can sign in with your BlueBot account instead of a key you paste. Each gets a token that works like a key with the scopes you approved and has its own limit of 120 requests a minute. You can disconnect one at any time under **Connected apps**. Zapier, Make, n8n and Pipedream use an API key. [OAuth or an API key](https://usebluebot.com/docs/mcp/auth) --- Source: https://usebluebot.com/docs/concepts/idempotency # Idempotency > Send an Idempotency-Key with every POST so a retry after a dropped connection gets the first answer instead of sending a second text. A network can drop a connection after BlueBot accepted your text but before the answer reached you. Your code sees an error and sends again, and without protection the person gets the text twice. An `Idempotency-Key` header prevents that: BlueBot keeps the first answer under the key for 24 hours, and a retry with the same key gets that answer back instead of doing the work again. ```bash curl -X POST "https://usebluebot.com/api/v1/messages" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: appt-7712-reminder" \ -d '{ "to": "+15555550123", "text": "Hi Maria, a reminder that your cleaning is tomorrow at 10:30 AM.", "metadata": { "appointment_id": "7712" } }' ``` ```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, a reminder that your cleaning is tomorrow at 10:30 AM.', metadata: { appointment_id: '7712', }, }, { idempotencyKey: 'appt-7712-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", text="Hi Maria, a reminder that your cleaning is tomorrow at 10:30 AM.", metadata={ "appointment_id": "7712", }, idempotency_key="appt-7712-reminder", ) print(message) ``` Send it again, byte for byte the same, within 24 hours, and the answer is the same `201` with the same message, plus the header `Idempotent-Replayed: true`. Maria gets one text. ## Which requests take one Every `POST`: sending a text, uploading a file, canceling or settling a text, adding to the stop list, and making, testing, rotating or replaying webhooks. BlueBot ignores the header on `GET`, `PATCH` and `DELETE`, which are safe to repeat as they are: reading twice changes nothing, and a `PATCH` sent twice sets the same values twice. Send one on every `POST`, not only on sends. A retried `send_again` without a key can queue two new texts. ## Choosing a key A key is any string of 1 to 255 characters. What matters is that one key stands for one thing you mean to do: - **Build it from your own record and intent.** `appt-7712-reminder`, `order-10293-shipped`, `lead-48213-welcome`. A retry of the same job makes the same key, even from a different process or after a restart. - **Put the date in a key for something that repeats.** A daily summary to the same person needs `daily-summary-48213-2026-11-13`, or tomorrow’s text is answered with today’s. - **Or make a random UUID** when there’s no record to name, and keep it with the job so a retry reuses it rather than making a new one. Keys belong to the account, not to one API key, so two programs on the same account must not make the same key for different things. Starting each with the program’s name (`booking-`, `crm-`) keeps them apart. ## What a retry gets | What happened the first time | What a retry with the same key gets | | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | | Success | The same status and body, with `Idempotent-Replayed: true`. Nothing is done again. | | A refusal after BlueBot read the key, such as a number it can’t read or a file that isn’t there | The same refusal, replayed. Fix the request and send it with a new key. | | A refusal before BlueBot read the key: a malformed body, a missing field, a bad key, a missing scope, a rate limit | The request runs fresh. These refusals don’t use the key. | | A server error (`5xx`) | The request runs fresh. A server error lets the key go so a retry can succeed. | | Still running | `409 conflict` with `Retry-After: 1`. Wait a second and send the same request again. | So the rule for a loop is simple: on a network error, a timeout or a `5xx`, send the same request with the same key until you get an answer. On a `4xx`, read the error, fix the request, and use a new key. ## Reusing a key for a different request A key stands for one request. Using it in the last 24 hours with a different request answers `409 idempotency_conflict`: The answer, `409`: ```json { "error": { "code": "idempotency_conflict", "message": "This Idempotency-Key was already used for a different request in the last 24 hours.", "hint": "This Idempotency-Key was used in the last 24 hours with a different request. Use a new key for a new request.", "doc_url": "https://usebluebot.com/docs/errors/idempotency_conflict", "request_id": "req_1Hq2LxW9cVbN3mKpRtYs0a" } } ``` BlueBot compares the method, the path and the exact bytes of the body. Two bodies that mean the same thing but differ in spacing or key order count as different requests. Build the body once and send those same bytes on every retry; the SDKs do this for you. A file upload (`POST /v1/files`) is the exception, because every multipart body carries a boundary your HTTP library picks at random. For an upload BlueBot compares the parts instead: each field’s name and value, and each file’s field name, file name, type and contents. The order of the fields doesn’t count. Sending the same file again with the same key replays the first answer, whatever boundary the retry used. This is also what keeps a key from being reused by mistake. If a job meant to text Maria and a job meant to text her husband both made the key `appt-7712-reminder`, the second is refused rather than answered with Maria’s message. | You got | It means | Do next | | ---------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `201` | The first time with this key. It ran. | Keep going. | | `201` with `Idempotent-Replayed: true` | A retry. This is the first answer, and nothing was sent again. | Treat it as the answer you missed. | | `409 idempotency_conflict` | The key was used in the last 24 hours with a different request. | Use a new key. If you meant the same request, send the same bytes (for an upload, the same fields and file). | | `409 conflict` with `Retry-After: 1` | The first request with this key hasn’t finished. | Wait a second and retry with the same key. | | `400 invalid_request` naming `Idempotency-Key` | The key is empty or over 255 characters. | Send 1 to 255 characters. | ## How long a key lasts 24 hours from its first use. After that the key is free again, and a request with it runs as new. Retries belong in minutes and hours, not days; a job that may retry after a day should check whether its text went (by its `metadata` or the message `id` it stored) before sending again. ## In the SDKs The TypeScript and Python SDKs put an `Idempotency-Key` on every `POST` for you: a new random key for each call, reused for that call’s own retries. That covers the SDK retrying after a dropped connection. It doesn’t cover your own program running a job twice, because the second run makes a new call with a new key. Pass your own key for that: ```ts title="send.ts" const message = await client.messages.send( { to: '+15555550123', text: 'Your order 10293 has shipped.' }, { idempotencyKey: 'order-10293-shipped' }, ); ``` ```python title="send.py" message = client.messages.send( to="+15555550123", text="Your order 10293 has shipped.", idempotency_key="order-10293-shipped", ) ``` The Zapier, Make, n8n and Pipedream apps build a key from the run and the step, so a step that’s retried doesn’t send twice. The CLI’s `bluebot send` makes one for each command you run. --- Source: https://usebluebot.com/docs/concepts/pagination # Pagination > Every list returns one page at a time with the newest first. Each page carries a cursor for the next one. Read one page, loop through them all, or let the SDKs do it. Every list in the API answers one page at a time, newest first. Ask for up to 100 items with `limit`, and pass the page’s `next_cursor` as `cursor` to get the next one. These lists work this way: | List | Ordered by | | ---------------------------------- | --------------------------------- | | `GET /v1/messages` | When each text was made or copied | | `GET /v1/chats` | When BlueBot first saw each chat | | `GET /v1/stops` | When each person was stopped | | `GET /v1/events` | When each event happened | | `GET /v1/webhooks` | When each webhook was made | | `GET /v1/webhooks/{id}/deliveries` | When each delivery was made | ## One page ```bash curl "https://usebluebot.com/api/v1/messages?direction=in&limit=100" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const page = await client.messages.list({ direction: 'in', limit: 100, }); console.log(page.data); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) page = client.messages.list(direction="in", limit=100) print(page.data) ``` The answer, `200`: ```json { "object": "list", "data": [ { "id": "msg_3Lp3VsC7hGqE5tZyKbWn2d", "object": "message", "direction": "in", "state": "received", "from": "+15555550123", "text": "Perfect, see you there at 3" } ], "has_more": true, "next_cursor": "WyIyMDI2LTExLTEzVDE5OjQ0OjEwLjAwMFoiLCJiMmI4ZjE0Yi0zZTM4LTRmNTQtODZiYi0wOWE2OGYwYzE2ZjEiXQ" } ``` | Field | What it tells you | | ------------- | ----------------------------------------------------------------- | | `data` | This page’s items, newest first. | | `has_more` | `true` when another page follows. | | `next_cursor` | Send this as `cursor` for the next page. `null` on the last page. | | Parameter | What it does | | --------- | -------------------------------------------------------------------------------------- | | `limit` | How many items, 1 to 100. Default 25. | | `cursor` | Where to start: the `next_cursor` of the page before. Leave it out for the first page. | ## The next page Send the same request with `cursor` added: ```bash curl "https://usebluebot.com/api/v1/messages?direction=in&limit=100&cursor=WyIyMDI2LTExLTEzVDE5OjQ0OjEwLjAwMFoiLCJiMmI4ZjE0Yi0zZTM4LTRmNTQtODZiYi0wOWE2OGYwYzE2ZjEiXQ" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const page = await client.messages.list({ direction: 'in', limit: 100, cursor: 'WyIyMDI2LTExLTEzVDE5OjQ0OjEwLjAwMFoiLCJiMmI4ZjE0Yi0zZTM4LTRmNTQtODZiYi0wOWE2OGYwYzE2ZjEiXQ', }); console.log(page.data); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) page = client.messages.list( direction="in", limit=100, cursor="WyIyMDI2LTExLTEzVDE5OjQ0OjEwLjAwMFoiLCJiMmI4ZjE0Yi0zZTM4LTRmNTQtODZiYi0wOWE2OGYwYzE2ZjEiXQ", ) print(page.data) ``` Keep every other parameter the same from page to page. A cursor says where the last page ended, not what you were looking for, so changing `direction` or `person` partway gives you a page of a different list. A cursor is a position, not a snapshot. Lists page on each item’s time and id, so a text that arrives while you page never shifts an item onto a second page or makes you miss one. New items appear at the front of the list, which you’ve already read; to catch them, start again from the first page, or use `since` on lists that take it. Treat a cursor as an opaque string: store it and send it back as it is. A cursor BlueBot didn’t make, or one cut short, is `400 invalid_request`. ## Every page Loop until `has_more` is `false`: ```ts title="all-replies.ts" import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); let cursor: string | undefined; do { const page = await client.messages.list({ direction: 'in', limit: 100, cursor }); for (const message of page.data) { console.log(message.receivedAt, message.from, message.text); } cursor = page.hasMore ? (page.nextCursor ?? undefined) : undefined; } while (cursor); ``` ```python title="all_replies.py" import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) cursor = None while True: page = client.messages.list(direction="in", limit=100, cursor=cursor) for message in page.data: print(message.received_at, message.from_, message.text) if not page.has_more: break cursor = page.next_cursor ``` ### Let the SDK page for you A list call in the SDKs returns its first page, and iterating over the result walks every page after it, asking for the next one only when you get there: ```ts title="all-replies.ts" for await (const message of client.messages.list({ direction: 'in', limit: 100 })) { console.log(message.receivedAt, message.from, message.text); } ``` ```python title="all_replies.py" for message in client.messages.list(direction="in", limit=100): print(message.received_at, message.from_, message.text) ``` With the async Python client, use `async for message in client.messages.list(...)`. Break out of the loop when you have what you need; no more pages are fetched after that. ## Big reads - **Ask for 100 at a time.** `limit=100` reads in one request what the default of 25 takes four to read. Every page is a request against your 120 a minute. [Rate limits](https://usebluebot.com/docs/concepts/rate-limits) - **Narrow the list first.** `GET /v1/messages` takes `since`, `until`, `person`, `contact_id`, `chat_id`, `state`, `direction` and `source`. A narrow list has fewer pages. - **Keep your place.** For a sync that runs every few minutes, keep the newest `created_at` you’ve stored and ask with `since` next time, rather than paging everything again. `since` includes that moment, so skip any `id` you already have. - **Catch up on missed webhooks** with `GET /v1/events?since=…`, which keeps 30 days of events. [Webhooks](https://usebluebot.com/docs/webhooks) --- Source: https://usebluebot.com/docs/concepts/metadata # Metadata > Put your own ids on a text with metadata, and BlueBot hands them back on the message and on every webhook about it, so each delivery, failure and read ties back to your record. Metadata is your own data on a text. BlueBot never reads it or acts on it; it stores it with the text and hands it back on the message and on every event about that text. Use it to tie a text to the record in your system it was sent for, so a webhook saying “this text failed” tells you which appointment, order or lead it was about. Dr. Patel’s booking system texts each patient a reminder the day before. It puts the appointment’s id in metadata: ```bash curl -X POST "https://usebluebot.com/api/v1/messages" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: appt-7712-reminder" \ -d '{ "to": "+15555550123", "text": "Hi Maria, a reminder that your cleaning with Dr. Patel is tomorrow at 10:30 AM.", "metadata": { "appointment_id": "7712", "office": "north" } }' ``` ```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, a reminder that your cleaning with Dr. Patel is tomorrow at 10:30 AM.', metadata: { appointment_id: '7712', office: 'north', }, }, { idempotencyKey: 'appt-7712-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", text="Hi Maria, a reminder that your cleaning with Dr. Patel is tomorrow at 10:30 AM.", metadata={ "appointment_id": "7712", "office": "north", }, idempotency_key="appt-7712-reminder", ) print(message) ``` The answer, `201`: ```json { "id": "msg_2Bf8QzD4kJmA6nXwEuTr1c", "object": "message", "direction": "out", "state": "queued", "to": "+15555550123", "metadata": { "appointment_id": "7712", "office": "north" } } ``` When the text fails, the `message.failed` webhook carries the same metadata, so the booking system can flag appointment 7712 for a phone call without looking anything up. ## What it can hold | | Limit | | ------------ | -------------------------------- | | Keys | Up to 20 | | A key’s name | 1 to 40 characters | | A value | A string of up to 500 characters | Every value is a string. Send a number as `"7712"`, not `7712`, and a list or an object as a string you encode yourself, such as `"a,b,c"`. A number, a `true`, a `null`, a nested object or anything over a limit makes the whole send `400 invalid_request`, with `details` naming the field, such as `metadata.appointment_id`. Nothing is sent. Metadata is for ids and short labels. It's sent with every event about the text, so keep it small: the id of your record rather than a copy of it. ## Where it comes back | Where | What you get | | ------------------------------------------------------------ | ----------------------------------------------------------------------- | | The send’s answer | The `metadata` you sent. | | `GET /v1/messages/{id}` and `GET /v1/messages` | The text’s `metadata`, on every text in a list. | | Every `message.*` event, by webhook or from `GET /v1/events` | The text’s `metadata` inside `data`, as it was when the event happened. | | Tapback events (`reaction.*`) | Not there. They carry `message_id`; read the text for its metadata. | | Texts that come in | `{}`. A person’s reply has no metadata of yours. | A text with no metadata has `"metadata": {}`, never `null`, so your code can read a key without checking for the object first. ### In a webhook handler ```ts title="webhooks.ts" const event = client.webhooks.verify(body, headers, process.env.BLUEBOT_WEBHOOK_SECRET!); if (event.type === 'message.failed') { const appointmentId = event.data.metadata.appointment_id; if (appointmentId) await flagForCall(appointmentId, event.data.failure?.reason); } ``` ```python title="webhooks.py" event = client.webhooks.verify(body, headers, os.environ["BLUEBOT_WEBHOOK_SECRET"]) if event.type == "message.failed": appointment_id = event.data.metadata.get("appointment_id") if appointment_id: flag_for_call(appointment_id, event.data.failure.reason if event.data.failure else None) ``` The SDKs send and return metadata keys exactly as you write them. TypeScript turns the API’s own fields into camelCase (`event.data.contactId`) but leaves the keys inside `metadata` alone, so `appointment_id` stays `appointment_id`. ## Matching a reply to your record A reply has no metadata, because the person wrote it, not your code. To find which of your records it belongs to, read the last text you sent that person and take its metadata: ```bash curl "https://usebluebot.com/api/v1/messages?person=%2B15555550123&direction=out&limit=1" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const page = await client.messages.list({ person: '+15555550123', direction: 'out', limit: 1, }); console.log(page.data); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) page = client.messages.list(person="+15555550123", direction="out", limit=1) print(page.data) ``` The first text in `data` is the newest you sent them, and its `metadata.appointment_id` says which appointment Maria is answering. When you text the same person about more than one thing at a time, this picks the newest, which may not be the one they mean; read the words of the reply too. ## Changing it You can change metadata only while a text is scheduled. `PATCH /v1/messages/{id}` with `metadata` replaces all of it, so send every key you want to keep: ```bash curl -X PATCH "https://usebluebot.com/api/v1/messages/msg_2Bf8QzD4kJmA6nXwEuTr1c" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "metadata": { "appointment_id": "7712", "office": "south" } }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const message = await client.messages.update('msg_2Bf8QzD4kJmA6nXwEuTr1c', { metadata: { appointment_id: '7712', office: 'south', }, }); console.log(message); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) message = client.messages.update( "msg_2Bf8QzD4kJmA6nXwEuTr1c", metadata={ "appointment_id": "7712", "office": "south", }, ) print(message) ``` Once a text is queued, its metadata is fixed. [Change or cancel a text](https://usebluebot.com/docs/sending/change-or-cancel) covers what else can change and when. Settling an unconfirmed text with `send_again` queues a new text with the same words, files and metadata, so the new text’s events still tie back to your record. ## What you can’t do with it - **Search by it.** No list filters on metadata. Keep the BlueBot message `id` on your own record when you send, and look the text up by its id later. - **Use it as a key for retries.** Two sends with the same metadata are two texts. Use an [`Idempotency-Key`](https://usebluebot.com/docs/concepts/idempotency) to keep a retry from sending twice. ## What not to put in it Metadata goes to every webhook address you’ve subscribed to `message.*` events and to anyone with a key that can read messages. BlueBot deletes the words of a text after 30 days, but its metadata stays with the rest of its record. So put an id in it and keep the details in your own system: - No passwords, keys or tokens. - No health, financial or other private details about the person. `"appointment_id": "7712"` is fine; `"reason": "root canal follow-up"` is not. - Nothing you would want deleted along with the words. ## In no-code apps The Zapier, Make, n8n and Pipedream **Send Text** actions each take metadata as key and value pairs, and their triggers for a text’s status hand it back, so a Zap can carry a row’s id from the send to the step that updates the row. --- Source: https://usebluebot.com/docs/concepts/rate-limits # Rate limits > Each key may make 120 requests a minute. How the minute is counted, the headers that say where you stand, what a 429 looks like, and how to wait it out. Each API key may make 120 requests a minute. The limit is there to keep one runaway loop from slowing BlueBot down for everyone, and most programs never come near it: a program that sends a text and listens for webhooks makes one request a text. The limit counts requests, not texts. It doesn’t decide how fast your texts go out. That is your sending pace, and a send over the pace is held in the queue, never refused. [How fast texts go](#requests-and-texts) explains the difference. ## How the minute is counted - **Per key.** Each key has its own count. Two programs on two keys each get 120 a minute; two programs sharing one key share it. - **In fixed minutes.** The count starts again at the top of each minute on BlueBot’s clock, not 60 seconds after your first request. A burst at 10:41:58 and another at 10:42:01 fall in different minutes. - **Every request with a working key counts.** Reads, sends, lookups, and requests BlueBot refuses (a `400`, a `404`, a missing scope) all count, and so does a `429`. A request with no key or a bad key isn’t counted, and neither is `GET /v1/status`, which needs no key. - **Sign-ins have their own count.** An AI assistant that signs in to BlueBot’s MCP server with your account instead of a key gets 120 a minute of its own. Webhook deliveries are BlueBot’s requests to you, so they never count. ## The headers Every answer to a request with a working key says where the key stands: ```http RateLimit-Limit: 120 RateLimit-Remaining: 87 RateLimit-Reset: 23 RateLimit-Policy: 120;w=60 ``` | Header | What it says | | --------------------- | --------------------------------------------------------------------- | | `RateLimit-Limit` | Requests the key may make in a minute. | | `RateLimit-Remaining` | Requests left in this minute. | | `RateLimit-Reset` | Seconds until the next minute starts and the count goes back to zero. | | `RateLimit-Policy` | The rule itself: 120 requests in a window of 60 seconds. | These follow the IETF’s draft for rate limit headers, without the `X-` prefix some APIs use. A program that does a lot of reading can watch `RateLimit-Remaining` and slow down before it reaches zero, rather than waiting to be refused. To read them from the SDKs, use `.withResponse()` in TypeScript or `with_raw_response` in Python. [TypeScript SDK](https://usebluebot.com/docs/sdks/typescript) and [Python SDK](https://usebluebot.com/docs/sdks/python) show both. ## Over the limit The request after the 120th in a minute is refused with `429 rate_limited`. Nothing in it happens: a send isn’t queued and a change isn’t made. The answer, `429`: ```json { "error": { "code": "rate_limited", "message": "This key made more than 120 requests in a minute.", "hint": "Wait the number of seconds in `retry_after`, then try again.", "doc_url": "https://usebluebot.com/docs/errors/rate_limited", "retry_after": 23, "request_id": "req_1Hq2LxW9cVbN3mKpRtYs0a" } } ``` The `Retry-After` header and `error.retry_after` give the same number: the seconds until the next minute starts. Wait that long and send the same request again, with the same `Idempotency-Key` if it’s a `POST`. A refused request never used the key, so the retry runs as new. [Idempotency](https://usebluebot.com/docs/concepts/idempotency) | You got | It means | Do next | | ----------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------- | | `RateLimit-Remaining` near `0` | This key is close to its limit for the minute. | Slow down, or wait `RateLimit-Reset` seconds. | | `429 rate_limited` | This key made more than 120 requests this minute. | Wait `Retry-After` seconds, then send the same request again. | | `429` again and again | A loop is asking faster than 120 a minute, or several programs share the key. | Find the loop. Give each program its own key. | | A send answered `201` with `"hold": { "reason": "pacing" }` | Not a rate limit. The text is queued and waits its turn in your sending pace. | Nothing. It goes when its turn comes. | ## Waiting it out in your own code The SDKs retry a `429` for you: they wait what `Retry-After` says and try again, up to two more times. When you call the API without them, do the same: ```ts title="with-retry.ts" async function withRetry(request: () => Promise): Promise { for (let attempt = 0; ; attempt++) { const response = await request(); if (response.status !== 429 || attempt === 3) return response; const wait = Number(response.headers.get('Retry-After') ?? '1'); await new Promise((resolve) => setTimeout(resolve, wait * 1000)); } } const response = await withRetry(() => fetch('https://usebluebot.com/api/v1/messages?direction=in&limit=100', { headers: { Authorization: `Bearer ${process.env.BLUEBOT_API_KEY}` }, }), ); ``` ```python title="with_retry.py" import os import time import httpx def with_retry(send): for attempt in range(4): response = send() if response.status_code != 429 or attempt == 3: return response time.sleep(int(response.headers.get("Retry-After", "1"))) response = with_retry(lambda: httpx.get( "https://usebluebot.com/api/v1/messages", params={"direction": "in", "limit": 100}, headers={"Authorization": f"Bearer {os.environ['BLUEBOT_API_KEY']}"}, )) ``` Wait the number BlueBot gives rather than a guess. Retrying sooner gets another `429`, and each one counts as a request. ## Staying under it - **Use webhooks instead of polling.** Asking for a text’s state every few seconds spends requests on answers that haven’t changed. A webhook tells you when it changes. [Webhooks](https://usebluebot.com/docs/webhooks) - **Read 100 at a time.** A list with `limit=100` costs one request where the default of 25 costs four. [Pagination](https://usebluebot.com/docs/concepts/pagination) - **Give each program its own key.** Your booking form and your nightly sync then can’t use up each other’s minute, and you can revoke one without the other. [API keys](https://usebluebot.com/docs/concepts/api-keys) - **Look up a few people at a time.** A lookup can hold a request for up to 15 seconds while your Mac answers. Run a handful at once, not hundreds. [Lookups](https://usebluebot.com/docs/sending/lookups) ## Requests and texts The rate limit and your sending pace are two different things: | | The rate limit | Your sending pace | | -------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------- | | What it counts | Requests to the API | Texts your Mac sends | | How much | 120 a minute per key | One text every 20 seconds by default, which you can change under **Settings → Sending rules** | | What happens over it | `429 rate_limited`, and nothing happens | The text is queued with a `pacing` hold and goes when its turn comes | | Why it’s there | To keep BlueBot fast for everyone | To keep your number looking like a person to Apple and the carriers | So you can queue 100 texts in a minute with 100 requests, and at the default pace they go out one at a time over the next 33 minutes or so. [Sending rules](https://usebluebot.com/docs/concepts/sending-rules) covers the pace, quiet hours and daily limits. If you need more than 120 requests a minute for a good reason, write to [support@usebluebot.com](mailto:support@usebluebot.com) and say what your program does. --- Source: https://usebluebot.com/docs/concepts/versioning # Versioning > The API is /v1 and webhook payloads carry a dated version that each webhook keeps. What BlueBot may add without warning, what counts as a breaking change, and how to move to a new version. BlueBot versions its API in two places. Requests and answers are versioned by the path: everything in these docs is `/v1`. Webhook payloads are versioned by date: every event carries `api_version`, now `2026-11-13`, and each webhook keeps the version it was made with. Code you write against either keeps working when BlueBot changes. ## Where you see the version | Where | What it says | | ------------------------------------------------ | ---------------------------------------------------------------------- | | The path | `https://usebluebot.com/api/v1/…` | | Every event, by webhook or from `GET /v1/events` | `"api_version": "2026-11-13"`: the shape of `data`. | | Every webhook, from `GET /v1/webhooks` | `"api_version": "2026-11-13"`: the version it was made with and keeps. | | `GET /v1/status` | `"api_version"`: the newest version. | | The OpenAPI file | `info.version`: the version it describes. | ```bash curl "https://usebluebot.com/api/v1/status" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const status = await client.status(); console.log(status); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) status = client.status() print(status) ``` The answer, `200`: ```json { "object": "status", "status": "ok", "sending": "ok", "database": "ok", "api_version": "2026-11-13", "checked_at": "2026-11-13T19:41:00.000Z" } ``` ## What BlueBot changes without a new version These changes can come at any time, and your code should expect them: - **New endpoints**, and new optional fields and query parameters on existing ones. - **New fields in answers and in event `data`.** Read the fields you need and ignore the rest. The OpenAPI file never says an object’s fields are closed, so code generated from it accepts new ones. - **New event types.** A new type reaches a webhook made with `["*"]` (`"all_events": true`) at once, and no webhook that lists its types until you add it to `events` with `PATCH /v1/webhooks/{id}`. Your handler should ignore types it doesn’t know. [Webhooks](https://usebluebot.com/docs/webhooks) - **New values in a list of reasons**, such as a new `hold.reason`, `failure.reason` or `cancel_reason`. Handle a value you don’t know the way you handle the closest general case, and log it. - **New error codes** for situations that had none before. Every error has a `message` and a `hint` you can show or log whatever its code. - **Different words** in `message`, `hint`, `hold.description` and `failure.detail`. Match on codes and reasons, never on words. - **Different ids.** An id’s prefix stays the same (`msg_`, `chat_`, `ct_`); the rest is opaque and may grow longer. Store ids as strings of any length. - **Different limits and defaults** that are your settings, such as the sending pace. The [Limits](https://usebluebot.com/docs/limits) page always has the current numbers. ## What counts as a breaking change BlueBot never makes these to `/v1` or to a payload version once it’s out: - Removing or renaming a field, an endpoint, an event type or a value. - Changing a field’s type, or what it means. - Making an optional request field required, or refusing a request that used to be accepted. - Changing which error code a situation answers with. - Changing what an event’s `data` holds. A change like that to requests or answers comes as `/v2`, beside `/v1`, so you choose when to move. A change like that to event payloads comes as a new `api_version` date. Webhooks made before it keep the old shape: BlueBot stores each event in the newest shape and changes it back for each webhook as it delivers it. Before a version is retired, BlueBot emails the account owner at least 90 days ahead, and lists the change on the [changelog](https://usebluebot.com/changelog). A version still in use is never turned off without that notice. ## Moving a webhook to a new version A webhook keeps its version for good; you can’t change it on an existing webhook. To move, make a new webhook beside the old one: ### Make a new webhook Make it with the same address and events, or a new address for a new handler. A new webhook gets the newest version. ```bash curl -X POST "https://usebluebot.com/api/v1/webhooks" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: move-crm-webhook-2026-11-13" \ -d '{ "url": "https://crm.example.com/bluebot/webhooks", "events": [ "message.received", "message.failed" ], "description": "CRM, new payload version" }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const webhook = await client.webhooks.create({ url: 'https://crm.example.com/bluebot/webhooks', events: ['message.received', 'message.failed'], description: 'CRM, new payload version', }, { idempotencyKey: 'move-crm-webhook-2026-11-13' }); console.log(webhook); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) webhook = client.webhooks.create( url="https://crm.example.com/bluebot/webhooks", events=["message.received", "message.failed"], description="CRM, new payload version", idempotency_key="move-crm-webhook-2026-11-13", ) print(webhook) ``` The answer carries the new webhook’s `api_version` and its own `secret`. ### Handle both versions For a while each event arrives twice, once from each webhook, and each signed with its own webhook’s secret. Read `api_version` on each event to tell the two shapes apart, and use the event `id` to handle each event once. ```ts title="webhooks.ts" if (await alreadyHandled(event.id)) return; if (event.api_version === '2026-11-13') { await handleCurrent(event); } else { await handleOlder(event); } ``` ### Delete the old webhook Once your handler reads the new shape, delete the old webhook with `DELETE /v1/webhooks/{id}` or in the dashboard. From then on every event arrives once and in the new shape. ## The SDKs and the OpenAPI file The SDKs follow semantic versioning. A new minor version adds what BlueBot added; a new major version comes only with a breaking change in the SDK itself or a new API version, and its release notes say what to change. The [OpenAPI file](https://usebluebot.com/docs/openapi) describes `/v1` as it is now, and its `info.version` names the newest payload version. Generating a client from it again after BlueBot adds something never breaks code written against an older copy. --- Source: https://usebluebot.com/docs/webhooks # Webhooks > BlueBot posts a signed JSON event to your address when a text comes in, a text you sent changes state, or your Mac needs you. Make a webhook, choose its events and answer fast. A webhook is an address of yours that BlueBot posts to when something happens on your account: somebody texts you, a text you sent is delivered or fails, a person texts STOP, your Mac goes offline. Each post is one event as JSON, signed so you can prove it came from BlueBot. For example, Dana runs a dog-grooming shop and texts clients from her own number through BlueBot. When Maria replies "Yes, 3 PM works", BlueBot posts a `message.received` event to Dana's booking system within about a second, with Maria's number, her words and the conversation it belongs to. Dana's system confirms the slot without polling BlueBot for replies. ## How a delivery works - BlueBot sends one `POST` per event to each webhook that asked for that event's type, with `Content-Type: application/json` and `User-Agent: BlueBot-Webhooks/1.0`. - Every post is signed the [Standard Webhooks](https://www.standardwebhooks.com) way, with three headers: `webhook-id`, `webhook-timestamp` and `webhook-signature`. [Verify signatures](https://usebluebot.com/docs/webhooks/verify) shows how to check them in every language. - A `2xx` answer within 10 seconds means delivered. Anything else, a timeout, or a redirect counts as a failure, and BlueBot tries again for 72 hours. [Retries and the deliveries log](https://usebluebot.com/docs/webhooks/retries) has the schedule. - Most events go out within a second of happening. A delivery that waits behind a retry goes on the next pass, within a minute. - The address must be `https://` on the public internet. BlueBot checks where the name points on every delivery, never posts to a private or loopback address, and never follows a redirect. ## Make a webhook ### In the dashboard 1. Open **Developers → Webhooks** and press **Add webhook**. 2. Paste your address, add a description you'll recognize later ("Replies to the booking system"), and tick the events you want. 3. Press **Save**. BlueBot shows the signing secret, `whsec_…`, once. Copy it into your app's environment as `BLUEBOT_WEBHOOK_SECRET`. 4. Press **Send a test event** to post an example to your address and see what it answered. ### With the API Make one with `POST /v1/webhooks`. The key needs the `webhooks:manage` scope. Send an `Idempotency-Key` so a retried request never makes two webhooks. ```bash curl -X POST "https://usebluebot.com/api/v1/webhooks" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: booking-system-webhook" \ -d '{ "url": "https://bookings.example.com/bluebot/webhooks", "events": [ "message.received", "message.failed", "contact.stopped" ], "description": "Replies to the booking system" }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const webhook = await client.webhooks.create({ url: 'https://bookings.example.com/bluebot/webhooks', events: ['message.received', 'message.failed', 'contact.stopped'], description: 'Replies to the booking system', }, { idempotencyKey: 'booking-system-webhook' }); console.log(webhook); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) webhook = client.webhooks.create( url="https://bookings.example.com/bluebot/webhooks", events=["message.received", "message.failed", "contact.stopped"], description="Replies to the booking system", idempotency_key="booking-system-webhook", ) print(webhook) ``` The answer, `201`: ```json { "id": "wh_1Hq2LxW9cVbN3mKpRtYs0a", "object": "webhook", "url": "https://bookings.example.com/bluebot/webhooks", "description": "Replies to the booking system", "events": ["message.failed", "message.received", "contact.stopped"], "api_version": "2026-11-13", "status": "active", "paused_at": null, "pause_reason": null, "failing_since": null, "last_success_at": null, "last_failure_at": null, "last_status": null, "previous_secret_expires_at": null, "created_at": "2026-11-13T19:41:00.000Z", "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw" } ``` | You got | It means | Do next | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | | `201` with a `secret` | The webhook is live. The secret is in this answer and nowhere else. | Store the secret as `BLUEBOT_WEBHOOK_SECRET`, then [send a test event](https://usebluebot.com/docs/webhooks/testing). | | `400 invalid_request` | The address isn't `https://`, points at a private network, carries a user name or password before the host, or `events` is empty. `details` names the field. | Fix the field and send again. [invalid_request](https://usebluebot.com/docs/errors/invalid_request) | | `403 insufficient_scope` | The key can't manage webhooks. | Make a key with `webhooks:manage`. [API keys and scopes](https://usebluebot.com/docs/concepts/api-keys) | | `409 limit_reached` | The account already has 20 webhooks. | Delete one you no longer use, or point several jobs at one address. [limit_reached](https://usebluebot.com/docs/errors/limit_reached) | Lost the secret? You can't read it again, but you can make a new one: [Rotate secrets](https://usebluebot.com/docs/webhooks/rotate-secrets). ## Choose its events A webhook receives only the types it asked for. Pick by job: | If you're building | Ask for | | --------------------------------------- | ----------------------------------------------------------------------------------------------- | | Replies into a CRM, a helpdesk or Slack | `message.received` | | Delivery tracking for texts you send | `message.sent`, `message.delivered`, `message.failed`, `message.unconfirmed`, `message.expired` | | A record of opt-outs | `contact.stopped`, `contact.unstopped` | | An alert when the Mac needs you | `device.offline`, `device.check_failed`, `device.online` | | An AI agent that answers texts | `message.received`, and `message.failed` to know when an answer didn't go | | A mirror of everything | `["*"]` | `["*"]` subscribes to every type, including types BlueBot adds later: the webhook answers `"all_events": true`, and its `events` lists every type there is now. A webhook with a list of types gets only those, so a new type doesn't reach it until you add it to `events`. Every type, with when it fires: | Event | When it fires | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | [`message.queued`](https://usebluebot.com/docs/webhooks/events#messagequeued) | A text was accepted and is waiting for the Mac, or a scheduled text became due. | | [`message.sent`](https://usebluebot.com/docs/webhooks/events#messagesent) | Messages on the Mac showed the text sent. Also fires for a text you typed by hand on your Mac or iPhone, with `source: "mac"`. | | [`message.delivered`](https://usebluebot.com/docs/webhooks/events#messagedelivered) | iMessage showed the text delivered to the person’s device. | | [`message.read`](https://usebluebot.com/docs/webhooks/events#messageread) | The person read the text. Only for people who share read receipts. | | [`message.failed`](https://usebluebot.com/docs/webhooks/events#messagefailed) | Messages couldn’t send the text, or it was an iMessage-only text to somebody not on iMessage. | | [`message.unconfirmed`](https://usebluebot.com/docs/webhooks/events#messageunconfirmed) | The Mac took the text and didn’t say whether it went before its 3-minute lease ran out. | | [`message.expired`](https://usebluebot.com/docs/webhooks/events#messageexpired) | The text was still waiting when its `expires_at` passed, so it will never go. | | [`message.canceled`](https://usebluebot.com/docs/webhooks/events#messagecanceled) | A queued or scheduled text was canceled: by you, because the person is on your stop list, or when its sequence or broadcast stopped. | | [`message.received`](https://usebluebot.com/docs/webhooks/events#messagereceived) | Somebody texted you. Pictures and voice memos are on the message as files. | | [`message.edited`](https://usebluebot.com/docs/webhooks/events#messageedited) | A text was edited in Messages, by you or by the other person. | | [`message.unsent`](https://usebluebot.com/docs/webhooks/events#messageunsent) | A text was unsent in Messages. Its words are gone from BlueBot too. | | [`reaction.added`](https://usebluebot.com/docs/webhooks/events#reactionadded) | Somebody added a tapback (love, like, laugh and so on) to a text, or you did from your Mac or iPhone. | | [`reaction.removed`](https://usebluebot.com/docs/webhooks/events#reactionremoved) | A tapback was taken back. | | [`chat.created`](https://usebluebot.com/docs/webhooks/events#chatcreated) | The Mac saw a chat BlueBot didn’t know: a new group, or a first text with somebody. | | [`contact.created`](https://usebluebot.com/docs/webhooks/events#contactcreated) | A person was added: by a send to somebody new, a text from somebody new, an import, the API or the dashboard. | | [`contact.stopped`](https://usebluebot.com/docs/webhooks/events#contactstopped) | A person went on your stop list. Their queued texts were canceled. | | [`contact.unstopped`](https://usebluebot.com/docs/webhooks/events#contactunstopped) | A person came off your stop list: they texted START, or you took them off. | | [`sequence.completed`](https://usebluebot.com/docs/webhooks/events#sequencecompleted) | A person reached the end of a sequence, or it stopped early because they replied or stopped. | | [`broadcast.completed`](https://usebluebot.com/docs/webhooks/events#broadcastcompleted) | Every text in a broadcast has settled: sent, failed, skipped, expired or canceled. | | [`automation.matched`](https://usebluebot.com/docs/webhooks/events#automationmatched) | An incoming text matched a keyword rule that has the “Send a webhook event” action. | | [`device.online`](https://usebluebot.com/docs/webhooks/events#deviceonline) | The Mac checked in after being offline. Held texts start going. | | [`device.offline`](https://usebluebot.com/docs/webhooks/events#deviceoffline) | The Mac hasn’t checked in for 2 minutes. Texts wait with `hold.reason: "device_offline"`. | | [`device.check_failed`](https://usebluebot.com/docs/webhooks/events#devicecheck_failed) | One of the Mac’s setup checks turned `needs_you`, such as Messages signing out or Full Disk Access being turned off. | The [event catalog](https://usebluebot.com/docs/webhooks/events) has a full example of each. ## What a delivery looks like Each delivery is a `POST` with these headers: ```text title="Headers" POST /bluebot/webhooks HTTP/1.1 Host: bookings.example.com Content-Type: application/json User-Agent: BlueBot-Webhooks/1.0 webhook-id: evt_1Hq2LxW9cVbN3mKpRtYs0a webhook-timestamp: 1763062940 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= ``` And a body like this one, for Maria's reply: ```json title="message.received" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "message.received", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:44:12.000Z", "data": { "id": "msg_2Bf8QzD4kJmA6nXwEuTr1c", "object": "message", "direction": "in", "state": "received", "to": "+15555550100", "from": "+15555550123", "chat_id": null, "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "conversation_id": "conv_1Hq2LxW9cVbN3mKpRtYs0a", "text": "Perfect, see you there!", "files": [], "service": "imessage", "source": "mac", "metadata": {}, "received_at": "2026-11-13T19:44:10.000Z", "created_at": "2026-11-13T19:44:12.000Z" } } ``` The body is trimmed here; a real `message.received` carries every field a message has, as the [event catalog](https://usebluebot.com/docs/webhooks/events#messagereceived) shows. | Field | What it is | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | The event's id, `evt_…`. It's also the `webhook-id` header, the same on every retry and replay, so you can skip an event you already handled. | | `object` | Always `"event"`. | | `type` | What happened, such as `message.received`. | | `api_version` | The payload's shape. A webhook keeps the version it was made with, so a later change to the API never changes what your handler receives. [Versioning](https://usebluebot.com/docs/concepts/versioning) | | `timestamp` | When it happened, in UTC. | | `test` | `true` on a test event from **Send a test event** or `POST /v1/webhooks/{id}/test`, whose `data` is an example with example ids. Absent on every real event. [Test webhooks](https://usebluebot.com/docs/webhooks/testing) | | `data` | The record itself, as the API returns it at that moment: the message for `message.*`, the contact for `contact.*`, the Mac for `device.*`. Your handler needs no second request. | `data` is the record, not a wrapper around it: Maria's number is `event.data.from` and her words are `event.data.text`. On a text you sent, `data.metadata` is the metadata you sent with it, which is how you match an event to the record in your own system. [Metadata](https://usebluebot.com/docs/concepts/metadata) ## A handler Read the raw body, verify it, then act on the type. This is the whole shape of a good handler: ```ts title="TypeScript" // app/api/bluebot/webhooks/route.ts (Next.js) import BlueBot, { WebhookVerificationError } from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); export async function POST(request: Request) { const payload = await request.text(); let event; try { event = client.webhooks.verify(payload, request.headers, process.env.BLUEBOT_WEBHOOK_SECRET!); } catch (error) { if (error instanceof WebhookVerificationError) return new Response('Bad signature', { status: 400 }); throw error; } switch (event.type) { case 'message.received': await saveReply({ eventId: event.id, from: event.data.from, text: event.data.text }); break; case 'message.failed': await markUndelivered(event.data.metadata.booking_id, event.data.failure?.reason); break; case 'contact.stopped': await optOut(event.data.handle); break; } return new Response(null, { status: 204 }); } ``` ```python title="Python" # FastAPI import os from fastapi import FastAPI, HTTPException, Request, Response from bluebot import BlueBot, WebhookVerificationError app = FastAPI() client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) @app.post("/bluebot/webhooks") async def bluebot_webhook(request: Request): payload = await request.body() try: event = client.webhooks.verify(payload, request.headers, os.environ["BLUEBOT_WEBHOOK_SECRET"]) except WebhookVerificationError: raise HTTPException(status_code=400, detail="Bad signature") if event.type == "message.received": save_reply(event_id=event.id, sender=event.data.from_, text=event.data.text) elif event.type == "message.failed": mark_undelivered(event.data.metadata.get("booking_id"), event.data.failure.reason) elif event.type == "contact.stopped": opt_out(event.data.handle) return Response(status_code=204) ``` In Python the message's `from` is `from_`, since `from` is a keyword. ## Answer fast Answer with a `2xx` as soon as you've checked the signature and saved the event, and do the slow work afterwards. BlueBot waits 10 seconds for an answer and counts a slower one as a failure, then sends the same event again, so a handler that calls a CRM and an AI model before answering gets the same reply twice. A dependable pattern: 1. Verify the signature. 2. Write the event to your database or a queue, keyed by `id`. If the key is already there, answer `200` and stop. 3. Answer `204`. 4. Process the event from the queue. Answer `200`, `202` or `204`. BlueBot ignores what your answer's body says. > **Answer 410 to delete a webhook.** Answer `410 Gone` when an address will never take events again, and BlueBot deletes the webhook instead of retrying. Zapier does this when a Zap is turned off. ## Webhook status A webhook's `status` says how its deliveries are going: | `status` | It means | | --------- | --------------------------------------------------------------------------------------------------------------------- | | `active` | The last delivery succeeded, or none has been tried yet. | | `failing` | Deliveries have failed since `failing_since`. BlueBot keeps retrying each one for 72 hours. | | `paused` | Nothing is posted. `pause_reason` says why: `failing` (no `2xx` for 72 hours), `customer` (you paused it) or `staff`. | When a webhook goes 72 hours with no successful answer, BlueBot pauses it and emails you once. Events keep being recorded while it's paused. Fix the address, then resume it from **Developers → Webhooks** or with `PATCH /v1/webhooks/{id}`; BlueBot sends what waited from the last 72 hours. ```bash curl -X PATCH "https://usebluebot.com/api/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "paused": false }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const webhook = await client.webhooks.update('wh_1Hq2LxW9cVbN3mKpRtYs0a', { paused: false, }); console.log(webhook); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) webhook = client.webhooks.update("wh_1Hq2LxW9cVbN3mKpRtYs0a", paused=False) print(webhook) ``` To stop deliveries for a while yourself, send `"paused": true`. ## Limits and housekeeping - An account can have 20 webhooks. Each can take any number of event types. - Revoking an API key deletes the webhooks a program made with that key, so an integration you disconnect stops receiving events. Webhooks made in the dashboard stay. - Deleting a webhook drops the deliveries still waiting for it. - Deliveries are listed for 30 days, and events are kept in the event feed for 30 days. ## Not able to take webhooks? A script behind a firewall can poll the event feed instead. `GET /v1/events` lists the last 30 days of events, newest first, in the same envelope a webhook receives. Ask for the types you want and the time you last read up to: ```bash curl "https://usebluebot.com/api/v1/events?type=message.received&since=2026-11-13T19%3A00%3A00Z" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const page = await client.events.list({ type: 'message.received', since: '2026-11-13T19:00:00Z', }); console.log(page.data); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) page = client.events.list(type="message.received", since="2026-11-13T19:00:00Z") print(page.data) ``` The feed is also how you catch up after an outage on your side: read everything since your last handled event, and skip the ids you already have. ## Next - [Verify signatures](https://usebluebot.com/docs/webhooks/verify): Check that each post came from BlueBot, with examples in every language. - [Event catalog](https://usebluebot.com/docs/webhooks/events): Every event type with a full example payload. - [Retries and the deliveries log](https://usebluebot.com/docs/webhooks/retries): The retry schedule, duplicates and ordering. - [Test webhooks](https://usebluebot.com/docs/webhooks/testing): Send a test event, use `bluebot listen`, or open a tunnel. --- Source: https://usebluebot.com/docs/webhooks/verify # Verify signatures > Check that each webhook post came from BlueBot and wasn't changed on the way, with the SDKs, any Standard Webhooks library, or a few lines of code in your language. Your webhook address is on the public internet, so anybody who learns it can post to it. Every post from BlueBot is signed with your webhook's secret, and checking that signature is the only way to know a "Maria replied" event is real. Check it on every request, before you act on anything in the body. BlueBot signs the [Standard Webhooks](https://www.standardwebhooks.com) way, so the SDKs, the official Standard Webhooks libraries and the code below all agree. ## The three headers | Header | What it holds | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `webhook-id` | The event's id, `evt_…`. The same on every retry and replay of the event. | | `webhook-timestamp` | When this attempt was signed, in whole seconds since 1970 (Unix time). Each retry is signed afresh, so it carries a new time. | | `webhook-signature` | One or more signatures, separated by spaces, each written `v1,`. There are two for 24 hours after you rotate the secret. | ## How the signature is made 1. Take the secret, drop the `whsec_` at its start, and base64-decode the rest. Those bytes are the key. 2. Join the id, the timestamp and the raw body with dots: `..`. The body is the exact bytes BlueBot sent, before any JSON parsing. 3. Compute HMAC-SHA256 of that string with the key, and base64-encode the result. 4. Compare it with each `v1,` value in `webhook-signature`, using a constant-time comparison. If any one matches, the post is genuine. Then check the time: refuse a `webhook-timestamp` more than **5 minutes** away from your own clock, before or after. That stops anybody who captured a real post from sending it to you again later. Because BlueBot signs each attempt when it makes it, a genuine retry an hour later still has a fresh timestamp and passes. Keep your server's clock synced (NTP is on by default almost everywhere). ## With the SDKs The SDKs do all of it in one call and give you the event, typed. They read the three headers from whatever your framework hands you: a `Headers` object, a plain object or a list of pairs. ```ts title="TypeScript" import BlueBot, { WebhookVerificationError } from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); // payload: the raw body as a string or bytes. headers: the request's headers. try { const event = client.webhooks.verify(payload, headers, process.env.BLUEBOT_WEBHOOK_SECRET!); console.log(event.type, event.id); } catch (error) { if (error instanceof WebhookVerificationError) { // Answer 400 and do nothing else. } throw error; } ``` ```python title="Python" import os from bluebot import BlueBot, WebhookVerificationError client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) # payload: the raw body as bytes or str. headers: the request's headers, any mapping. try: event = client.webhooks.verify(payload, headers, os.environ["BLUEBOT_WEBHOOK_SECRET"]) print(event.type, event.id) except WebhookVerificationError: # Answer 400 and do nothing else. raise ``` `verify` checks the signature and the 5-minute window, then parses the body. It throws `WebhookVerificationError` (Python raises `bluebot.WebhookVerificationError`) when no signature matches, a header is missing, or the timestamp is out of the window. It never calls BlueBot, so it works offline and costs no request. You don't need BlueBot's SDK to verify. The official Standard Webhooks libraries take a BlueBot secret as it is, `whsec_` and all: ```ts title="TypeScript" import { Webhook } from 'standardwebhooks'; const wh = new Webhook(process.env.BLUEBOT_WEBHOOK_SECRET!); const event = wh.verify(payload, headers); // throws on a bad signature ``` ```python title="Python" import os from standardwebhooks.webhooks import Webhook wh = Webhook(os.environ["BLUEBOT_WEBHOOK_SECRET"]) event = wh.verify(payload, headers) # raises on a bad signature ``` ## Without a library With no library at all, this is the whole check. Each example reads the raw body, verifies it, then handles a `message.received`. ```ts title="TypeScript" // app/api/bluebot/route.ts (Next.js). Any framework works: verify the raw body before parsing it. import BlueBot, { WebhookVerificationError } from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); export async function POST(request: Request) { const payload = await request.text(); // the raw body, byte for byte let event; try { event = client.webhooks.verify(payload, request.headers, process.env.BLUEBOT_WEBHOOK_SECRET); } catch (error) { if (error instanceof WebhookVerificationError) return new Response('Bad signature', { status: 400 }); throw error; } if (event.type === 'message.received') { // For a message event, data is the message itself. const { from, text } = event.data; console.log(`${from}: ${text}`); } return new Response(null, { status: 204 }); } ``` ```python title="Python" # Flask. Any framework works: verify the raw body before parsing it. import os from flask import Flask, abort, request from bluebot import BlueBot, WebhookVerificationError app = Flask(__name__) client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) @app.post("/bluebot/webhooks") def bluebot_webhook(): try: event = client.webhooks.verify( request.get_data(), # the raw body, byte for byte request.headers, secret=os.environ["BLUEBOT_WEBHOOK_SECRET"], ) except WebhookVerificationError: abort(400) if event.type == "message.received": message = event.data # for a message event, data is the message itself print(f"{message.from_}: {message.text}") # `from` is `from_` in Python return "", 204 ``` ```go title="Go" package main import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "io" "net/http" "os" "strconv" "strings" "time" ) // verify checks a BlueBot webhook the Standard Webhooks way. func verify(secret string, header http.Header, body []byte) error { id := header.Get("webhook-id") timestamp := header.Get("webhook-timestamp") sent, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return errors.New("no timestamp") } if age := time.Since(time.Unix(sent, 0)); age > 5*time.Minute || age < -5*time.Minute { return errors.New("timestamp too old or too new") } key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return err } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + timestamp + "." + string(body))) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) for _, signature := range strings.Fields(header.Get("webhook-signature")) { version, value, ok := strings.Cut(signature, ",") if ok && version == "v1" && hmac.Equal([]byte(value), []byte(expected)) { return nil } } return errors.New("no signature matched") } func handler(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) // the raw body, byte for byte if err := verify(os.Getenv("BLUEBOT_WEBHOOK_SECRET"), r.Header, body); err != nil { http.Error(w, "bad signature", http.StatusBadRequest) return } // json.Unmarshal(body, &event) and handle it. w.WriteHeader(http.StatusNoContent) } func main() { http.HandleFunc("/bluebot/webhooks", handler) http.ListenAndServe(":8080", nil) } ``` ```ruby title="Ruby" require "base64" require "json" require "openssl" require "sinatra" def verify_bluebot!(secret, headers, body) id = headers["HTTP_WEBHOOK_ID"] timestamp = headers["HTTP_WEBHOOK_TIMESTAMP"] raise "timestamp too old or too new" if (Time.now.to_i - timestamp.to_i).abs > 300 key = Base64.strict_decode64(secret.delete_prefix("whsec_")) expected = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")) matched = headers["HTTP_WEBHOOK_SIGNATURE"].to_s.split(" ").any? do |signature| version, value = signature.split(",", 2) version == "v1" && Rack::Utils.secure_compare(value.to_s, expected) end raise "no signature matched" unless matched end post "/bluebot/webhooks" do body = request.body.read # the raw body, byte for byte begin verify_bluebot!(ENV.fetch("BLUEBOT_WEBHOOK_SECRET"), request.env, body) rescue StandardError halt 400, "bad signature" end event = JSON.parse(body) puts "#{event["data"]["from"]}: #{event["data"]["text"]}" if event["type"] == "message.received" status 204 end ``` ```php title="PHP" 300) { return false; // too old or too new } $key = base64_decode(substr($secret, strlen('whsec_'))); $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true)); foreach (explode(' ', $headers['webhook-signature'] ?? '') as $signature) { [$version, $value] = array_pad(explode(',', $signature, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $value)) { return true; } } return false; } $body = file_get_contents('php://input'); // the raw body, byte for byte $headers = array_change_key_case(getallheaders(), CASE_LOWER); if (!verify_bluebot(getenv('BLUEBOT_WEBHOOK_SECRET'), $headers, $body)) { http_response_code(400); exit('bad signature'); } $event = json_decode($body, true); if ($event['type'] === 'message.received') { error_log($event['data']['from'] . ': ' . $event['data']['text']); } http_response_code(204); ``` ```js title="JavaScript" // Express, with no SDK: node:crypto only. import crypto from 'node:crypto'; import express from 'express'; function verifyBlueBot(secret, headers, body) { const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // too old or too new const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest('base64'); return (headers['webhook-signature'] ?? '').split(' ').some((signature) => { const [version, value = ''] = signature.split(','); return version === 'v1' && value.length === expected.length && crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected)); }); } const app = express(); // express.raw keeps the body as bytes; parsing JSON first would change it and break the signature. app.post('/bluebot/webhooks', express.raw({ type: 'application/json' }), (req, res) => { const body = req.body.toString('utf8'); if (!verifyBlueBot(process.env.BLUEBOT_WEBHOOK_SECRET, req.headers, body)) return res.status(400).send('bad signature'); const event = JSON.parse(body); if (event.type === 'message.received') console.log(`${event.data.from}: ${event.data.text}`); res.sendStatus(204); }); app.listen(3000); ``` ```bash title="Shell" # Check one delivery by hand: paste the three headers and the raw body your endpoint logged for it. WEBHOOK_ID='evt_6Tn2xL9mQ0cR4bW8kV1sHd' # the webhook-id header WEBHOOK_TIMESTAMP='1763046273' # the webhook-timestamp header BODY='{"id":"evt_…","type":"message.received",…}' SECRET='whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw' KEY_HEX=$(printf '%s' "${SECRET#whsec_}" | base64 --decode | xxd -p -c 256) printf '%s' "$WEBHOOK_ID.$WEBHOOK_TIMESTAMP.$BODY" \ | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary \ | base64 # Compare the output with the value after "v1," in the webhook-signature header. ``` > **Compare in constant time.** Use `crypto.timingSafeEqual`, `hmac.compare_digest`, `hmac.Equal`, `hash_equals` or `Rack::Utils.secure_compare`, never `==`. An ordinary comparison stops at the first different character, and the time it takes can leak the right signature one character at a time. ## Keep the raw body Almost every failed check is a body that changed before it was verified. A framework that parses JSON and you then turn back into a string gives different bytes: key order, spacing and escaped characters all move. Verify the bytes as they arrived, then parse them. ```js title="Express" import express from 'express'; const app = express(); // Mount the webhook route with express.raw BEFORE any app.use(express.json()), // or express.json() will have read and parsed the body first. app.post('/bluebot/webhooks', express.raw({ type: 'application/json' }), (req, res) => { const payload = req.body; // a Buffer of the exact bytes // verify(payload, req.headers, secret), then JSON.parse(payload.toString('utf8')) res.sendStatus(204); }); app.use(express.json()); // the rest of your app ``` ```ts title="Next.js" // app/api/bluebot/webhooks/route.ts (App Router route handler) export async function POST(request: Request) { const payload = await request.text(); // read it once, as text; never request.json() first // verify(payload, request.headers, secret), then JSON.parse(payload) return new Response(null, { status: 204 }); } // Pages Router (pages/api/...) parses the body unless you turn that off: // export const config = { api: { bodyParser: false } }; // then read the stream into a Buffer yourself. ``` ```python title="FastAPI" from fastapi import FastAPI, Request, Response app = FastAPI() @app.post("/bluebot/webhooks") async def bluebot_webhook(request: Request): payload = await request.body() # bytes, as sent # Don't declare a Pydantic model parameter for the body: FastAPI would parse it first. # verify(payload, request.headers, secret) return Response(status_code=204) ``` ```python title="Flask" from flask import Flask, request app = Flask(__name__) @app.post("/bluebot/webhooks") def bluebot_webhook(): payload = request.get_data() # bytes, as sent; call it before request.json # verify(payload, request.headers, secret) return "", 204 ``` ```ruby title="Rails" class BluebotWebhooksController < ActionController::API # ActionController::API has no CSRF check. In an ActionController::Base controller, add: # skip_before_action :verify_authenticity_token def create payload = request.raw_post # the body, as sent headers = { "webhook-id" => request.headers["webhook-id"], "webhook-timestamp" => request.headers["webhook-timestamp"], "webhook-signature" => request.headers["webhook-signature"], } # verify(payload, headers, ENV.fetch("BLUEBOT_WEBHOOK_SECRET")), then JSON.parse(payload) head :no_content end end ``` ```php title="Laravel" withMiddleware(fn ($m) => $m->validateCsrfTokens(except: ['bluebot/webhooks'])) // Laravel 10, App\Http\Middleware\VerifyCsrfToken: protected $except = ['bluebot/webhooks']; use Illuminate\Http\Request; Route::post('/bluebot/webhooks', function (Request $request) { $payload = $request->getContent(); // the body, as sent $headers = [ 'webhook-id' => $request->header('webhook-id'), 'webhook-timestamp' => $request->header('webhook-timestamp'), 'webhook-signature' => $request->header('webhook-signature'), ]; // verify($payload, $headers, env('BLUEBOT_WEBHOOK_SECRET')), then json_decode($payload, true) return response()->noContent(); }); ``` ```go title="Go net/http" func handler(w http.ResponseWriter, r *http.Request) { // Cap the size, then read the body once. r.Body can't be read twice. body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { http.Error(w, "too large", http.StatusRequestEntityTooLarge) return } // verify(secret, r.Header, body), then json.Unmarshal(body, &event) w.WriteHeader(http.StatusNoContent) } ``` Other places a body changes on the way: - **A proxy or gateway that rewrites JSON.** Some API gateways, edge functions and logging middleware re-encode bodies. Verify in the first code that sees the request, or pass the original body through untouched. - **Character encoding.** Read the body as bytes, or as UTF-8 text. Message words often carry accents, emoji and curly quotes, and a body decoded as Latin-1 won't match. - **Header names.** HTTP header names are case-insensitive, and frameworks change their case: Rack and PHP's `$_SERVER` turn `webhook-id` into `HTTP_WEBHOOK_ID`. Look them up the way your framework expects. ## When a check fails | You got | It means | Do next | | ------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | No signature matched, every time | The body changed before you verified it, or the secret is wrong. | Log the length of the body you verify and compare it with the request's `Content-Length`. Check you're using this webhook's secret: each webhook has its own. | | No signature matched, only after a rotation | You still verify with the old secret, and its 24 hours are up. | Put the new secret in your environment. [Rotate secrets](https://usebluebot.com/docs/webhooks/rotate-secrets) | | Timestamp out of the window | Your server's clock is off by more than 5 minutes, or somebody sent you an old post. | Sync the clock. If it's right, the refusal did its job. | | A header is missing | The post didn't come from BlueBot, or a proxy dropped the headers. | Pass `webhook-id`, `webhook-timestamp` and `webhook-signature` through any proxy in front of your app. | Answer a failed check with `400` and do nothing else. A genuine post that failed because of a bug on your side is retried by BlueBot for 72 hours, so once you fix the bug, those events still arrive. [Retries and the deliveries log](https://usebluebot.com/docs/webhooks/retries) ## Next - [Test webhooks](https://usebluebot.com/docs/webhooks/testing): Send yourself a signed test event and check your handler with it. - [Rotate secrets](https://usebluebot.com/docs/webhooks/rotate-secrets): Change the secret without missing a delivery. --- Source: https://usebluebot.com/docs/webhooks/retries # Retries and the deliveries log > What counts as a delivered webhook, when BlueBot tries a failed one again, why the same event can arrive twice or out of order, and how to read every attempt in the deliveries log. Your server will be down sometimes: a deploy, an expired certificate, a database that won't answer. BlueBot keeps every event it couldn't deliver and tries again for 72 hours, so a short outage on your side loses nothing. In exchange, your handler has to cope with an event arriving more than once and not always in order. This page covers both. ## What counts as delivered A delivery succeeds when your address answers with any `2xx` status within 10 seconds. Everything else is a failure, and BlueBot tries again: - Any other status: `3xx` (BlueBot never follows a redirect), `4xx` or `5xx`. - No answer within 10 seconds. - A connection that's refused or reset, a certificate that doesn't check out, or a name that doesn't resolve. - A name that resolves to a private, loopback or reserved address, which BlueBot never posts to. One status is different: `410 Gone`. It tells BlueBot the address will never take events again, and BlueBot deletes the webhook rather than retrying. Use it only when you mean it. ## The retry schedule After a failed try, BlueBot waits, then tries the same delivery again: | After the failed try | BlueBot waits | So the next try is about | | -------------------- | ------------- | ------------------------ | | 1st | 1 minute | 1 minute after the event | | 2nd | 5 minutes | 6 minutes after | | 3rd | 30 minutes | 36 minutes after | | 4th | 2 hours | 2.5 hours after | | 5th | 6 hours | 8.5 hours after | | 6th | 12 hours | 20.5 hours after | | 7th | 24 hours | 44.5 hours after | | 8th | 24 hours | 68.5 hours after | That's nine tries in all. A delivery that has failed every one of them by the time it's 72 hours old ends as `gave_up`. It stays in the deliveries log for 30 days, and you can [replay](https://usebluebot.com/docs/webhooks/replay) it any time in those 30 days. Each try is signed when it's made, with a fresh `webhook-timestamp`, so a retry two days later still passes the 5-minute check in [Verify signatures](https://usebluebot.com/docs/webhooks/verify). ## When a webhook keeps failing While its deliveries fail, a webhook's `status` is `failing`, and `failing_since` says since when. When it has gone 72 hours with no `2xx` at all, BlueBot pauses it (`status: "paused"`, `pause_reason: "failing"`) and emails you once. A paused webhook isn't posted to, but BlueBot keeps recording the events it would have received. Once your address works again, resume it in **Developers → Webhooks** or with the API, and BlueBot sends everything that waited from the last 72 hours. Anything older is marked `gave_up`, and the [event feed](https://usebluebot.com/docs/api/events/list-events) still has it for 30 days. ```bash curl -X PATCH "https://usebluebot.com/api/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "paused": false }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const webhook = await client.webhooks.update('wh_1Hq2LxW9cVbN3mKpRtYs0a', { paused: false, }); console.log(webhook); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) webhook = client.webhooks.update("wh_1Hq2LxW9cVbN3mKpRtYs0a", paused=False) print(webhook) ``` Changing a failing webhook's `url` clears `failing_since`, since the old failures were about the old address. ## Getting the same event twice Webhooks are delivered at least once, not exactly once. You'll see an event again when: - Your handler did the work but answered after 10 seconds, or the connection dropped before BlueBot read your answer. BlueBot counts that as a failure and retries. - You or a teammate [replayed](https://usebluebot.com/docs/webhooks/replay) a delivery. Every copy of an event carries the same `webhook-id` header, which is the event's `id` in the body. Record the ids you've handled and skip one you've seen. Two rules make this safe: 1. **Record the id when you finish, not when you start**, ideally in the same database transaction as the work. Then an event whose handling failed halfway isn't skipped when it comes again. 2. **Keep ids at least as long as BlueBot might resend.** Retries stop after 72 hours, but a replay can come any time in the 30 days deliveries are kept. Thirty days covers both. ```ts title="TypeScript" // Postgres: CREATE TABLE handled_events (id text PRIMARY KEY, handled_at timestamptz NOT NULL DEFAULT now()); import { sql } from './db'; export async function handleOnce(event: { id: string }, work: (tx: typeof sql) => Promise) { await sql.begin(async (tx) => { const [seen] = await tx`SELECT 1 FROM handled_events WHERE id = ${event.id} FOR UPDATE`; if (seen) return; // already handled: answer 2xx and move on await work(tx); await tx`INSERT INTO handled_events (id) VALUES (${event.id})`; }); } ``` ```python title="Python" # Postgres: CREATE TABLE handled_events (id text PRIMARY KEY, handled_at timestamptz NOT NULL DEFAULT now()); def handle_once(conn, event, work): with conn.transaction(): seen = conn.execute("SELECT 1 FROM handled_events WHERE id = %s FOR UPDATE", (event.id,)).fetchone() if seen: return # already handled: answer 2xx and move on work(conn) conn.execute("INSERT INTO handled_events (id) VALUES (%s)", (event.id,)) ``` Answer `2xx` for a duplicate. An error tells BlueBot to try again, which only sends it a third time. ## Order isn't guaranteed Events usually arrive in the order they happened, but a retry can arrive after a later event. Say `message.sent` for a text to Maria fails once because your server was restarting, and a second later `message.delivered` goes through. You receive `delivered`, then, a minute later, `sent`. Don't let an older event undo a newer one: - **Messages only move forward.** A text you sent goes `scheduled`, `queued`, `sending`, then `sent`, `delivered` and `read`. `failed`, `unconfirmed`, `expired` and `canceled` are ends of the road, and `sent`, `delivered` and `read` only move forward among themselves. Keep the state you have unless the event's is further along. - **Compare times.** Every event has a `timestamp`, and a message has a time for each state (`sent_at`, `delivered_at`, `read_at`, `failed_at`). Ignore an event older than what you stored. - **Ask when it matters.** `data` is the record as it was when the event happened. When you need the state right now, read it: `GET /v1/messages/{id}`. ```ts title="Keep the furthest state" const RANK = { scheduled: 0, queued: 1, sending: 2, sent: 3, delivered: 4, read: 5 } as const; function isNewer(stored: keyof typeof RANK, incoming: string): boolean { if (!(incoming in RANK)) return true; // failed, unconfirmed, expired, canceled: always take it return RANK[incoming as keyof typeof RANK] > RANK[stored]; } ``` ## The deliveries log Every attempt to deliver an event to a webhook is a delivery, kept for 30 days. Open a webhook in **Developers → Webhooks** to see its deliveries with their status, the time each try took and the next try, or list them with the API. Filter by `state` to find the ones that need you: ```bash curl "https://usebluebot.com/api/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a/deliveries?state=gave_up&limit=50" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const page = await client.webhooks.deliveries.list('wh_1Hq2LxW9cVbN3mKpRtYs0a', { state: 'gave_up', limit: 50, }); console.log(page.data); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) page = client.webhooks.deliveries.list( "wh_1Hq2LxW9cVbN3mKpRtYs0a", state="gave_up", limit=50, ) print(page.data) ``` The answer, `200`: ```json { "object": "list", "data": [ { "id": "dlv_1Hq2LxW9cVbN3mKpRtYs0a", "object": "webhook_delivery", "webhook_id": "wh_1Hq2LxW9cVbN3mKpRtYs0a", "event_id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "event_type": "message.received", "test": false, "state": "gave_up", "attempts": 9, "last_status": 502, "last_error": null, "last_duration_ms": 214, "next_attempt_at": null, "delivered_at": null, "replay_of": null, "created_at": "2026-11-10T14:02:11.000Z" } ], "has_more": false, "next_cursor": null } ``` | Field | What it is | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `state` | `pending` (trying, or waiting to try again), `delivered`, `gave_up` after the last retry, or `dropped` (let go without delivering, such as after a `410`). | | `attempts` | How many tries so far. | | `last_status` | The HTTP status of the last answer, or null when there was none. | | `last_error` | Why the last try got no answer at all: a timeout, a refused connection, a name that doesn't resolve. | | `last_duration_ms` | How long the last try took. Close to 10,000 means your handler is too slow; see [Answer fast](https://usebluebot.com/docs/webhooks#answer-fast). | | `last_response_body` | The first 2 KB of what your server answered last, or null when it answered nothing. | | `next_attempt_at` | When it's tried next, while `pending`. | | `test` | `true` for an example sent with **Send a test event**. | | `replay_of` | For a replay, the delivery it repeats. | BlueBot keeps the status of each answer, how long it took and the first 2 KB of what your server answered, so you can see why a delivery failed. A delivery goes with its event after 30 days. | You got | It means | Do next | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `last_status: 401` or `403` | Your server refused BlueBot, often an auth layer or firewall in front of the handler. | Let the path through without a session, and rely on the [signature](https://usebluebot.com/docs/webhooks/verify) instead. | | `last_status: 400` | Your handler refused the post, usually a signature check that failed. | See [When a check fails](https://usebluebot.com/docs/webhooks/verify#when-a-check-fails). | | `last_status: 404` or `405` | Nothing answers `POST` at that path. | Check the address and that the route takes `POST`. | | `last_status: 301`, `302` or `308` | Your server redirected, and BlueBot doesn't follow redirects. | Use the final address, often the same one with `https://` or a trailing slash. | | `last_status: 500` to `504` | Your handler or its host failed. | Read your own logs at the time of the attempt. | | `last_error` says no answer within 10 seconds | Your handler is too slow. | Answer first and do the work afterwards. | | `last_error` says the name doesn't resolve, or a private network | The DNS name is wrong, or points at an internal address. | Use a public name that resolves to a public address. | ## Catch up after an outage When your server was down long enough for deliveries to give up, you have two ways back: - **Replay what gave up.** List the webhook's `gave_up` deliveries and replay each one. [Replay deliveries](https://usebluebot.com/docs/webhooks/replay) has a script. - **Read the event feed.** `GET /v1/events` has every event from the last 30 days, with `since` to start where you stopped. Handle each one through the same code your webhook uses, and the ids you already have are skipped. ## Next - [Replay deliveries](https://usebluebot.com/docs/webhooks/replay): Send a delivery again, one at a time or in bulk. - [Test webhooks](https://usebluebot.com/docs/webhooks/testing): Try your handler against signed events before you go live. --- Source: https://usebluebot.com/docs/webhooks/replay # Replay deliveries > Send an event to your webhook again, signed afresh, after you've fixed whatever made it fail. One delivery from the dashboard or the API, or every delivery that gave up with a short script. A replay sends an event to the same webhook again, as a new delivery. Use it after you've fixed a bug or brought a server back: the events that failed in the meantime arrive as if for the first time, and your handler deals with them like any other. Ravi's appointment app was down for four days while he moved hosts. BlueBot retried every reply his clients sent for 72 hours, then gave up on them. Once the new host answered, Ravi listed the deliveries that gave up and replayed them, and his app caught up on 37 replies without anyone texting twice. ## What a replay does - It makes a new delivery of the same event to the same webhook, and its `replay_of` names the delivery it repeats. - The event is unchanged: the same `id`, the same `webhook-id` header and the same body, with `data` as it was when the event happened. - It's signed afresh with the webhook's current secret and a new `webhook-timestamp`, so it passes your signature check even weeks later. - It goes out within a minute, and if it fails it's retried on the [usual schedule](https://usebluebot.com/docs/webhooks/retries#the-retry-schedule). - Any delivery from the last 30 days can be replayed, whatever its state: one that gave up, one still retrying, or one that was delivered. - A replay to a paused webhook waits until you resume the webhook. > **Replays and your duplicate check.** A replay carries the same `webhook-id` as the original. If your handler skips ids it has already handled, as it should, a replay of an event you finished is skipped too. That's what you want after an outage. To run an event through your handler a second time on purpose, take its id out of your store first. ## Replay one delivery In the dashboard, open the webhook in **Developers → Webhooks**, find the delivery in its list, and press **Send again**. With the API, find the delivery's id in the [deliveries log](https://usebluebot.com/docs/webhooks/retries#the-deliveries-log), then: ```bash curl -X POST "https://usebluebot.com/api/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a/deliveries/dlv_1Hq2LxW9cVbN3mKpRtYs0a/replay" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Idempotency-Key: replay-dlv_1Hq2LxW9cVbN3mKpRtYs0a" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const delivery = await client.webhooks.deliveries.replay('wh_1Hq2LxW9cVbN3mKpRtYs0a', 'dlv_1Hq2LxW9cVbN3mKpRtYs0a', { idempotencyKey: 'replay-dlv_1Hq2LxW9cVbN3mKpRtYs0a' }); console.log(delivery); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) delivery = client.webhooks.deliveries.replay( "wh_1Hq2LxW9cVbN3mKpRtYs0a", "dlv_1Hq2LxW9cVbN3mKpRtYs0a", idempotency_key="replay-dlv_1Hq2LxW9cVbN3mKpRtYs0a", ) print(delivery) ``` The answer, `202`: ```json { "id": "dlv_2Bf8QzD4kJmA6nXwEuTr1c", "object": "webhook_delivery", "webhook_id": "wh_1Hq2LxW9cVbN3mKpRtYs0a", "event_id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "event_type": "message.received", "test": false, "state": "pending", "attempts": 0, "last_status": null, "last_error": null, "last_duration_ms": null, "next_attempt_at": "2026-11-13T19:41:00.000Z", "delivered_at": null, "replay_of": "dlv_1Hq2LxW9cVbN3mKpRtYs0a", "created_at": "2026-11-13T19:41:00.000Z" } ``` The answer is the new delivery, waiting to go. Read it again with the deliveries list to see how it went. | You got | It means | Do next | | ----------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | `202` with `state: "pending"` | The replay is queued and goes within a minute. | Watch your handler's logs, or list the webhook's deliveries and look for this id. | | `404 not_found` | The delivery isn't on that webhook, or it's older than 30 days. | Check both ids. For an older event, there's nothing left to send. | | `409 idempotency_conflict` | You used this `Idempotency-Key` for a different request in the last 24 hours. | Use one key per delivery, such as `replay-`. | The `Idempotency-Key` matters here: without one, a script that runs twice replays every delivery twice. ## Replay everything that gave up After an outage, list the webhook's deliveries that gave up and replay each one. The SDKs page through the list for you, and the key per delivery means running the script again within 24 hours replays nothing twice. ```ts title="TypeScript" import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const webhookId = 'wh_1Hq2LxW9cVbN3mKpRtYs0a'; let count = 0; for await (const delivery of client.webhooks.deliveries.list(webhookId, { state: 'gave_up' })) { await client.webhooks.deliveries.replay(webhookId, delivery.id, { idempotencyKey: `replay-${delivery.id}` }); count += 1; } console.log(`Replayed ${count} deliveries.`); ``` ```python title="Python" import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) webhook_id = "wh_1Hq2LxW9cVbN3mKpRtYs0a" count = 0 for delivery in client.webhooks.deliveries.list(webhook_id, state="gave_up"): client.webhooks.deliveries.replay(webhook_id, delivery.id, idempotency_key=f"replay-{delivery.id}") count += 1 print(f"Replayed {count} deliveries.") ``` Each replay is one request, and a key may make 120 a minute. The SDKs wait and try again when BlueBot answers `429 rate_limited`, so a long list takes a few minutes. [Rate limits](https://usebluebot.com/docs/concepts/rate-limits) Replays you send together arrive close together and not always in the order the events happened. If your handler cares about order, see [Order isn't guaranteed](https://usebluebot.com/docs/webhooks/retries#order-isnt-guaranteed). ## When a replay isn't the right tool - **The event is older than 30 days.** Deliveries and events are kept 30 days, so there's nothing left to replay. - **You want the events at a different address.** A replay only goes to the webhook it was made for. Read the events with `GET /v1/events` and post them where you need them, or point a new webhook at the new address for what happens from now on. - **You want today's state, not the event's.** `data` is the record as it was. To act on a text's state now, read the text: `GET /v1/messages/{id}`. ## Next - [Retries and the deliveries log](https://usebluebot.com/docs/webhooks/retries): When BlueBot retries, and how to read every attempt. - [Rotate secrets](https://usebluebot.com/docs/webhooks/rotate-secrets): Change a webhook's secret without missing a delivery. --- Source: https://usebluebot.com/docs/webhooks/rotate-secrets # Rotate secrets > Give a webhook a new signing secret without missing a delivery. For 24 hours BlueBot signs with both the old and the new one, so you can deploy the change at your own pace. Each webhook has its own signing secret, `whsec_…`, shown once when you make it. Rotate it when it may have leaked (pasted into a chat, committed to a repository, known to somebody who left), when you've lost it, or on a schedule your security policy sets. ## What rotating does - BlueBot makes a new secret and shows it only once. The API returns it in its answer, and the dashboard shows it on screen. - For the next **24 hours**, every delivery is signed with both secrets. `webhook-signature` carries two values, the new secret's first: `v1, v1,`. - The webhook's `previous_secret_expires_at` says when the old secret stops signing. After that, only the new one does. - Nothing else changes: the address, the events and the deliveries waiting are the same. Because each delivery carries a signature from each secret during those 24 hours, a handler that knows only the old secret and a handler that knows only the new one both accept it. That's what makes the switch safe: you never need both secrets in your code. ## Rotate without missing a delivery ### Rotate In **Developers → Webhooks**, open the webhook and press **Rotate secret**, or call the API: ```bash curl -X POST "https://usebluebot.com/api/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a/rotate-secret" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Idempotency-Key: rotate-wh_1Hq2LxW9cVbN3mKpRtYs0a-2026-11" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const webhook = await client.webhooks.rotateSecret('wh_1Hq2LxW9cVbN3mKpRtYs0a', { idempotencyKey: 'rotate-wh_1Hq2LxW9cVbN3mKpRtYs0a-2026-11' }); console.log(webhook); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) webhook = client.webhooks.rotate_secret( "wh_1Hq2LxW9cVbN3mKpRtYs0a", idempotency_key="rotate-wh_1Hq2LxW9cVbN3mKpRtYs0a-2026-11", ) print(webhook) ``` The answer, `200`: ```json { "id": "wh_1Hq2LxW9cVbN3mKpRtYs0a", "object": "webhook", "url": "https://bookings.example.com/bluebot/webhooks", "status": "active", "previous_secret_expires_at": "2026-11-14T19:41:00.000Z", "secret": "whsec_Q2nVb8mLx0RkT4cW9yHj3PdZs6EuAa1F" } ``` Copy `secret`. It isn't shown again. ### Put the new secret in your environment Set `BLUEBOT_WEBHOOK_SECRET` to the new value wherever your handler runs: your host's environment settings, your secrets manager, every region and every worker that verifies. ### Deploy Deploy as you normally would. Servers still on the old secret and servers already on the new one both accept every delivery while the window is open, so a rolling deploy is fine. ### Check it Press **Send a test event** on the webhook, or call `POST /v1/webhooks/{id}/test`. Your handler answers `2xx` with the new secret in place. | You got | It means | Do next | | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `last_status: 204` (or any `2xx`) on the test delivery | Your handler verifies with a secret BlueBot signs with. | Make sure every server has the new secret before `previous_secret_expires_at`. | | `last_status: 400` | Your handler rejected the signature. The new value didn't reach it, or it was pasted with a space or cut short. | Copy the secret again from where you saved it. If it's lost, rotate again, but read the warning below first. | ### Let the old secret go When `previous_secret_expires_at` passes, BlueBot signs with the new secret only. Delete the old value from anywhere you kept it. > **Rotate once, then wait.** Rotating again inside the 24 hours ends the original secret at once: the new pair is the second and the third secret, and the first stops signing straight away. A server still on the first secret then rejects every delivery. If you need to rotate twice, finish the first switch before you start the second. Sending an `Idempotency-Key` stops a retried request from rotating twice by accident. ## If the secret leaked Anybody with the old secret can sign posts your handler accepts for as long as your handler trusts that secret. BlueBot's 24-hour window only decides which secrets BlueBot signs with; your handler decides which it accepts. So after a leak, put the new secret in place as soon as you can. Once your handler verifies with the new secret only, a post signed with the leaked one fails your check, even though BlueBot still adds its own old-secret signature for the rest of the day. Then look for posts that arrived during the leak and don't match an event BlueBot sent: every genuine event is in the [event feed](https://usebluebot.com/docs/api/events/list-events) under its `id`. ## Lost the secret? BlueBot shows a secret once and can't show it again. Rotate to get a new one. If nothing verifies with the old secret any more, the 24-hour window doesn't matter to you, and you can deploy the new secret whenever it's ready; deliveries that fail meanwhile are [retried for 72 hours](https://usebluebot.com/docs/webhooks/retries). ## Next - [Verify signatures](https://usebluebot.com/docs/webhooks/verify): How the signature is made and checked. - [Test webhooks](https://usebluebot.com/docs/webhooks/testing): Send a signed test event to your handler. --- Source: https://usebluebot.com/docs/webhooks/testing # Test webhooks > Three ways to check your webhook handler before you go live. Send a signed test event to your address, forward your account's real events to localhost with bluebot listen, or open a tunnel to your laptop. There are three ways to try a handler. Each suits a different stage of building one: | Way | What reaches your handler | Best for | | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | [Send a test event](#send-a-test-event) | A signed example of any event type, posted to a webhook's real address | Checking a deployed handler, and every event type, without sending a text | | [`bluebot listen`](#forward-events-to-localhost-with-bluebot-listen) | Your account's real events, forwarded to `localhost` and signed with a local secret | Writing a handler on your laptop, with no tunnel and no webhook to set up | | [A tunnel](#use-a-tunnel) | Real deliveries from a real webhook, with its retries and its secret | The last check before you deploy, end to end | None of them needs a stranger's phone. When an event needs a real text, text yourself. ## Send a test event Every webhook has **Send a test event** in **Developers → Webhooks**: pick a type and BlueBot posts an example of it to the webhook's address, signed with the webhook's secret like any delivery. The same thing through the API: ```bash curl -X POST "https://usebluebot.com/api/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a/test" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "message.received" }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const delivery = await client.webhooks.test('wh_1Hq2LxW9cVbN3mKpRtYs0a', { type: 'message.received', }); console.log(delivery); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) delivery = client.webhooks.test("wh_1Hq2LxW9cVbN3mKpRtYs0a", type="message.received") print(delivery) ``` BlueBot answers once it has tried, with the delivery, so you see what your handler said in the same request: The answer, `200`: ```json { "id": "dlv_3Lp3VsC7hGqE5tZyKbWn2d", "object": "webhook_delivery", "webhook_id": "wh_1Hq2LxW9cVbN3mKpRtYs0a", "event_id": "evt_3Lp3VsC7hGqE5tZyKbWn2d", "event_type": "message.received", "test": true, "state": "delivered", "attempts": 1, "last_status": 204, "last_error": null, "last_duration_ms": 87, "next_attempt_at": null, "delivered_at": "2026-11-13T19:41:00.000Z", "replay_of": null, "created_at": "2026-11-13T19:41:00.000Z" } ``` | You got | It means | Do next | | ------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | | `state: "delivered"` and a `2xx` in `last_status` | Your handler verified the post and answered. | Try the other types your handler takes. | | `state: "pending"` with `last_status: 400` | Your handler rejected it, usually the signature. | Check the secret and the raw body. [When a check fails](https://usebluebot.com/docs/webhooks/verify#when-a-check-fails) | | `state: "pending"` with `last_status: 500` | Your handler threw. | Read your logs. The test is retried like any delivery, so once you fix the handler it arrives. | | `state: "pending"` and `last_error` set | BlueBot couldn't reach the address or got no answer in 10 seconds. | Check the address is public and quick to answer. | | `state: "pending"`, `attempts: 0` | The webhook is paused, so nothing was sent. | Resume it, then test again. | What to know about test events: - The body is the [event catalog](https://usebluebot.com/docs/webhooks/events)'s example for that type inside the same envelope a real event has. Its ids are the docs' example ids (`msg_1Hq2LxW9cVbN3mKpRtYs0a`, `ct_1Hq2LxW9cVbN3mKpRtYs0a`), so a handler that looks the record up gets `404 not_found`. Handle that the way you would in production. - The body says it is a test: it has `"test": true` beside `type`, which a real event never has. Skip writing anything when you see it, or let it run through in a staging copy of your handler. The delivery also shows `test: true` in the deliveries log, and the event never appears in the event feed. - A test goes to the one webhook you chose, even if it doesn't subscribe to that type. To try every type your handler takes in one go: ```ts title="TypeScript" import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const webhookId = 'wh_1Hq2LxW9cVbN3mKpRtYs0a'; for (const type of ['message.received', 'message.sent', 'message.failed', 'contact.stopped'] as const) { const delivery = await client.webhooks.test(webhookId, { type }); console.log(type.padEnd(18), delivery.state); // "delivered", or "pending" while it's retried } ``` ```python title="Python" import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) webhook_id = "wh_1Hq2LxW9cVbN3mKpRtYs0a" for event_type in ["message.received", "message.sent", "message.failed", "contact.stopped"]: delivery = client.webhooks.test(webhook_id, type=event_type) print(event_type.ljust(18), delivery.state) # "delivered", or "pending" while it's retried ``` ## Forward events to localhost with bluebot listen `bluebot listen` lets you write a handler on your laptop with no tunnel and no webhook. It reads your account's event feed every couple of seconds and posts each new event to the address you give it, signed with a secret of its own, exactly as a webhook delivery is. ### Install the CLI and sign in ```bash npm install -g @usebluebot/cli bluebot login ``` `bluebot login` asks for an API key. A key with `messages:read` is enough to listen, and `messages:send` lets you text yourself from the CLI too. [CLI](https://usebluebot.com/docs/cli) ### Start listening ```bash bluebot listen --forward-to http://localhost:3000/bluebot/webhooks ``` ```text title="Output" Listening for events on dana@groomers.example. Press Ctrl+C to stop. Forwarding to http://localhost:3000/bluebot/webhooks Signing secret for local testing: whsec_LcL9pQ2wErT7yUiO1aSdF3gH5jK8zX0c ``` Set `BLUEBOT_WEBHOOK_SECRET` in your local environment to the secret it printed. It stays the same each time you run `bluebot listen` on this computer, so you set it once. ### Make something happen Text your BlueBot number from your phone, or text yourself from the CLI in another terminal: ```bash bluebot send +15555550123 "Testing my handler" ``` Each event is forwarded as it comes, and the CLI prints what your handler answered: ```text title="Output" 19:41:02 message.queued evt_6Tn2xL9mQ0cR4bW8kV1sHd → 204 (12 ms) 19:41:05 message.sent evt_7Uo3yM0nR1dS5cX9lW2tIe → 204 (9 ms) 19:41:31 message.received evt_8Vp4zN1oS2eT6dY0mX3uJf → 500 (41 ms) ``` Options: | Option | What it does | | -------------------- | ------------------------------------------------------------------------------------------- | | `--forward-to ` | Where to post each event. Any address your computer can reach, `http://localhost` included. | | `--events ` | Only these types, comma-separated: `--events message.received,message.failed`. | | `--json` | Print each event as one line of JSON instead of the summary. | What it doesn't do: - It forwards only events that happen after it starts. - It doesn't retry. When your handler errors, it prints the status and moves on; send the event again by making it happen again. - It doesn't forward test events from **Send a test event**, which never reach the event feed. - Its secret is for your laptop only. Your deployed handler verifies with the webhook's own secret. ## Use a tunnel A tunnel gives your laptop a public `https://` address, so a real webhook can deliver to it. Use it for the last check before you deploy, with real deliveries and retries and the webhook's own secret. ### Open a tunnel to your local port With [ngrok](https://ngrok.com): ```bash ngrok http 3000 ``` Or with Cloudflare's [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/), which needs no account: ```bash cloudflared tunnel --url http://localhost:3000 ``` Each prints an `https://` address, such as `https://example-words.trycloudflare.com`. ### Make a webhook to it ```bash curl -X POST "https://usebluebot.com/api/v1/webhooks" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: dev-tunnel-2026-11-13" \ -d '{ "url": "https://example-words.trycloudflare.com/bluebot/webhooks", "events": [ "message.received", "message.sent", "message.failed" ], "description": "Dev tunnel (delete when done)" }' ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const webhook = await client.webhooks.create({ url: 'https://example-words.trycloudflare.com/bluebot/webhooks', events: ['message.received', 'message.sent', 'message.failed'], description: 'Dev tunnel (delete when done)', }, { idempotencyKey: 'dev-tunnel-2026-11-13' }); console.log(webhook); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) webhook = client.webhooks.create( url="https://example-words.trycloudflare.com/bluebot/webhooks", events=["message.received", "message.sent", "message.failed"], description="Dev tunnel (delete when done)", idempotency_key="dev-tunnel-2026-11-13", ) print(webhook) ``` Put the `secret` from the answer in your local `BLUEBOT_WEBHOOK_SECRET`. ### Send a test event, then a real text Send a test event to the new webhook, then text your BlueBot number from your phone and watch the real `message.received` arrive. ### Delete the webhook when you're done ```bash curl -X DELETE "https://usebluebot.com/api/v1/webhooks/wh_2Bf8QzD4kJmA6nXwEuTr1c" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" ``` ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); const result = await client.webhooks.delete('wh_2Bf8QzD4kJmA6nXwEuTr1c'); console.log(result); ``` ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) result = client.webhooks.delete("wh_2Bf8QzD4kJmA6nXwEuTr1c") print(result) ``` > **Don't leave a tunnel webhook behind.** A free tunnel's address changes every time you start it. A webhook left pointing at a dead tunnel fails, is retried for 72 hours, then pauses and emails you. Delete it when you stop, and make a new one next time. ## Next - [Verify signatures](https://usebluebot.com/docs/webhooks/verify): What your handler checks on every post. - [Event catalog](https://usebluebot.com/docs/webhooks/events): Every event type with a full example. - [CLI](https://usebluebot.com/docs/cli): Every `bluebot` command. --- Source: https://usebluebot.com/docs/webhooks/events # Event catalog > Every webhook event BlueBot sends, when it fires, and a full example payload for each. Every event BlueBot sends to a webhook, with a full example of each. Each one is posted as JSON with the same envelope: `id` (the event, `evt_…`, also sent as the `webhook-id` header, the same on every retry), `object: "event"`, `type`, `api_version` (the payload version your endpoint is pinned to, `2026-11-13` today), `timestamp`, and `data`. Message events carry the whole message as `GET /v1/messages/{id}` returns it, `metadata` included, so you can match an event to the request that caused it. | Event | When it fires | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | [`message.queued`](https://usebluebot.com/docs/webhooks/events#messagequeued) | A text was accepted and is waiting for the Mac, or a scheduled text became due. | | [`message.sent`](https://usebluebot.com/docs/webhooks/events#messagesent) | Messages on the Mac showed the text sent. Also fires for a text you typed by hand on your Mac or iPhone, with `source: "mac"`. | | [`message.delivered`](https://usebluebot.com/docs/webhooks/events#messagedelivered) | iMessage showed the text delivered to the person’s device. | | [`message.read`](https://usebluebot.com/docs/webhooks/events#messageread) | The person read the text. Only for people who share read receipts. | | [`message.failed`](https://usebluebot.com/docs/webhooks/events#messagefailed) | Messages couldn’t send the text, or it was an iMessage-only text to somebody not on iMessage. | | [`message.unconfirmed`](https://usebluebot.com/docs/webhooks/events#messageunconfirmed) | The Mac took the text and didn’t say whether it went before its 3-minute lease ran out. | | [`message.expired`](https://usebluebot.com/docs/webhooks/events#messageexpired) | The text was still waiting when its `expires_at` passed, so it will never go. | | [`message.canceled`](https://usebluebot.com/docs/webhooks/events#messagecanceled) | A queued or scheduled text was canceled: by you, because the person is on your stop list, or when its sequence or broadcast stopped. | | [`message.received`](https://usebluebot.com/docs/webhooks/events#messagereceived) | Somebody texted you. Pictures and voice memos are on the message as files. | | [`message.edited`](https://usebluebot.com/docs/webhooks/events#messageedited) | A text was edited in Messages, by you or by the other person. | | [`message.unsent`](https://usebluebot.com/docs/webhooks/events#messageunsent) | A text was unsent in Messages. Its words are gone from BlueBot too. | | [`reaction.added`](https://usebluebot.com/docs/webhooks/events#reactionadded) | Somebody added a tapback (love, like, laugh and so on) to a text, or you did from your Mac or iPhone. | | [`reaction.removed`](https://usebluebot.com/docs/webhooks/events#reactionremoved) | A tapback was taken back. | | [`chat.created`](https://usebluebot.com/docs/webhooks/events#chatcreated) | The Mac saw a chat BlueBot didn’t know: a new group, or a first text with somebody. | | [`contact.created`](https://usebluebot.com/docs/webhooks/events#contactcreated) | A person was added: by a send to somebody new, a text from somebody new, an import, the API or the dashboard. | | [`contact.stopped`](https://usebluebot.com/docs/webhooks/events#contactstopped) | A person went on your stop list. Their queued texts were canceled. | | [`contact.unstopped`](https://usebluebot.com/docs/webhooks/events#contactunstopped) | A person came off your stop list: they texted START, or you took them off. | | [`sequence.completed`](https://usebluebot.com/docs/webhooks/events#sequencecompleted) | A person reached the end of a sequence, or it stopped early because they replied or stopped. | | [`broadcast.completed`](https://usebluebot.com/docs/webhooks/events#broadcastcompleted) | Every text in a broadcast has settled: sent, failed, skipped, expired or canceled. | | [`automation.matched`](https://usebluebot.com/docs/webhooks/events#automationmatched) | An incoming text matched a keyword rule that has the “Send a webhook event” action. | | [`device.online`](https://usebluebot.com/docs/webhooks/events#deviceonline) | The Mac checked in after being offline. Held texts start going. | | [`device.offline`](https://usebluebot.com/docs/webhooks/events#deviceoffline) | The Mac hasn’t checked in for 2 minutes. Texts wait with `hold.reason: "device_offline"`. | | [`device.check_failed`](https://usebluebot.com/docs/webhooks/events#devicecheck_failed) | One of the Mac’s setup checks turned `needs_you`, such as Messages signing out or Full Disk Access being turned off. | Subscribe to the events you need when you make the webhook; an endpoint never receives types it didn’t ask for. To check a handler against these payloads without sending a text, use **Send a test event** in the dashboard, `POST /v1/webhooks/{id}/test`, or `bluebot listen`. ## Messages ### `message.queued` A text was accepted and is waiting for the Mac, or a scheduled text became due. Its `hold` says why it is waiting, if anything holds it. A queued text can still be canceled. ```json title="message.queued" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "message.queued", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "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 } } ``` ### `message.sent` Messages on the Mac showed the text sent. Also fires for a text you typed by hand on your Mac or iPhone, with `source: "mac"`. `service` says whether it went as an iMessage or a regular text. A text sent with a file that couldn’t be attached is sent with `failure` set. ```json title="message.sent" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "message.sent", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "object": "message", "direction": "out", "state": "sent", "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": "imessage", "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": "2026-11-13T19:41:02.000Z", "sent_at": "2026-11-13T19:41:05.000Z", "delivered_at": null, "read_at": null, "failed_at": null, "unconfirmed_at": null, "expired_at": null, "canceled_at": null, "received_at": null } } ``` ### `message.delivered` iMessage showed the text delivered to the person’s device. Regular texts (SMS) never report delivery, so a text that went as SMS stays `sent`. ```json title="message.delivered" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "message.delivered", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "object": "message", "direction": "out", "state": "delivered", "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": "imessage", "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": "2026-11-13T19:41:02.000Z", "sent_at": "2026-11-13T19:41:05.000Z", "delivered_at": "2026-11-13T19:41:06.000Z", "read_at": null, "failed_at": null, "unconfirmed_at": null, "expired_at": null, "canceled_at": null, "received_at": null } } ``` ### `message.read` The person read the text. Only for people who share read receipts. Most people don’t share read receipts, so don’t wait for this event. ```json title="message.read" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "message.read", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "object": "message", "direction": "out", "state": "read", "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": "imessage", "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": "2026-11-13T19:41:02.000Z", "sent_at": "2026-11-13T19:41:05.000Z", "delivered_at": "2026-11-13T19:41:06.000Z", "read_at": "2026-11-13T19:43:30.000Z", "failed_at": null, "unconfirmed_at": null, "expired_at": null, "canceled_at": null, "received_at": null } } ``` ### `message.failed` Messages couldn’t send the text, or it was an iMessage-only text to somebody not on iMessage. `failure.reason` is a stable code and `failure.detail` says what the Mac saw. ```json title="message.failed" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "message.failed", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "object": "message", "direction": "out", "state": "failed", "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": "imessage", "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": { "reason": "not_on_imessage", "detail": "Messages couldn’t send this as an iMessage." }, "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": "2026-11-13T19:41:02.000Z", "sent_at": null, "delivered_at": null, "read_at": null, "failed_at": "2026-11-13T19:41:09.000Z", "unconfirmed_at": null, "expired_at": null, "canceled_at": null, "received_at": null } } ``` ### `message.unconfirmed` The Mac took the text and didn’t say whether it went before its 3-minute lease ran out. It may have gone. Check the conversation, then settle it with `POST /v1/messages/{id}/resolve`: `mark_sent` if it went, `send_again` if not. BlueBot never sends it again on its own. ```json title="message.unconfirmed" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "message.unconfirmed", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "object": "message", "direction": "out", "state": "unconfirmed", "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": "2026-11-13T19:41:02.000Z", "sent_at": null, "delivered_at": null, "read_at": null, "failed_at": null, "unconfirmed_at": "2026-11-13T19:44:02.000Z", "expired_at": null, "canceled_at": null, "received_at": null } } ``` ### `message.expired` The text was still waiting when its `expires_at` passed, so it will never go. Usually the Mac was away or the text sat behind quiet hours or a daily limit for longer than the expiry setting. ```json title="message.expired" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "message.expired", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "object": "message", "direction": "out", "state": "expired", "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": "2026-11-14T19:41:00.000Z", "canceled_at": null, "received_at": null } } ``` ### `message.canceled` A queued or scheduled text was canceled: by you, because the person is on your stop list, or when its sequence or broadcast stopped. `cancel_reason` says which. ```json title="message.canceled" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "message.canceled", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "object": "message", "direction": "out", "state": "canceled", "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": "opted_out", "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": "2026-11-13T19:42:00.000Z", "received_at": null } } ``` ### `message.received` Somebody texted you. Pictures and voice memos are on the message as files. Fires once the Mac has uploaded the message’s files, or after 2 minutes without them (their `status` is then `waiting`). Group texts carry `chat_id`. ```json title="message.received" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "message.received", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "msg_2Bf8QzD4kJmA6nXwEuTr1c", "object": "message", "direction": "in", "state": "received", "to": "+15555550100", "from": "+15555550123", "chat_id": null, "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "conversation_id": "conv_1Hq2LxW9cVbN3mKpRtYs0a", "text": "Perfect, see you there!", "text_deleted": false, "files": [], "service_requested": "auto", "service": "imessage", "sms_retry": false, "source": "mac", "source_ref": null, "metadata": {}, "hold": null, "send_at": null, "expires_at": null, "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:44:12.000Z", "queued_at": null, "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": "2026-11-13T19:44:10.000Z" } } ``` ### `message.edited` A text was edited in Messages, by you or by the other person. `text` is the new words and `edited_at` when. BlueBot can read edits but can’t make them. ```json title="message.edited" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "message.edited", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "msg_2Bf8QzD4kJmA6nXwEuTr1c", "object": "message", "direction": "in", "state": "received", "to": "+15555550100", "from": "+15555550123", "chat_id": null, "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "conversation_id": "conv_1Hq2LxW9cVbN3mKpRtYs0a", "text": "Perfect, see you there at 3!", "text_deleted": false, "files": [], "service_requested": "auto", "service": "imessage", "sms_retry": false, "source": "mac", "source_ref": null, "metadata": {}, "hold": null, "send_at": null, "expires_at": null, "failure": null, "cancel_reason": null, "resolution": null, "resolved_at": null, "resent_message_id": null, "reply_to_id": null, "reactions": [], "edited_at": "2026-11-13T19:44:40.000Z", "unsent_at": null, "created_at": "2026-11-13T19:44:12.000Z", "queued_at": null, "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": "2026-11-13T19:44:10.000Z" } } ``` ### `message.unsent` A text was unsent in Messages. Its words are gone from BlueBot too. `text` is null and `unsent_at` says when. ```json title="message.unsent" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "message.unsent", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "msg_2Bf8QzD4kJmA6nXwEuTr1c", "object": "message", "direction": "in", "state": "received", "to": "+15555550100", "from": "+15555550123", "chat_id": null, "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "conversation_id": "conv_1Hq2LxW9cVbN3mKpRtYs0a", "text": null, "text_deleted": false, "files": [], "service_requested": "auto", "service": "imessage", "sms_retry": false, "source": "mac", "source_ref": null, "metadata": {}, "hold": null, "send_at": null, "expires_at": null, "failure": null, "cancel_reason": null, "resolution": null, "resolved_at": null, "resent_message_id": null, "reply_to_id": null, "reactions": [], "edited_at": null, "unsent_at": "2026-11-13T19:45:00.000Z", "created_at": "2026-11-13T19:44:12.000Z", "queued_at": null, "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": "2026-11-13T19:44:10.000Z" } } ``` ## Reactions ### `reaction.added` Somebody added a tapback (love, like, laugh and so on) to a text, or you did from your Mac or iPhone. `direction` says whose it was. BlueBot can read tapbacks but can’t send them. ```json title="reaction.added" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "reaction.added", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "rxn_1Hq2LxW9cVbN3mKpRtYs0a", "object": "reaction", "kind": "love", "emoji": null, "from": "+15555550123", "direction": "in", "added_at": "2026-11-13T19:45:00.000Z", "message_id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "conversation_id": "conv_1Hq2LxW9cVbN3mKpRtYs0a", "chat_id": null, "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "removed_at": null } } ``` ### `reaction.removed` A tapback was taken back. The reaction has the id `reaction.added` gave it. ```json title="reaction.removed" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "reaction.removed", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "rxn_1Hq2LxW9cVbN3mKpRtYs0a", "object": "reaction", "kind": "love", "emoji": null, "from": "+15555550123", "direction": "in", "added_at": "2026-11-13T19:45:00.000Z", "message_id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "conversation_id": "conv_1Hq2LxW9cVbN3mKpRtYs0a", "chat_id": null, "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "removed_at": "2026-11-13T19:46:00.000Z" } } ``` ## Chats ### `chat.created` The Mac saw a chat BlueBot didn’t know: a new group, or a first text with somebody. A group chat’s id is what `chat_id` takes on a send. ```json title="chat.created" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "chat.created", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "chat_1Hq2LxW9cVbN3mKpRtYs0a", "object": "chat", "name": "Oak St closing", "is_group": true, "participants": [ "+15555550123", "+15555550187" ], "service": "imessage", "last_message_at": "2026-11-13T19:41:00.000Z", "created_at": "2026-11-13T19:41:00.000Z" } } ``` ## Contacts ### `contact.created` A person was added: by a send to somebody new, a text from somebody new, an import, the API or the dashboard. `created_via` says how. ```json title="contact.created" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "contact.created", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "object": "contact", "handle": "+15555550123", "kind": "phone", "first_name": "Maria", "last_name": "Alvarez", "company": null, "tags": [ "buyer" ], "custom_fields": {}, "stopped": false, "stopped_at": null, "stop_source": null, "last_service": "imessage", "first_message_at": null, "last_message_at": null, "created_via": "api", "created_at": "2026-11-13T19:41:00.000Z" } } ``` ### `contact.stopped` A person went on your stop list. Their queued texts were canceled. `stop_source: "reply"` means they texted a stop word such as STOP. BlueBot won’t text them again until they text START or you take them off the list. ```json title="contact.stopped" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "contact.stopped", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "object": "contact", "handle": "+15555550123", "kind": "phone", "first_name": "Maria", "last_name": "Alvarez", "company": null, "tags": [ "buyer" ], "custom_fields": {}, "stopped": true, "stopped_at": "2026-11-13T19:50:00.000Z", "stop_source": "reply", "last_service": "imessage", "first_message_at": "2026-11-13T19:41:00.000Z", "last_message_at": "2026-11-13T19:41:00.000Z", "created_via": "api", "created_at": "2026-11-13T19:41:00.000Z" } } ``` ### `contact.unstopped` A person came off your stop list: they texted START, or you took them off. Texts to them go again from now on. Texts canceled while they were stopped stay canceled. ```json title="contact.unstopped" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "contact.unstopped", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "object": "contact", "handle": "+15555550123", "kind": "phone", "first_name": "Maria", "last_name": "Alvarez", "company": null, "tags": [ "buyer" ], "custom_fields": {}, "stopped": false, "stopped_at": null, "stop_source": null, "last_service": "imessage", "first_message_at": "2026-11-13T19:41:00.000Z", "last_message_at": "2026-11-13T19:41:00.000Z", "created_via": "api", "created_at": "2026-11-13T19:41:00.000Z" } } ``` ## Automations ### `sequence.completed` A person reached the end of a sequence, or it stopped early because they replied or stopped. `state` and `stop_reason` say which. ```json title="sequence.completed" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "sequence.completed", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "enr_1Hq2LxW9cVbN3mKpRtYs0a", "object": "enrollment", "sequence_id": "seq_1Hq2LxW9cVbN3mKpRtYs0a", "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "handle": "+15555550123", "contact": { "id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "object": "contact", "handle": "+15555550123", "kind": "phone", "first_name": "Maria", "last_name": "Alvarez", "company": null, "tags": [ "buyer" ], "custom_fields": {}, "stopped": false, "stopped_at": null, "stop_source": null, "last_service": "imessage", "first_message_at": "2026-11-13T19:41:00.000Z", "last_message_at": "2026-11-13T19:41:00.000Z", "created_via": "api", "created_at": "2026-11-13T19:41:00.000Z" }, "state": "stopped", "stop_reason": "replied", "completed_at": "2026-11-13T19:41:00.000Z" } } ``` ### `broadcast.completed` Every text in a broadcast has settled: sent, failed, skipped, expired or canceled. `counts` has the totals. ```json title="broadcast.completed" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "broadcast.completed", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "bc_1Hq2LxW9cVbN3mKpRtYs0a", "object": "broadcast", "name": "Open house Saturday", "state": "completed", "recipient_count": 42, "counts": { "sent": 12, "delivered": 27, "failed": 1, "skipped": 2 }, "completed_at": "2026-11-13T19:41:00.000Z" } } ``` ### `automation.matched` An incoming text matched a keyword rule that has the “Send a webhook event” action. Fires once per incoming text, for the first rule that matched. `message` is the text that matched. A stop word never matches a rule, so it never fires for one. ```json title="automation.matched" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "automation.matched", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "object": "automation_match", "rule_id": "kw_1Hq2LxW9cVbN3mKpRtYs0a", "rule_name": "Pricing", "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "actions": [ "reply", "tag", "webhook" ], "message": { "id": "msg_2Bf8QzD4kJmA6nXwEuTr1c", "object": "message", "direction": "in", "state": "received", "to": "+15555550100", "from": "+15555550123", "chat_id": null, "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "conversation_id": "conv_1Hq2LxW9cVbN3mKpRtYs0a", "text": "PRICING", "text_deleted": false, "files": [], "service_requested": "auto", "service": "imessage", "sms_retry": false, "source": "mac", "source_ref": null, "metadata": {}, "hold": null, "send_at": null, "expires_at": null, "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:44:12.000Z", "queued_at": null, "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": "2026-11-13T19:44:10.000Z" }, "matched_at": "2026-11-13T19:41:00.000Z" } } ``` ## Your Mac ### `device.online` The Mac checked in after being offline. Held texts start going. Also fires the first time a newly connected Mac checks in. ```json title="device.online" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "device.online", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "dev_1Hq2LxW9cVbN3mKpRtYs0a", "object": "device", "status": "online", "name": "Dana’s MacBook Air", "number": "+15555550100", "app_version": "1.0.0", "macos_version": "15.1", "last_seen_at": "2026-11-13T19:41:00.000Z", "last_sync_at": "2026-11-13T19:41:00.000Z", "offline_since": null, "paired_at": "2026-11-01T15:00:00.000Z", "checks": { "messages": { "state": "ok", "detail": null }, "full_disk_access": { "state": "ok", "detail": null }, "forwarding": { "state": "ok", "detail": null }, "awake": { "state": "ok", "detail": null }, "filevault": { "state": "ok", "detail": null } }, "checks_ok": true, "transcription": false, "held_count": 0, "queued_count": 0, "unconfirmed_count": 0, "paused_until": null, "today": { "sent": 14, "new_people": 3, "new_people_limit": 50, "daily_limit": 1000, "resets_at": "2026-11-14T05:00:00.000Z" }, "failed_checks": [] } } ``` ### `device.offline` The Mac hasn’t checked in for 2 minutes. Texts wait with `hold.reason: "device_offline"`. The owner is emailed once if it stays away 10 minutes. ```json title="device.offline" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "device.offline", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "dev_1Hq2LxW9cVbN3mKpRtYs0a", "object": "device", "status": "offline", "name": "Dana’s MacBook Air", "number": "+15555550100", "app_version": "1.0.0", "macos_version": "15.1", "last_seen_at": "2026-11-13T19:41:00.000Z", "last_sync_at": "2026-11-13T19:41:00.000Z", "offline_since": "2026-11-13T19:39:00.000Z", "paired_at": "2026-11-01T15:00:00.000Z", "checks": { "messages": { "state": "ok", "detail": null }, "full_disk_access": { "state": "ok", "detail": null }, "forwarding": { "state": "ok", "detail": null }, "awake": { "state": "ok", "detail": null }, "filevault": { "state": "ok", "detail": null } }, "checks_ok": true, "transcription": false, "held_count": 2, "queued_count": 2, "unconfirmed_count": 0, "paused_until": null, "today": { "sent": 14, "new_people": 3, "new_people_limit": 50, "daily_limit": 1000, "resets_at": "2026-11-14T05:00:00.000Z" }, "failed_checks": [] } } ``` ### `device.check_failed` One of the Mac’s setup checks turned `needs_you`, such as Messages signing out or Full Disk Access being turned off. `failed_checks` names them and each check’s `detail` says what the Mac saw. ```json title="device.check_failed" { "id": "evt_1Hq2LxW9cVbN3mKpRtYs0a", "object": "event", "type": "device.check_failed", "api_version": "2026-11-13", "timestamp": "2026-11-13T19:41:00.000Z", "data": { "id": "dev_1Hq2LxW9cVbN3mKpRtYs0a", "object": "device", "status": "online", "name": "Dana’s MacBook Air", "number": "+15555550100", "app_version": "1.0.0", "macos_version": "15.1", "last_seen_at": "2026-11-13T19:41:00.000Z", "last_sync_at": "2026-11-13T19:41:00.000Z", "offline_since": null, "paired_at": "2026-11-01T15:00:00.000Z", "checks": { "messages": { "state": "needs_you", "detail": "Messages isn’t signed in to iMessage." }, "full_disk_access": { "state": "ok", "detail": null }, "forwarding": { "state": "ok", "detail": null }, "awake": { "state": "ok", "detail": null }, "filevault": { "state": "ok", "detail": null } }, "checks_ok": false, "transcription": false, "held_count": 0, "queued_count": 0, "unconfirmed_count": 0, "paused_until": null, "today": { "sent": 14, "new_people": 3, "new_people_limit": 50, "daily_limit": 1000, "resets_at": "2026-11-14T05:00:00.000Z" }, "failed_checks": [ "messages" ] } } ``` --- Source: https://usebluebot.com/docs/mcp # MCP server > Connect Claude, ChatGPT, Cursor, VS Code or any MCP client to BlueBot, and your assistant can read your conversations, check your Mac and text people from your own number when you ask. BlueBot runs an MCP server, so an AI assistant can use your texts the way you would. Ask Claude "Who's waiting on a reply from me?" and it reads your conversations. Ask it to "text Ana that I'm running ten minutes late" and it sends the text from your number, through your Mac, like any other. [MCP](https://modelcontextprotocol.io) (the Model Context Protocol) is the open standard most assistants use to call outside tools. For example, Jordan, a real estate agent, opens Claude Desktop before the first showing and asks, "Anything from buyers overnight?" Claude reads the conversations that wait on Jordan, sums up three replies, and drafts an answer to the one asking about parking. Jordan reads the draft, says "send it", approves the tool call, and the text goes from Jordan's own number. ## What your assistant can do | Job | Tools | | ------ | ------------------------------------------------------------------------ | | Read | `list_conversations`, `get_conversation`, `list_messages`, `get_message` | | Send | `send_text`, `schedule_text`, `cancel_text`, `send_template` | | People | `search_contacts`, `create_contact`, `enroll_in_sequence` | | Check | `get_device_status`, `lookup_service`, `list_templates`, `get_analytics` | [MCP tools](https://usebluebot.com/docs/mcp/tools) describes each one with its inputs and the scope it needs. ## What it never does - **Skip your sending rules.** A text an assistant sends goes through the same checks as one from the API: your pace, quiet hours, the new-people limit, the daily ceiling and your stop list. A text that can't go yet is held with a reason, and the assistant tells you why. [Holds](https://usebluebot.com/docs/concepts/holds) - **Text anybody on your stop list**, or anybody at all when your Mac is offline: the text waits for the Mac instead. - **Change your account.** There are no tools for settings, API keys, webhooks, billing or deleting anything. - **See secrets.** API keys and webhook signing secrets never pass through the server. - **Do what Messages can't.** It can't start a new group chat, send a tapback or edit a sent text. Every text an assistant sends has `source: "mcp"`, so it's easy to find in **Messages** in the dashboard and in the API. ## Connect your assistant The server is one address, and you can sign in to it two ways: - **OAuth**: the assistant opens a BlueBot page where you sign in and approve it. There's no key to copy, and you can disconnect it later from **Developers → Connected apps**. Claude Desktop, claude.ai, ChatGPT and VS Code use this. - **An API key** in an `Authorization: Bearer` header. Use it for a client that can't do OAuth, or when you want to limit the assistant to a key's scopes, such as a read-only key. [OAuth or an API key](https://usebluebot.com/docs/mcp/auth) compares them. Then follow the steps for your client: The server's address is `https://usebluebot.com/mcp`. Every client below connects to that one address. ### Claude Code Add it once from a terminal. Without `--header`, Claude Code signs in with OAuth the first time you use a tool (run `/mcp` to start). ```bash claude mcp add --transport http bluebot https://usebluebot.com/mcp \ --header "Authorization: Bearer $BLUEBOT_API_KEY" ``` ### Claude Desktop Settings → Connectors → Add custom connector. Name it BlueBot, paste the URL, and sign in to BlueBot when asked; no key to paste. The same connector works on claude.ai. ```text https://usebluebot.com/mcp ``` ### Cursor Use the button, or add this to `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project) and set `BLUEBOT_API_KEY` in your environment. [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=bluebot&config=eyJ1cmwiOiJodHRwczovL3VzZWJsdWVib3QuY29tL21jcCJ9) ```json title="~/.cursor/mcp.json" { "mcpServers": { "bluebot": { "url": "https://usebluebot.com/mcp", "headers": { "Authorization": "Bearer ${env:BLUEBOT_API_KEY}" } } } } ``` ### VS Code Use the button, or add this to `.vscode/mcp.json`. VS Code signs in with OAuth; to use a key instead, add an `Authorization` header. [Add to VS Code](vscode:mcp/install?%7B%22name%22%3A%22bluebot%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fusebluebot.com%2Fmcp%22%7D) ```json title=".vscode/mcp.json" { "servers": { "bluebot": { "type": "http", "url": "https://usebluebot.com/mcp" } } } ``` ### Windsurf Add this to `~/.codeium/windsurf/mcp_config.json`, then refresh the MCP servers in Cascade. ```json title="~/.codeium/windsurf/mcp_config.json" { "mcpServers": { "bluebot": { "serverUrl": "https://usebluebot.com/mcp", "headers": { "Authorization": "Bearer ${env:BLUEBOT_API_KEY}" } } } } ``` ### ChatGPT Settings → Apps and connectors → Advanced → turn on Developer mode, then Create: name it BlueBot, paste the URL, choose OAuth, and sign in to BlueBot. ```text https://usebluebot.com/mcp ``` ### Any client (mcp-remote) For a client that only starts local servers, `mcp-remote` bridges to BlueBot over HTTP. This is Claude Desktop’s `claude_desktop_config.json` shape; most clients take the same. ```json title="claude_desktop_config.json" { "mcpServers": { "bluebot": { "command": "npx", "args": [ "-y", "mcp-remote", "https://usebluebot.com/mcp", "--header", "Authorization:${BLUEBOT_AUTH}" ], "env": { "BLUEBOT_AUTH": "Bearer bb_live_…" } } } } ``` ### Check it works Ask your assistant: "Is my Mac online?" It calls `get_device_status` and answers from what BlueBot knows. | You got | It means | Do next | | ------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | An answer about your Mac, its checks and today's counts | The assistant is connected and signed in. | Try [a prompt or two](https://usebluebot.com/docs/mcp/prompts). | | The client says it can't find any tools from BlueBot | It hasn't connected, or it read the tool list before you signed in. | Reconnect from the client's MCP settings. In Claude Code, run `/mcp`. | | `401` or "unauthorized" | The key is wrong or revoked, or the sign-in expired. | Paste the key again, or sign in again from the client. | | A tool answers that the key lacks a scope | The key can't do what the tool needs, such as sending with a read-only key. | That's the key working as meant. Use a key with that scope if you want the tool. [API keys and scopes](https://usebluebot.com/docs/concepts/api-keys) | ## Approve what your assistant sends Your client decides whether to ask before it calls a tool. Most ask by default, and let you allow a tool once or always. - **Allow the read tools always** if you like: `list_conversations`, `get_conversation`, `get_device_status` and the rest only read. - **Keep asking for the send tools**: `send_text`, `schedule_text`, `send_template` and `enroll_in_sequence` text real people from your number. Read the words and the number before you approve. - **For read-only use, connect with a read-only key.** A key with only `messages:read`, `contacts:read` and `account:read` can't send whatever the assistant decides, because BlueBot refuses the send tools for it. - **Undo is cancel.** A scheduled or held text can be canceled until it goes: ask the assistant to cancel it, or cancel it in **Messages**. A text that was sent can't be taken back. > **What the assistant reads goes to its provider.** When a tool reads a conversation, those texts go to the company that runs your assistant (Anthropic for Claude, OpenAI for ChatGPT, and so on) under its terms, like anything else you share in the chat. Connect only assistants you'd trust with your texts. ## How answers look Each tool answers with compact structured data for the assistant and a one-line summary for you. A send answers with the message and its state, like `POST /v1/messages` does. When a text can't go yet, it comes back `queued` with a `hold`, and the assistant explains it: ```json title="send_text, held by quiet hours" { "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "state": "queued", "to": "+15555550123", "service_requested": "auto", "hold": { "reason": "quiet_hours", "until": "2026-11-14T13:00:00.000Z", "description": "Waiting for quiet hours to end at 8:00 AM." }, "source": "mcp" } ``` That text isn't lost: it goes at 8:00 the next morning in your time zone, when quiet hours end. [Quiet hours](https://usebluebot.com/docs/concepts/quiet-hours) ## For MCP client developers - **Transport**: Streamable HTTP at `https://usebluebot.com/mcp`. - **API keys**: `Authorization: Bearer bb_live_…`, the same keys as the REST API, with the same scopes. - **OAuth 2.1**: an unauthenticated request gets `401` with a `WWW-Authenticate` header pointing at `https://usebluebot.com/.well-known/oauth-protected-resource`. The authorization server's metadata is at `https://usebluebot.com/.well-known/oauth-authorization-server`. Clients register themselves at `/oauth/register` (dynamic client registration) and use the authorization code flow with PKCE (`S256` only). - **Server card**: `https://usebluebot.com/.well-known/mcp.json` describes the server. - **Tools**: every tool calls the same code as the REST API, so the API reference's rules (states, holds, errors) hold for MCP too. ## Next - [MCP tools](https://usebluebot.com/docs/mcp/tools): Every tool, its inputs and the scope it needs. - [OAuth or an API key](https://usebluebot.com/docs/mcp/auth): How each sign-in works, and how to revoke it. - [Example prompts](https://usebluebot.com/docs/mcp/prompts): What to ask, and what the assistant does. - [Docs for AI agents](https://usebluebot.com/docs/for-ai-agents): Point a coding agent at these docs to build an integration. --- Source: https://usebluebot.com/docs/mcp/tools # MCP tools > The fifteen tools BlueBot's MCP server offers an assistant, what each one does, the inputs it takes and the API key scope it needs. Your assistant picks tools by their descriptions, so you rarely name one. You say "did the text to Marcus get delivered?" and it calls `get_message`. This page is for when you want to know exactly what it can do, choose a key's scopes, or work out why a tool answered the way it did. ## Which scope each tool needs A tool runs only when the key or sign-in has its scope. With a read-only key, the five `messages:send` tools and `create_contact` refuse, and everything else works. | Scope | Tools | | ---------------- | ------------------------------------------------------------------------------------------------------------ | | `messages:send` | `send_text`, `schedule_text`, `cancel_text`, `send_template`, `enroll_in_sequence` | | `messages:read` | `list_conversations`, `get_conversation`, `list_messages`, `get_message`, `list_templates`, `lookup_service` | | `contacts:read` | `search_contacts` | | `contacts:write` | `create_contact` | | `account:read` | `get_device_status`, `get_analytics` | No tool needs `webhooks:manage`. [API keys and scopes](https://usebluebot.com/docs/concepts/api-keys) ## How the tools behave **They're the API underneath.** Each tool calls the same code as the REST API, so a text sent with `send_text` is the same message `POST /v1/messages` makes: the same [states](https://usebluebot.com/docs/concepts/message-states), the same [holds](https://usebluebot.com/docs/concepts/holds), the same [sending rules](https://usebluebot.com/docs/concepts/sending-rules). It has `source: "mcp"`, and it fires the same webhooks. **A held text is still a sent request.** When quiet hours, a daily limit or an offline Mac keeps a text from going now, the tool answers with the message `queued` and a `hold` saying why and until when. The assistant should tell you that, not try again. Sending again would only queue a second copy. **Numbers can be written any common way.** Give a number however you'd say it: `(555) 555-0123`, `555.555.0123` or `+1 555 555 0123`. A number without a country code is taken as a US number. Answers always write numbers in E.164, `+15555550123`. An email address works for somebody on iMessage. **Conversations include what you typed by hand.** `get_conversation` and `list_messages` read every text your Mac saw, including ones you sent from your iPhone or the Messages app, as long as BlueBot copies that chat. [Receiving texts](https://usebluebot.com/docs/sending/receiving) **Templates and sequences come from the dashboard.** `list_templates`, `send_template` and `enroll_in_sequence` use the templates and sequences you made in **Templates** and **Sequences**. An assistant can't make or change them. **Lookups are best guesses.** `lookup_service` answers from your own Messages history: if your newest chat with somebody was iMessage, they're probably on iMessage. With no history it says it doesn't know. [Lookups](https://usebluebot.com/docs/sending/lookups) ## When a tool refuses A refused tool call comes back as an error the assistant reads to you. The codes are the API's: | Code | It means | | -------------------- | ---------------------------------------------------------------------------------------------- | | `insufficient_scope` | The key or sign-in doesn't have the scope this tool needs. | | `invalid_request` | An input is wrong: a number that isn't one, a time without a zone, text over 2,000 characters. | | `not_found` | No text, contact, template or sequence with that id on your account. | | `conflict` | The record is past the point where this works, such as canceling a text that already went. | | `plan_lapsed` | Your plan isn't active. Texts wait until it is. | | `account_held` | BlueBot paused sending on your account. Write to support. | | `rate_limited` | Too many calls in a minute. The assistant should wait and try again. | [Errors](https://usebluebot.com/docs/errors) has every code. ## Every tool ### `send_text` Sends a text from your number now. The answer is the message with its state; a text that can’t go yet comes back `queued` with a `hold` saying why. Needs the `messages:send` scope. | Input | Type | Required | What it is | | ---------- | ---------- | ---------- | -------------------------------------------------------------------------- | | `to` | `string` | Yes | A phone number in any format (US assumed) or an email address on iMessage. | | `text` | `string` | Yes | The words, up to 2,000 characters. | | `files` | `string[]` | No | File ids from an upload, sent after the words. | | `service` | \`"auto" | "imessage" | "sms"\` | No | How to send; `auto` uses whatever the newest chat with that person used. | | `metadata` | `object` | No | String keys and values kept on the text and echoed on its webhooks. | Try: _Text Ana at +1 555 555 0123: “Running 10 minutes late, sorry!”_ ### `schedule_text` Schedules a text for a time. It stays editable and cancelable until then. Needs the `messages:send` scope. | Input | Type | Required | What it is | | ---------- | -------- | -------- | -------------------------------------------------------------------------- | | `to` | `string` | Yes | A phone number in any format (US assumed) or an email address on iMessage. | | `text` | `string` | Yes | The words. | | `send_at` | `string` | Yes | ISO 8601 time with a zone. | | `metadata` | `object` | No | String keys and values kept on the text and echoed on its webhooks. | Try: _Remind Marcus tomorrow at 9 AM that his inspection is at 11._ ### `cancel_text` Cancels a scheduled or queued text. Needs the `messages:send` scope. | Input | Type | Required | What it is | | ----- | -------- | -------- | ------------------------ | | `id` | `string` | Yes | The message id, `msg_…`. | Try: _Cancel the reminder you scheduled for Marcus._ ### `list_conversations` Recent conversations, newest first, with the last text in each and whether it waits on you. Needs the `messages:read` scope. | Input | Type | Required | What it is | | --------------- | --------- | -------- | ----------------------------------------------- | | `limit` | `integer` | No | 1 to 50, default 20. | | `waiting_on_me` | `boolean` | No | Only conversations where the last text came in. | Try: _Who’s waiting on a reply from me?_ ### `get_conversation` The texts with one person or group, oldest to newest, including ones typed by hand on the Mac or iPhone. Needs the `messages:read` scope. | Input | Type | Required | What it is | | --------- | --------- | -------- | ------------------------------------------------ | | `person` | `string` | No | A phone number or email. Give this or `chat_id`. | | `chat_id` | `string` | No | A group chat id. | | `limit` | `integer` | No | How many texts, 1 to 100, default 30. | Try: _What did Ana and I agree on last week?_ ### `list_messages` Texts in and out, filtered by person, direction, state or time. Needs the `messages:read` scope. | Input | Type | Required | What it is | | ----------- | --------- | -------- | --------------------------------------------------- | | `person` | `string` | No | A phone number or email. | | `direction` | \`"in" | "out"\` | No | Only received or only sent texts. | | `state` | `string` | No | A message state, such as `failed` or `unconfirmed`. | | `since` | `string` | No | ISO 8601 time. | | `limit` | `integer` | No | 1 to 100, default 25. | Try: _Did any texts fail today?_ ### `get_message` One text with its state, hold and metadata. Needs the `messages:read` scope. | Input | Type | Required | What it is | | ----- | -------- | -------- | ------------------------ | | `id` | `string` | Yes | The message id, `msg_…`. | Try: _Did the text to Marcus get delivered?_ ### `search_contacts` Contacts by name, number, email or tag. Needs the `contacts:read` scope. | Input | Type | Required | What it is | | ------- | --------- | -------- | -------------------------------------- | | `query` | `string` | No | Words in a name, a number or an email. | | `tag` | `string` | No | Only contacts with this tag. | | `limit` | `integer` | No | 1 to 50, default 20. | Try: _Find everyone tagged “buyer”._ ### `create_contact` Makes a contact, or updates the one with that number or email. Needs the `contacts:write` scope. | Input | Type | Required | What it is | | ------------ | ---------- | -------- | ---------------------- | | `handle` | `string` | Yes | Phone number or email. | | `first_name` | `string` | No | First name. | | `last_name` | `string` | No | Last name. | | `tags` | `string[]` | No | Tags to add. | Try: _Save +1 555 555 0177 as Priya Shah, tag her “seller”._ ### `get_device_status` Whether your Mac is online, its checks, today’s counts against your limits, and how many texts are held. Needs the `account:read` scope. It takes no inputs. Try: _Is my Mac online? Why are texts waiting?_ ### `list_templates` Your saved texts and the variables each one takes. Needs the `messages:read` scope. It takes no inputs. Try: _What templates do I have?_ ### `send_template` Fills a template’s variables and sends it, now or at `send_at`. Needs the `messages:send` scope. | Input | Type | Required | What it is | | ------------- | -------- | -------- | -------------------------------------------------------------------------- | | `template_id` | `string` | Yes | The template id, `tpl_…`. | | `to` | `string` | Yes | A phone number in any format (US assumed) or an email address on iMessage. | | `variables` | `object` | No | Values for the template’s `{{variables}}`; contact fields fill the rest. | | `send_at` | `string` | No | ISO 8601 time to send it later. | Try: _Send Ana the showing reminder for 3 PM._ ### `enroll_in_sequence` Starts a person on a sequence of follow-ups that stops when they reply or text STOP. Needs the `messages:send` scope. | Input | Type | Required | What it is | | ------------- | -------- | -------- | -------------------------------------------------------------------------- | | `sequence_id` | `string` | Yes | The sequence id, `seq_…`. | | `to` | `string` | Yes | A phone number in any format (US assumed) or an email address on iMessage. | | `variables` | `object` | No | Values for the sequence’s templates. | Try: _Put Priya on the new-listing follow-up._ ### `get_analytics` The Analytics page’s numbers for a period: sent, delivered, read, replied, failed and stopped, and your median time to reply. Needs the `account:read` scope. | Input | Type | Required | What it is | | ------ | --------- | -------- | ------------------------------------------------------------------------------------------- | | `days` | `integer` | No | How many days, today included, counted from midnight in your time zone: 1 to 90, default 7. | Try: _How many people replied this week, and how fast did I answer?_ ### `lookup_service` BlueBot’s best guess from your own Messages history, with its basis: `history` or `none`. Needs the `messages:read` scope. | Input | Type | Required | What it is | | -------- | -------- | -------- | ------------------------ | | `number` | `string` | Yes | A phone number or email. | Try: _Is +1 555 555 0188 on iMessage?_ ## Next - [Example prompts](https://usebluebot.com/docs/mcp/prompts): What to ask for each job. - [OAuth or an API key](https://usebluebot.com/docs/mcp/auth): Sign-in, scopes and revoking. --- Source: https://usebluebot.com/docs/mcp/auth # OAuth or an API key > An assistant connects to BlueBot's MCP server by signing in with OAuth or by sending an API key. How each works, which scopes to give it, and how to cut it off. BlueBot's MCP server takes two kinds of sign-in, and both reach the same tools with the same rules. | | OAuth sign-in | API key | | -------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | Setup | Paste the server address; the client opens a BlueBot page where you sign in and approve it. | Make a key, then put it in the client's config as a header. | | Suits | Claude Desktop, claude.ai, ChatGPT, VS Code: clients with a sign-in button. | Claude Code, Cursor, Windsurf, scripts, and any client without OAuth. | | What it can do | The scopes on the approval page. | The scopes you tick on the key. | | Lasts | Until you disconnect it. The client renews its access by itself. | Until you revoke the key. | | Cut it off | **Developers → Connected apps → Disconnect** | **Developers → API keys → Revoke** | ## OAuth sign-in OAuth lets an assistant connect with nothing to copy and paste, and no key sitting in a config file. ### Add the server In your client, add a custom connector or MCP server with the address `https://usebluebot.com/mcp`. [Connect your assistant](https://usebluebot.com/docs/mcp#connect-your-assistant) has the steps for each client. ### Approve it The client opens BlueBot in your browser. Sign in if you aren't already, and read the approval page: it names the app asking, the BlueBot account it would reach, and what it could do. Press **Allow**. ### Use it The client comes back connected and lists BlueBot's tools. Ask it "Is my Mac online?" to check. Underneath, the client registers itself with BlueBot, sends you to BlueBot to approve it with the authorization code flow and PKCE, and gets back an access token that lasts an hour and a refresh token it uses to get the next one. Each refresh token works once. If one is ever used twice, which happens when a token is stolen and both copies are used, BlueBot ends the connection and the client asks you to sign in again. A token works where the client asked for it to work. Clients name that with the `resource` parameter (RFC 8707): a token asked for `https://usebluebot.com/mcp` works only on the MCP server, one asked for `https://usebluebot.com/api/v1` works only on the REST API, and one asked for `https://usebluebot.com`, or for no resource at all, works on both, as an API key does. An OAuth connection asks for the scopes its tools use: `messages:send`, `messages:read`, `contacts:read`, `contacts:write` and `account:read`. It never gets `webhooks:manage`. When you want an assistant that can only read, connect it with a read-only API key instead. ## An API key ### Make a key for the assistant In **Developers → API keys**, press **New key**. Name it after the assistant ("Claude Code on my laptop"), so you know which to revoke later, and tick its scopes: | You want the assistant to | Tick | | ----------------------------------------------- | ------------------------------------------------ | | Read your texts and contacts and check your Mac | `messages:read`, `contacts:read`, `account:read` | | Do that, and send and schedule texts | Those three, and `messages:send` | | Use every tool, saving contacts too | Those four, and `contacts:write` | Copy the key. It's shown once. ### Keep it out of the config file Put the key in your environment and have the config refer to it, as the examples in [Connect your assistant](https://usebluebot.com/docs/mcp#connect-your-assistant) do: ```bash export BLUEBOT_API_KEY="bb_live_…" ``` Cursor and Windsurf read `${env:BLUEBOT_API_KEY}` in their config files, and Claude Code reads `$BLUEBOT_API_KEY` when you add the server. A config file with a key pasted in is a secret: never commit `.cursor/mcp.json` or `.vscode/mcp.json` with a key in it. ### Send it as a header Every request carries the key as `Authorization: Bearer bb_live_…`. The client does this once it's configured. Give each assistant its own key. Then you can revoke one without touching the others, and the request log in **Developers** shows which assistant did what. An account can have 10 keys. ## Cut an assistant off - **An OAuth connection**: open **Developers → Connected apps** and press **Disconnect** beside it. Its tokens stop working at once, and its next tool call fails until you sign it in again. - **An API key**: open **Developers → API keys** and press **Revoke**. The key stops working on its next request. Revoking also deletes any webhooks a program made with that key. Removing the server from your client's settings doesn't revoke anything at BlueBot. If you're done with an assistant, or a laptop with a key on it is lost, revoke it in the dashboard as well. | You got | It means | Do next | | ----------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | The client asks you to sign in to BlueBot again | Its connection was disconnected, or its refresh token was refused. | Sign in again. If you didn't disconnect it, look at **Developers → Connected apps** for anything you don't recognize. | | Every tool call fails with `unauthorized` | The key was revoked or mistyped. | Make a new key and put it in the client's config. | | Send tools fail with `insufficient_scope` | The key is read-only. | Working as meant. For sending, use a key with `messages:send`. | ## Next - [MCP tools](https://usebluebot.com/docs/mcp/tools): Which scope each tool needs. - [API keys and scopes](https://usebluebot.com/docs/concepts/api-keys): Every scope, and keys for the REST API. --- Source: https://usebluebot.com/docs/mcp/prompts # Example prompts > Things to ask an assistant connected to BlueBot, grouped by job, with the tools it calls and what it does for each one. Talk to your assistant the way you'd talk to someone helping you with your phone. These prompts work in Claude, ChatGPT, Cursor and any client connected to [BlueBot's MCP server](https://usebluebot.com/docs/mcp). Each row says which tools the assistant reaches for, so you know what you'll be asked to approve. ## Catch up | Ask | What the assistant does | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | "Who's waiting on a reply from me?" | Calls `list_conversations` with `waiting_on_me`, then lists each person with the last thing they said. | | "Anything from buyers overnight?" | Calls `list_messages` for texts that came in since last evening, finds the contacts tagged `buyer` with `search_contacts`, and sums up what each one said. | | "What did Ana and I agree on last week?" | Finds Ana with `search_contacts`, reads the thread with `get_conversation`, and pulls out what was decided. | | "Summarize today in the Oak St closing group." | Finds the group in `list_conversations` and reads it with `get_conversation` and its `chat_id`. | | "Did anyone text me about the open house?" | Reads this week's incoming texts with `list_messages` and quotes the ones that mention it. | ## Reply and send | Ask | What the assistant does | | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | "Text Ana at +1 555 555 0123: Running 10 minutes late, sorry!" | Calls `send_text` with those exact words. You approve the call before it goes. | | "Reply to Marcus that Thursday works, and ask if 2 PM is OK." | Reads Marcus's thread with `get_conversation` for context, writes the reply, and calls `send_text`. | | "Draft replies to everyone waiting on me. Don't send anything yet." | Reads each waiting conversation and shows you a draft for each. Nothing is sent until you say which to send. | | "Send Ana the showing reminder for 3 PM." | Looks up your templates with `list_templates`, then calls `send_template` with the time filled in. | | "Send Maria the photo of the kitchen from my last text to Jordan." | Explains that it can only send files you've uploaded to BlueBot, and offers to send the words alone. | > **Many people at once.** An assistant sends texts one at a time, and each one waits its turn in your [pace](https://usebluebot.com/docs/concepts/sending-rules) and counts against your new-people limit. For more than a handful of people, ask it to list them, then send a **Broadcast** from the dashboard. ## Schedule and change | Ask | What the assistant does | | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | "Remind Marcus tomorrow at 9 AM that his inspection is at 11." | Calls `schedule_text` with `send_at` set to 9:00 tomorrow in your time zone. | | "What have I got scheduled?" | Lists your texts in the `scheduled` state with `list_messages`, with who and when. | | "Cancel the reminder for Marcus." | Finds it with `list_messages` and calls `cancel_text`. | | "Move Marcus's reminder to 10 AM." | There's no tool to change a scheduled text, so it cancels the 9 AM one with `cancel_text` and schedules a new one with `schedule_text`. You approve both. | | "Text Priya on Friday afternoon to ask how the move went." | Picks a time on Friday afternoon, tells you which, and calls `schedule_text`. | ## People | Ask | What the assistant does | | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | "Save +1 555 555 0177 as Priya Shah and tag her seller." | Calls `create_contact`. If Priya is already saved under that number, it updates her. | | "Find everyone tagged buyer." | Calls `search_contacts` with the tag. | | "What's Maria Alvarez's number?" | Calls `search_contacts` with her name. | | "Put Priya on the new-listing follow-up." | Finds the sequence and calls `enroll_in_sequence`. The sequence stops on its own when she replies or texts STOP. | | "Is +1 555 555 0188 on iMessage?" | Calls `lookup_service`, which answers from your own Messages history, and says how sure it is. | ## Check on things | Ask | What the assistant does | | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | "Is my Mac online? Why are texts waiting?" | Calls `get_device_status` and explains any held texts by their reason: quiet hours, a daily limit, the Mac offline. | | "Did the text to Marcus get delivered?" | Finds the text with `list_messages` and reads its state: `sent`, `delivered` or `read`. Texts that went as SMS stop at `sent`. | | "Did any texts fail today?" | Calls `list_messages` with the `failed` state since this morning, and says why each one failed. | | "Are any texts waiting for me to check them?" | Lists texts in the `unconfirmed` state and explains that each one may have gone. You settle them in **Messages** in the dashboard. | | "How many people replied this week, and how fast did I answer?" | Calls `get_analytics` for 7 days and reads out the counts and the median reply time. | ## With your other tools An assistant with more than one connector can join them up. You approve each send. | Ask | What the assistant does | | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | "Text everyone with an appointment tomorrow a reminder." | Reads tomorrow from your calendar connector, then calls `schedule_text` for each person for the morning. | | "Add everyone who texted me this week to my CRM as leads." | Reads this week's conversations from BlueBot and makes the records through your CRM's connector. | | "When I'm in a meeting, tell people who text me that I'll call back." | Explains that an assistant only acts when you talk to it, and points you to the **Away message** in **Automations**, which does this on its own. | ## Getting good answers - **Name the person and the number the first time.** "Text Ana at +1 555 555 0123" leaves no doubt; after that, "Ana" is enough in the same chat. - **Say "draft" when you want to read first.** The assistant writes the words and waits. - **Give times as you'd say them.** "Tomorrow at 9" is read in your time zone. - **Read the tool call before you approve it.** The number and the words in the approval are what goes. ## Next - [MCP tools](https://usebluebot.com/docs/mcp/tools): What each tool does and the scope it needs. - [MCP server](https://usebluebot.com/docs/mcp): How to connect your assistant and approve what it sends. --- Source: https://usebluebot.com/docs/for-ai-agents # Docs for AI agents > Every page of these docs comes as Markdown for language models, with llms.txt to find them, llms-full.txt to read them all at once, and a prompt that gets a coding agent to build a BlueBot integration the right way. These docs are written for people and for the models that help them. A coding agent building your integration should read the same pages you do, as plain Markdown it can take in whole, rather than guess at BlueBot's API from what it half remembers. Everything on this page is public and needs no key. ## llms.txt [`https://usebluebot.com/llms.txt`](https://usebluebot.com/llms.txt) is the map, in the [llms.txt](https://llmstxt.org) format. It has: - what BlueBot is, in one paragraph, and the facts an agent gets wrong most often: the base URL, how to authenticate, that a held text is accepted rather than refused, and to send an `Idempotency-Key`; - **What BlueBot is not**, so an agent doesn't build something BlueBot won't do, such as texting people who never asked; - every page of the docs, by section, as a link to its Markdown copy with its one-line description; - an **Optional** section with the long lists (the app recipes and the error codes) that an agent reads only when it needs them. Give an agent this address first. It's short enough to read in one go, and it tells the agent which page to open for each question. ## llms-full.txt [`https://usebluebot.com/llms-full.txt`](https://usebluebot.com/llms-full.txt) is every page as Markdown, one after another, and each page starts with its address. It's large, a few hundred thousand characters, so use it when an agent has room for all of it or when you want to load the docs into a project's knowledge: a Claude Project, a ChatGPT project, or a retrieval index of your own. ## A Markdown copy of every page Add `.md` to any page's address to get the page as Markdown: | Page | Its Markdown | | --------------------------------------------- | ------------------------------------------------ | | `https://usebluebot.com/docs/quickstart` | `https://usebluebot.com/docs/quickstart.md` | | `https://usebluebot.com/docs/webhooks/verify` | `https://usebluebot.com/docs/webhooks/verify.md` | | `https://usebluebot.com/docs` | `https://usebluebot.com/docs.md` | The copy says everything the page shows. Request examples come out as curl, TypeScript and Python. Tables built from BlueBot's own data (holds, scopes, limits, events) are written out in full, diagrams come as Mermaid, callouts as quotes, and every link is absolute, so the copy reads correctly wherever it's pasted. Each page's HTML also names its copy with ``, so a crawler finds it on its own. ## The buttons on every page Under each page's title: - **Copy as Markdown** puts the page's Markdown on your clipboard, ready to paste into a chat or a file. - **Open in Claude** starts a new Claude chat that reads the page's Markdown copy, so you can ask about it straight away. - **Open in ChatGPT** does the same in ChatGPT. - **.md** opens the Markdown copy itself. The two "Open in" buttons send the assistant the page's address, never the page's words, so the chat opens instantly and the assistant reads the current version. ## The OpenAPI file [`https://usebluebot.com/openapi.v1.json`](https://usebluebot.com/openapi.v1.json) describes every operation in OpenAPI 3.1: the paths, every field with its type and limits, an example of every request and answer, the scope each operation needs (`x-scope`) and the error codes it can answer (`x-error-codes`). An agent writing HTTP calls by hand, or generating a client, should work from it. [OpenAPI, Postman and Insomnia](https://usebluebot.com/docs/openapi) ## Point your coding agent at the docs ### Claude Code Tell it where the docs are in your project's `CLAUDE.md`, so every session knows: ```markdown title="CLAUDE.md" ## BlueBot This project texts through BlueBot. Before writing or changing BlueBot code, read https://usebluebot.com/llms.txt and the Markdown pages it links that the task needs. Use @usebluebot/sdk (never the npm package "bluebot"). The API key is BLUEBOT_API_KEY and the webhook secret is BLUEBOT_WEBHOOK_SECRET. Only ever text BLUEBOT_TEST_NUMBER while testing. ``` Claude Code fetches the pages as it needs them. To let it check your account while it works, such as whether your Mac is online, add BlueBot's [MCP server](https://usebluebot.com/docs/mcp) with a read-only key. ### Cursor Add the docs to Cursor's index once: open **Cursor Settings → Indexing & Docs**, press **Add Doc**, and give it `https://usebluebot.com/docs`. Then mention `@BlueBot` in a chat to have it read them. For a project, put the same instructions as the `CLAUDE.md` above in a rule, such as `.cursor/rules/bluebot.mdc`. ### Any other agent Paste the address of `llms.txt`, or of the one page that matters, into the chat, and ask it to read it before it writes code. An agent that can't fetch pages can take the Markdown pasted in: use **Copy as Markdown**. ## A prompt to start an integration Paste this into your coding agent, with the first line changed to what you're building. It sends the agent to the right pages and gives it the rules that keep an integration from texting someone twice or by mistake. ```text title="Prompt" I want to build: . Use BlueBot to send and receive the texts. Before you write code, read https://usebluebot.com/llms.txt, then the Markdown pages it links that this needs (at least the quickstart, sending a text, webhooks and verifying signatures). Work from https://usebluebot.com/openapi.v1.json for exact fields. Follow these rules: - Use the official SDK: @usebluebot/sdk for TypeScript or the Python package bluebot. The npm package named "bluebot" is not ours. - Read the key from BLUEBOT_API_KEY. Never put a key in code. - Send an Idempotency-Key on every POST, built from something stable (the form response id, the row id), so a retry never sends a second text. - A text that can't go yet comes back "queued" with a "hold" saying why. That's success; don't retry it. - Put my own record id in the text's metadata so webhooks can be matched back to it. - Verify every webhook with BLUEBOT_WEBHOOK_SECRET against the raw body, skip event ids you've already handled, and answer 2xx within 10 seconds, doing slow work afterwards. - Never text anyone but BLUEBOT_TEST_NUMBER while we're testing. Tell me your plan before you write code. ``` > **Test runs send real texts.** BlueBot sends real texts from your own number, and there's no sandbox phone. Keep an agent's test runs pointed at your own number, and read any send it wants to make before you let it run. ## Next - [Quickstart](https://usebluebot.com/docs/quickstart): Text yourself in four steps. - [MCP server](https://usebluebot.com/docs/mcp): Let an assistant use your texts directly. --- Source: https://usebluebot.com/docs/zapier # Zapier > BlueBot’s Zapier app texts from your own number and starts a Zap when a text comes in, a text changes state or somebody texts STOP. This page lists every trigger, action and search and each of their fields. The BlueBot app for Zapier joins your number to the apps Zapier connects. A form fills in and the person gets a text from you; a client texts back and the reply goes into your CRM; a text fails and you hear about it in Slack. It has five triggers, eight actions and three searches. Every trigger is instant. BlueBot posts to Zapier as soon as something happens, so a Zap never waits for a polling interval. Texts sent through Zapier go through the same sending rules as everything else: pacing, quiet hours, the daily limits and your stop list. A text that can’t go yet is accepted and held, never refused, and the Zap step still succeeds. Each one shows `source: "zapier"` in BlueBot, so you can tell them apart in the inbox and in your numbers. ## Connect your account The app signs in with an API key. Make one for Zapier alone, so you can revoke it without touching anything else. ### Make a key In BlueBot, open **Developers → API keys** and press **New key**. Name it “Zapier” and tick every scope: the triggers need `webhooks:manage` to subscribe, the contact steps need `contacts:read` and `contacts:write`, and the connection test needs `account:read`. Copy the key. It starts with `bb_live_` and is shown once. ### Add it in Zapier Add a BlueBot step to a Zap and press **Sign in**. Paste the key into **API Key** and press **Yes, Continue to BlueBot**. Zapier checks it with `GET /v1/me` and names the connection after your account’s email address. ### Check the connection | You got | It means | Do next | | --------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | A connection named after your email | The key works and can read the account. | Pick a trigger or an action. | | “That API key isn’t valid” | BlueBot doesn’t know the key: mistyped, revoked, or only part of it pasted. | Copy the key again, or make a new one. [unauthorized](https://usebluebot.com/docs/errors/unauthorized) | | “This key needs the account:read scope” | The key was made without `account:read`. | Make a new key with every scope ticked. [insufficient_scope](https://usebluebot.com/docs/errors/insufficient_scope) | One connection serves every Zap in your Zapier account. To change keys later, open **My Apps** in Zapier, find BlueBot and press **Reconnect**. A revoked key makes every Zap that uses it fail with `unauthorized` until you reconnect. ## Triggers All five are instant. When you turn a Zap on, the app makes a webhook in your BlueBot account that points at the Zap, subscribed to only the events that trigger needs; when you turn the Zap off or delete it, the app deletes that webhook. You can see them under **Developers → Webhooks** in BlueBot, each described as “Zapier: ” and the Zap’s name. Don’t delete one there while its Zap is on, or the Zap stops hearing anything. Each trigger’s output is the record as the API returns it (a message, a contact or a sequence enrollment), plus three fields about the event: `event_id`, `event_type` and `event_timestamp`. Zapier flattens nested fields when you map them, so `metadata.record_id` shows as **Metadata Record ID** and each file as a line item under **Files**. When you press **Test trigger**, the app reads your three newest matching records from BlueBot, so you map real fields from your own account. If there are none yet, it shows the sample below. ### New Incoming Text Starts when somebody texts your number. Pictures and voice memos come with it as files. BlueBot subscribes the Zap to `message.received`. | Field | Key | Type | Required | Help text | | ------------------- | ---------------- | ------- | -------- | ------------------------------------------------------------------------------------------ | | Include Group Chats | `include_groups` | Boolean | No | Also start for texts in group chats. Default: yes. | | Only From | `only_from` | String | No | Only start for texts from this phone number or email address. Leave it empty for everyone. | BlueBot copies the texts of people you’ve texted through it, unless you set **Which chats to copy** to **Everything** in **Settings → Sending rules**. A text from somebody you’ve never texted through BlueBot doesn’t start the Zap until you change that setting. ```json title="Sample: New Incoming Text" { "id": "msg_4Tz8QvN2pLk7RcYw1HmXe3", "object": "message", "direction": "in", "state": "received", "to": "+15555550100", "from": "+15555550123", "chat_id": null, "contact_id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "conversation_id": "conv_1Hq2LxW9cVbN3mKpRtYs0a", "text": "Yes, 3 PM works. See you there.", "files": [], "service": "imessage", "source": "mac", "metadata": {}, "reply_to_id": null, "received_at": "2026-11-13T19:44:12.000Z", "event_id": "evt_7Kd3WnP9xQ2mLc5RvT8bHy", "event_type": "message.received", "event_timestamp": "2026-11-13T19:44:13.000Z" } ``` ### Text Status Changed Starts when a text you sent changes state: sent, delivered, read, failed and so on. Use it to write the result back to the record that caused the text, or to hear about failures. | Field | Key | Type | Required | Help text | | ------------------ | -------------- | ------------------------------------------------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------- | | States | `states` | Multiple select: Sent, Delivered, Read, Failed, Unconfirmed, Expired, Canceled | Yes | Which changes start the Zap. Pick Failed, Unconfirmed and Expired to hear only about texts that didn’t go. | | Only With Metadata | `metadata_key` | String | No | Only start for texts whose metadata has this key, such as `record_id`, so texts you typed by hand don’t start it. | BlueBot subscribes the Zap to the matching events (`message.sent`, `message.delivered` and so on). Delivered and read come only from iMessage, and read only from people who share read receipts, so an SMS goes from sent to nothing more. [Message states](https://usebluebot.com/docs/concepts/message-states) has the whole picture. The output is the message as it is after the change, so `state`, `failure` and `metadata` are all there. A failed text carries `failure.reason`: `not_on_imessage`, `messages_refused` or `file_unavailable`. ### New Contact Starts when a person is added to your contacts: by a first text either way, an import, the API, the website widget, a Text Hook or the dashboard. BlueBot subscribes the Zap to `contact.created`. It has no fields. ```json title="Sample: New Contact" { "id": "ct_1Hq2LxW9cVbN3mKpRtYs0a", "object": "contact", "handle": "+15555550123", "kind": "phone", "first_name": "Maria", "last_name": "Lopez", "company": null, "tags": ["lead"], "custom_fields": {}, "stopped": false, "created_via": "widget", "created_at": "2026-11-13T19:40:02.000Z", "event_id": "evt_2Mx8TqR4vB9nKd1WcY6pLs", "event_type": "contact.created", "event_timestamp": "2026-11-13T19:40:02.000Z" } ``` ### Contact Stopped Starts when somebody goes on your stop list: they replied with a stop word, or somebody added them in the dashboard or through the API. Their queued texts are already canceled. Use it to mark them as opted out in your CRM. BlueBot subscribes the Zap to `contact.stopped`. | Field | Key | Type | Required | Help text | | ------------ | -------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------- | | Only Replies | `only_replies` | Boolean | No | Only start when the person texted a stop word themselves, not when somebody added them. Default: no. | The output is the contact with `stopped: true`, `stopped_at` and `stop_source` (`reply`, `dashboard`, `api`, `import` or `admin`). ### Sequence Completed Starts when a person comes to the end of a sequence, or leaves it early because they replied, texted STOP or were taken off. BlueBot subscribes the Zap to `sequence.completed`. | Field | Key | Type | Required | Help text | | -------- | ------------- | ----------------------------------- | -------- | ---------------------------------------------------------------- | | Sequence | `sequence_id` | Dropdown of your sequences | No | Only start for this sequence. Leave it empty for every sequence. | | Outcome | `outcome` | Select: Any, Completed, Ended early | No | Default: Any. | The output is the enrollment: `id` (`enr_…`), `sequence_id`, `contact_id`, `state` (`completed`, `stopped` or `canceled`), `stop_reason` (`replied`, `stopped`, `canceled`, `sequence_archived` or `failed`) and `completed_at`, plus the contact’s phone number and name as `contact_handle`, `contact_first_name` and `contact_last_name`. ## Actions Every action that sends a text carries an `Idempotency-Key` made from the Zap’s id and a hash of the step’s inputs. If Zapier runs the step again after a timeout, or you replay the run, BlueBot answers with the text it already accepted instead of sending a second one. The key lasts 24 hours. That has one effect worth knowing: if the same Zap sends exactly the same words to the same person twice within 24 hours, the second run gets the first text back and nothing new goes. When a Zap should be able to repeat itself, map something unique into **Metadata**, such as the record’s id or the date. ### Send Text Sends a text from your number now, or as soon as your sending rules allow. The answer is the message, usually `state: "queued"`; it moves to `sent` within seconds once your Mac takes it. | Field | Key | Type | Required | Help text | | ---------- | ------------ | ---------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | To | `to` | String | Yes, unless Group Chat is set | A mobile number in any format, such as `(555) 555-0123`; US numbers can leave off the +1. Or an email address the person uses for iMessage. | | Group Chat | `chat_id` | Dropdown of your group chats | No | Text an existing group chat instead of one person. Leave To empty. BlueBot can’t start a new group. | | Text | `text` | Long text | Yes, unless Files is set | The words, up to 2,000 characters. | | Files | `files` | File, list | No | Up to 10 pictures, videos, audio files, PDFs or contact cards (.vcf), each up to 25 MB, sent after the words. The app uploads each one first. | | Service | `service` | Select: Auto, iMessage, SMS | 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 SMS. iMessage never falls back. SMS needs your iPhone with Text Message Forwarding. | | Give Up At | `expires_at` | Date and time | No | If the text is still waiting at this time, it expires instead of going late. Default: 24 hours after it’s due (a setting). | | Metadata | `metadata` | Key and value pairs | No | Your own data, kept on the text and sent back on every status change: up to 20 keys of up to 40 characters, values up to 500. Put the record’s id here. | The output is the message. Map `id` into a later Cancel Scheduled Text, or keep it on your record. If the text is held (quiet hours, your Mac offline, a daily limit), `hold.reason` and `hold.until` say why and until when, and the step still succeeds. A text to somebody on your stop list succeeds as a step but comes back `state: "canceled"` with `cancel_reason: "opted_out"`, and nothing is sent. Add a Filter after the step on **State** if later steps should only run for texts that will go. ### Send Template Sends one of your BlueBot templates, filled in with the values you map. | Field | Key | Type | Required | Help text | | --------- | ------------- | --------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | To | `to` | String | Yes | A mobile number or an iMessage email address, as on Send Text. | | Template | `template_id` | Dropdown of your templates | Yes | The template to send. Its words appear under the field once you pick it. | | Variables | `variables` | Key and value pairs | No | A value for each `{{name}}` in the template, keyed by the name without braces. A variable you leave out comes from the contact when BlueBot knows it (`first_name`, `last_name`, `company` and custom fields) or from your business name (`business`), else from the template’s own fallback. One with none of those stops the send, naming it. | | Service | `service` | Select: Auto, iMessage, SMS | No | As on Send Text. | | Metadata | `metadata` | Key and value pairs | No | As on Send Text. | The output is the message, with the template’s words filled in as `text`. A variable with no value and no fallback fails the step with `invalid_request` and the variable’s name, and nothing is sent. ### Schedule Text Schedules a text for a time. Until it’s due it can be changed in BlueBot or canceled with Cancel Scheduled Text. When the time comes it goes through your sending rules like any other text, so a text scheduled inside quiet hours waits for morning. | Field | Key | Type | Required | Help text | | ---------- | ------------ | ---------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | To | `to` | String | Yes, unless Group Chat is set | As on Send Text. | | Group Chat | `chat_id` | Dropdown of your group chats | No | As on Send Text. | | Text | `text` | Long text | Yes, unless Files is set | As on Send Text. | | Send At | `send_at` | Date and time | Yes | When to send it. Zapier reads times like “tomorrow 9am” in your Zapier time zone. | | Files | `files` | File, list | No | As on Send Text. Each file is uploaded when the step runs and kept with the text for up to 30 days, so schedule a text with files at most 30 days ahead. | | Service | `service` | Select: Auto, iMessage, SMS | No | As on Send Text. | | Give Up At | `expires_at` | Date and time | No | Default: 24 hours after Send At. | | Metadata | `metadata` | Key and value pairs | No | As on Send Text. | The output is the message with `state: "scheduled"` and `send_at`. Keep its `id` if anything might need to cancel it, such as a booking being moved. ### Cancel Scheduled Text Cancels a text that hasn’t gone yet: one that’s scheduled, or queued and waiting on a hold. | Field | Key | Type | Required | Help text | | ---------- | ---- | ------ | -------- | ---------------------------------------------- | | Message ID | `id` | String | Yes | The `msg_` id from the step that scheduled it. | The output is the message with `state: "canceled"` and `cancel_reason: "requested"`. A text the Mac already took can’t be called back: the step fails with `conflict`, “Your Mac already took that text, so it can’t be canceled.” Use a Filter on the earlier step’s **State** if that can happen often. ### Create or Update Contact Finds the person by phone number or email address and updates them, or adds them when BlueBot doesn’t know them. | Field | Key | Type | Required | Help text | | -------------- | --------------- | ------------------- | -------- | ---------------------------------------------------------------------------------------------------------- | | Phone or Email | `handle` | String | Yes | The person’s mobile number in any format, or an email address they use for iMessage. | | First Name | `first_name` | String | No | | | Last Name | `last_name` | String | No | | | Company | `company` | String | No | | | Tags | `tags` | String, list | No | Tags to add. Tags they already have stay. | | Custom Fields | `custom_fields` | Key and value pairs | No | Your own fields, for templates. A key you send replaces that field; keys you don’t send stay as they were. | A field you leave empty doesn’t clear what BlueBot has. The output is the contact, with `created: true` when the person was new. ### Add Tag Adds tags to a person, adding them as a contact first if BlueBot doesn’t know them. | Field | Key | Type | Required | Help text | | -------------- | -------- | ------------ | -------- | ---------------------------------------------------------- | | Phone or Email | `handle` | String | Yes | | | Tags | `tags` | String, list | Yes | One or more tags. Tags are matched without regard to case. | The output is the contact with all of its tags. ### Enroll in Sequence Starts a person on one of your sequences. The sequence’s texts go through your sending rules, and by default it stops the moment they reply or text STOP. | Field | Key | Type | Required | Help text | | -------------- | ------------- | -------------------------- | -------- | ---------------------------------------------------------------------------- | | Phone or Email | `handle` | String | Yes | Added as a contact if BlueBot doesn’t know them. | | Sequence | `sequence_id` | Dropdown of your sequences | Yes | | | Variables | `variables` | Key and value pairs | No | Values for the `{{name}}`s in the sequence’s templates, kept for every step. | A person already in the sequence isn’t started again; the step succeeds and returns the enrollment they’re in. A person on your stop list isn’t enrolled, and the step stops the run as Halted rather than Errored, with BlueBot’s reason. The output is the enrollment: `id` (`enr_…`), `sequence_id`, `contact_id`, `state` and the time of the first text. ### Remove from Sequence Takes a person off a sequence. Their texts from it that haven’t gone are canceled. | Field | Key | Type | Required | Help text | | -------------- | ------------- | -------------------------- | -------- | ----------------------------------------------- | | Phone or Email | `handle` | String | Yes | | | Sequence | `sequence_id` | Dropdown of your sequences | No | Leave it empty to take them off every sequence. | The output lists the enrollments that ended. A person in none of them isn’t an error; the list is empty. ## Searches ### Find Contact Finds a person by phone number or email address. Turn on **Create BlueBot Contact if it doesn’t exist yet?** to add them when there’s no match, with the fields of Create or Update Contact. | Field | Key | Type | Required | Help text | | -------------- | -------- | ------ | -------- | ---------------------------------------------- | | Phone or Email | `handle` | String | Yes | Any format; BlueBot compares numbers in E.164. | ### Find Conversation Finds your conversation with a person and its newest text, to check whether they’ve answered before you send something else. | Field | Key | Type | Required | Help text | | -------------- | -------- | ------ | -------- | --------- | | Phone or Email | `handle` | String | Yes | | The output is `conversation_id`, `contact_id`, `last_message_text`, `last_message_direction` (`in` or `out`), `last_message_at` and `waiting_on_you`, which is true when the newest text came from them. ### Check iMessage Says whether your newest chat with a number used iMessage or SMS. It reads your own Messages history through your Mac and waits up to 15 seconds for it; it calls `GET /v1/lookup`. | Field | Key | Type | Required | Help text | | -------------- | -------- | ------ | -------- | --------- | | Phone or Email | `number` | String | Yes | | The output is `service` (`imessage`, `sms` or `unknown`), `basis` (`history`, `none`, `device_offline` or `timeout`) and `last_seen_at`. Apple gives no way to ask about a number you’ve never texted, so a new number answers `unknown` with `basis: "none"`. You don’t need this step before sending: **Service: Auto** already tries iMessage and falls back to SMS. ## When a step fails The app turns BlueBot’s error into Zapier’s, with BlueBot’s message, its hint, the error code and the request id, such as “Send `to` or `chat_id`. Check the fields named in the message and send it again. (invalid_request, req_1Hq2LxW9cVbN3mKpRtYs0a)”. Quote the request id if you write to support. | BlueBot answers | Zapier shows | What to do | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `invalid_request` (400) | The step shows as Errored and names the field that didn’t fit. | Fix the mapping. A number BlueBot can’t read is the usual cause. [More](https://usebluebot.com/docs/errors/invalid_request) | | `unauthorized` (401) | The connection as expired, and an email asking you to reconnect. | Reconnect with a working key. [More](https://usebluebot.com/docs/errors/unauthorized) | | `plan_lapsed` (402) | The step as Errored. | Update the card on the **Billing** page. [More](https://usebluebot.com/docs/errors/plan_lapsed) | | `insufficient_scope` (403) | The step as Errored, naming the scope. | Reconnect with a key that has every scope. [More](https://usebluebot.com/docs/errors/insufficient_scope) | | `account_held` (403) | The step as Errored. | Write to support. [More](https://usebluebot.com/docs/errors/account_held) | | `not_found` (404) | The step as Errored. | Check the id you mapped. [More](https://usebluebot.com/docs/errors/not_found) | | `conflict` (409) | The step as Errored. | The record changed first, such as a text already sent. [More](https://usebluebot.com/docs/errors/conflict) | | `rate_limited` (429) | Nothing at first: the app asks Zapier to try the step again after `retry_after` seconds. | Nothing, unless one key runs many busy Zaps. [More](https://usebluebot.com/docs/errors/rate_limited) | | `internal`, `upstream_failed`, `unavailable` (5xx) | The step as Errored. With Autoreplay on, Zapier tries again, and the idempotency key keeps that from sending twice. | Try again, or replay the run. [More](https://usebluebot.com/docs/errors/internal) | A held text is never an error. A text that fails after it was accepted (Messages refused it, or it was iMessage-only to somebody not on iMessage) isn’t either, because the step already succeeded; catch it with **Text Status Changed** on Failed. ## Your first Zap Maria runs a cleaning company and keeps new leads in a Google Sheet. This Zap texts each new lead from her own number. ### Pick the trigger Make a Zap with Google Sheets’ **New Spreadsheet Row** trigger and choose the sheet. Press **Test trigger** so Zapier loads a row. ### Add Send Text Add a BlueBot step, choose **Send Text** and pick your connection. Map the row’s phone column into **To**, and write the text with the row’s name column in it: “Hi Dana, it’s Maria from Sparkle Cleaning. Thanks for asking about a quote. When is a good time to come by?” Map the row number into **Metadata** under `row`. ### Test it with your own number Before testing, type your own mobile number into **To** in place of the mapped column. Press **Test step**. | You got | It means | Do next | | -------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------- | | A message with `state: "queued"`, and the text on your phone a few seconds later | It works end to end. | Map the phone column back into **To** and turn the Zap on. | | `state: "queued"` with `hold.reason: "quiet_hours"` | It’s inside your quiet hours, so the text waits for morning. | Nothing; it goes when quiet hours end. Change them in **Settings → Sending rules**. | | `state: "queued"` with `hold.reason: "device_offline"` | Your Mac hasn’t checked in for 2 minutes. | Wake the Mac and open BlueBot on it. The text goes once it’s back. | | `invalid_request` naming `to` | The number couldn’t be read. | Check what you typed in **To**. | ### Turn it on Turn the Zap on. Each new row now gets a text within seconds, paced 20 seconds apart by default so a big import doesn’t send a burst. Next, add a second Zap with **New Incoming Text** that writes each reply back to the sheet, or follow a full walkthrough in [A Google Sheets row sends a text](https://usebluebot.com/docs/guides/google-sheets). The [recipes](https://usebluebot.com/docs/recipes) have one page per app with the exact fields to map. ## Using the API directly in a Zap For anything the app doesn’t have a step for, use Zapier’s **Webhooks by Zapier** or **Code by Zapier** with a key and the [API](https://usebluebot.com/docs/api). Send `Authorization: Bearer `, an `Idempotency-Key` on every POST, and the header `BlueBot-Client: zapier` so the text shows as sent from Zapier. --- Source: https://usebluebot.com/docs/make # Make > BlueBot’s Make app sends texts from your own number and starts scenarios when a text comes in, a text changes state or somebody texts STOP. Every module, its fields and what it outputs. The BlueBot app for Make (once called Integromat) puts your number into any scenario. It has five instant triggers, eight actions, four searches and **Make an API Call** for everything else. The triggers are webhooks: BlueBot posts each event to Make as it happens, so a scenario starts within a second or two and uses no operations on polling. Texts sent from Make go through your sending rules like every other text (pacing, quiet hours, the daily limits, the stop list) and show `source: "make"` in BlueBot. ## Connect your account ### Make a key In BlueBot, open **Developers → API keys** and press **New key**. Name it “Make” and tick every scope. The watch modules need `webhooks:manage` to attach their webhooks, the contact modules need `contacts:read` and `contacts:write`, and the connection check needs `account:read`. Copy the key; it is shown once. ### Add the connection in Make Add any BlueBot module to a scenario. Next to **Connection**, press **Add**, give the connection a name, paste the key into **API Key** and press **Save**. Make checks it with `GET /v1/me`. ### Check it | You got | It means | Do next | | --------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------- | | The connection saved and selected | The key works. | Fill in the module. | | “That API key isn’t valid” | The key is mistyped, revoked or cut short. | Paste it again or make a new key. [unauthorized](https://usebluebot.com/docs/errors/unauthorized) | | “This key needs the account:read scope” | The key is missing a scope. | Make a key with every scope ticked. [insufficient_scope](https://usebluebot.com/docs/errors/insufficient_scope) | To change keys later, open **Connections** in Make, find the BlueBot connection and press **Reauthorize**. Every module and webhook that uses the connection follows it. ## Instant triggers Each watch module uses a Make webhook. When you add the module, press **Create a webhook**, name it and press **Save**. Make then makes a webhook in your BlueBot account that points at it, subscribed to the events the module needs, and shows it under **Developers → Webhooks** in BlueBot described as “Make: ” and the webhook’s name. Deleting the webhook in Make deletes it in BlueBot too. A module’s settings (such as the states to watch) are fixed when the webhook is made; to change them, make a new webhook. When the scenario is off, Make keeps what arrives in the webhook’s queue and works through it when you turn the scenario back on. BlueBot also retries a delivery Make doesn’t accept for about three days, with each event’s id the same on every try. Each bundle is the record as the API returns it (a message, a contact or an enrollment), plus `event_id`, `event_type` and `event_timestamp`. A text’s files are an array; put an **Iterator** after the trigger to handle each picture on its own. ### Watch Incoming Texts Starts when somebody texts your number. BlueBot sends it `message.received`. | Field | Type | Required | Help | | ------------------- | ------- | -------- | --------------------------------------------------- | | Webhook | Webhook | Yes | Create one here. | | Include group chats | Boolean | No | Also start for texts in group chats. Default: yes. | | Only from | Text | No | Only texts from this phone number or email address. | BlueBot copies the texts of people you’ve texted through it, unless you set **Which chats to copy** to **Everything** in **Settings → Sending rules**. ### Watch Text Status Changes Starts when a text you sent changes state. | Field | Type | Required | Help | | ---------------------- | ------------------------------------------------------------------------------ | -------- | ---------------------------------------------------------------------------- | | Webhook | Webhook | Yes | | | States | Select, several: Sent, Delivered, Read, Failed, Unconfirmed, Expired, Canceled | Yes | The changes to watch. BlueBot subscribes to the matching `message.*` events. | | Only with metadata key | Text | No | Only texts whose metadata has this key, such as `record_id`. | The bundle is the message after the change, with `state`, `failure` and `metadata`. Delivered and read come only from iMessage, and read only from people who share read receipts. ### Watch New Contacts Starts when a person is added to your contacts, however they were added. BlueBot sends it `contact.created`. Its only field is the webhook. ### Watch Contact Stops Starts when somebody goes on your stop list. Their queued texts are already canceled. BlueBot sends it `contact.stopped`. | Field | Type | Required | Help | | ------------ | ------- | -------- | ----------------------------------------------------------- | | Webhook | Webhook | Yes | | | Only replies | Boolean | No | Only people who texted a stop word themselves. Default: no. | ### Watch Completed Sequences Starts when a person reaches the end of a sequence or leaves it early. BlueBot sends it `sequence.completed`. | Field | Type | Required | Help | | -------- | ----------------------------------- | -------- | --------------------------- | | Webhook | Webhook | Yes | | | Sequence | Select from your sequences | No | Empty means every sequence. | | Outcome | Select: Any, Completed, Ended early | No | Default: Any. | The bundle is the enrollment (`id`, `sequence_id`, `contact_id`, `state`, `stop_reason`, `completed_at`) with `contact_handle`, `contact_first_name` and `contact_last_name`. ## Actions Every action that sends a text carries an `Idempotency-Key` unique to that run of that module. If Make runs the module again in the same execution (a **Break** error handler retrying it, say), BlueBot answers with the text it already accepted instead of sending a second one. ### Send Text Sends a text from your number now, or as soon as your sending rules allow. The output bundle is the message, usually `state: "queued"`. | Field | Type | Required | Help | | ---------- | ----------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | To | Text | Yes, unless Group chat is set | A mobile number in any format (US numbers can leave off +1), or an email address the person uses for iMessage. | | Group chat | Select from your group chats | No | Text an existing group chat instead. Leave To empty. | | Text | Text | Yes, unless Files has an item | Up to 2,000 characters. | | Files | Array of files (file name and data) | No | Up to 10, each up to 25 MB: pictures, video, audio, PDFs, contact cards (.vcf). Map the output of an HTTP **Get a file** module or any module that returns a file. | | Service | Select: Auto, iMessage, SMS | No | Auto (the default) uses what the newest chat with the person used, else iMessage, and resends an iMessage that never went once as SMS. iMessage never falls back. SMS needs your iPhone with Text Message Forwarding. | | Give up at | Date | No | If it’s still waiting then, it expires. Default: 24 hours after it’s due. | | Metadata | Array of key and value | No | Up to 20 keys of up to 40 characters, values up to 500. Sent back on every status change, so put the record’s id here. | A held text (quiet hours, your Mac offline, a daily limit) still succeeds, with `hold.reason` and `hold.until` in the bundle. A text to somebody on your stop list succeeds with `state: "canceled"` and `cancel_reason: "opted_out"`; add a filter on **State** if later modules should only run for texts that will go. ### Send Template | Field | Type | Required | Help | | --------- | -------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | To | Text | Yes | As on Send Text. | | Template | Select from your templates | Yes | | | Variables | Array of key and value | No | A value for each `{{name}}` in the template, keyed by the name without braces. A variable you leave out comes from the contact when BlueBot knows it (`first_name`, `last_name`, `company` and custom fields) or from your business name (`business`), else from the template’s own fallback. One with none of those stops the send, naming it. | | Service | Select | No | As on Send Text. | | Metadata | Array of key and value | No | As on Send Text. | The bundle is the message, with the template’s words filled in as `text`. A variable with no value and no fallback fails the module with `invalid_request` and the variable’s name, and nothing is sent. ### Schedule Text The fields of Send Text plus **Send at** (Date, required). The text waits as `scheduled` until then, then goes through your sending rules, so one due inside quiet hours goes in the morning. Files are uploaded when the module runs, and an unsent file is deleted after a day, so schedule texts with files at most a day ahead. Keep the output’s `id` to cancel it later. ### Cancel Scheduled Text | Field | Type | Required | Help | | ---------- | ---- | -------- | -------------------------------------------- | | Message ID | Text | Yes | The `msg_` id of a scheduled or queued text. | The bundle is the message with `state: "canceled"`. A text the Mac already took can’t be called back, and the module fails with `conflict`. ### Create or Update Contact | Field | Type | Required | Help | | -------------- | ---------------------- | -------- | --------------------------------------------------- | | Phone or email | Text | Yes | Finds the person by it, or adds them. | | First name | Text | No | | | Last name | Text | No | | | Company | Text | No | | | Tags | Array of text | No | Added to the tags they have. | | Custom fields | Array of key and value | No | Each key you send replaces that field; others stay. | An empty field doesn’t clear what BlueBot has. The bundle is the contact, with `created: true` when they were new. ### Add Tag | Field | Type | Required | Help | | -------------- | ------------- | -------- | ------------------------------- | | Phone or email | Text | Yes | Added as a contact if new. | | Tags | Array of text | Yes | Matched without regard to case. | ### Enroll in Sequence | Field | Type | Required | Help | | -------------- | -------------------------- | -------- | ------------------------------------------ | | Phone or email | Text | Yes | Added as a contact if new. | | Sequence | Select from your sequences | Yes | | | Variables | Array of key and value | No | Values for the sequence’s templates. | | Metadata | Array of key and value | No | Put on every text the sequence sends them. | Somebody already in the sequence, or on your stop list, isn’t started, and the module fails with `conflict`. Add an **Ignore** error handler to the module if the scenario should carry on. ### Remove from Sequence | Field | Type | Required | Help | | -------------- | -------------------------- | -------- | ----------- | | Phone or email | Text | Yes | Any format. | | Sequence | Select from your sequences | Yes | | Their texts from it that haven’t gone are canceled. The bundle’s `enrollments` lists the enrollment that ended, or nothing when they weren’t in the sequence, which isn’t an error. To take somebody off every sequence, add a module for each one. ## Searches ### Search Messages Lists texts, newest first, one bundle each. It calls `GET /v1/messages` and follows the cursor for you. | Field | Type | Required | Help | | -------------- | ----------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------- | | Direction | Select: In, Out | No | | | State | Select: Scheduled, Queued, Sending, Sent, Delivered, Read, Failed, Unconfirmed, Expired, Canceled, Received | No | | | Person | Text | No | Only texts with this number or email address. | | Group chat | Select from your group chats | No | | | Source | Select | No | Such as API, Make, Zapier, Mac (typed by hand). | | Created from | Date | No | | | Created before | Date | No | | | Limit | Number | No | The most bundles to return. Default 10, up to 1,000. | ### Find Contact | Field | Type | Required | Help | | -------------- | ---- | -------- | ----------- | | Phone or email | Text | Yes | Any format. | Returns one bundle for the contact, or none. Add a router with **Create or Update Contact** on the empty path to add people you don’t have. ### Find Conversation | Field | Type | Required | Help | | -------------- | ---- | -------- | ----------- | | Phone or email | Text | Yes | Any format. | Returns one bundle, or none when you’ve never texted them: `conversation_id`, `contact_id`, `last_message_id`, `last_message_text`, `last_message_direction`, `last_message_state`, `last_message_at` and `waiting_on_you` (true when the newest text came from them). Texts in a group chat they’re in, and texts of yours that never went, don’t count. ### Check iMessage | Field | Type | Required | Help | | -------------- | ---- | -------- | ---- | | Phone or email | Text | Yes | | Calls `GET /v1/lookup`, which reads your own Messages history through your Mac and waits up to 15 seconds. Returns `service` (`imessage`, `sms` or `unknown`), `basis` and `last_seen_at`. A number you’ve never texted is `unknown`: Apple gives no way to ask. You don’t need it before sending, since **Service: Auto** tries iMessage first. ## Make an API Call Calls any endpoint in the [API](https://usebluebot.com/docs/api) with your connection’s key. | Field | Type | Required | Help | | ------------ | -------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | URL | Text | Yes | The path after `https://usebluebot.com/api/v1`, such as `/messages` or `/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a/deliveries`. | | Method | Select: GET, POST, PATCH, DELETE | Yes | | | Headers | Array of key and value | No | `Authorization` and `BlueBot-Client: make` are added for you. Add an `Idempotency-Key` on a POST that must not run twice, such as the triggering record’s id. | | Query String | Array of key and value | No | | | Body | Text (JSON) | No | | The bundle is the status code, the headers and the body. A list answers one page; read `next_cursor` and call again with it as `cursor` for the next. ## When a module fails The app turns BlueBot’s error into Make’s, with BlueBot’s message, its hint, the code and the request id. Quote the request id if you write to support. | BlueBot answers | Make raises | What happens | | ---------------------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------ | | `invalid_request` (400), `payload_too_large` (413), `unsupported_media_type` (415) | DataError | The module fails on that bundle. Fix the mapping; the message names the field. | | `unauthorized` (401) | InvalidAccessTokenError | Make stops the scenario until you reauthorize the connection. | | `plan_lapsed` (402), `insufficient_scope`, `forbidden`, `account_held` (403) | InvalidConfigurationError | Make turns the scenario off. Fix the account or the key, then turn it on. | | `not_found` (404), `conflict`, `idempotency_conflict`, `limit_reached` (409) | DataError | The module fails on that bundle. | | `rate_limited` (429) | RateLimitError | Add a **Break** error handler to try again; wait at least `retry_after` seconds. | | `internal`, `upstream_failed`, `unavailable` (5xx) | ConnectionError | Add a **Break** handler to try again. The idempotency key keeps a retried send from going twice. | A held text is not an error, and neither is a text that fails after it was accepted: catch those with **Watch Text Status Changes**. ## Test a scenario ### Build it with your own number Add **Send Text**, type your own mobile number in **To** and some words in **Text**. ### Run once Press **Run once**. For a scenario that starts with a watch module, press **Run once** first and then cause the event: text your BlueBot number from another phone that you’ve texted through BlueBot before. | You got | It means | Do next | | ---------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | A bundle with `state: "queued"` and the text on your phone | It works. | Map the real field into **To** and schedule the scenario. | | `hold.reason: "quiet_hours"` | It’s quiet hours; it waits for morning. | Nothing, or change quiet hours in **Settings → Sending rules**. | | `hold.reason: "device_offline"` | Your Mac is asleep or BlueBot isn’t running on it. | Wake the Mac; held texts go once it checks in. | | The watch module still waiting | No event reached the webhook. | Check **Which chats to copy**, and that the webhook is listed under **Developers → Webhooks** in BlueBot. | The [recipes](https://usebluebot.com/docs/recipes) show the fields to map for many apps, and [Webhooks](https://usebluebot.com/docs/webhooks) explains what BlueBot sends. --- Source: https://usebluebot.com/docs/n8n # n8n > The BlueBot community node for n8n, with every resource and operation in the API and a trigger node that starts a workflow when a text arrives. Installing it on n8n Cloud and on your own server. `n8n-nodes-bluebot` adds two nodes to n8n. The **BlueBot** node calls every operation in the BlueBot API: send a text, schedule one, read texts, check a number, manage webhooks. The **BlueBot Trigger** node starts a workflow when something happens, such as a text coming in, through a webhook BlueBot posts to. Texts sent from n8n go through your sending rules like every other text and show `source: "n8n"` in BlueBot. Because n8n can run on your own server, a workflow’s logic and the data it reads stay on machines you control; only the texts themselves pass through BlueBot and your Mac. [A self-hosted n8n flow](https://usebluebot.com/docs/guides/n8n-self-hosted) walks through one. ## Install the node The package is `n8n-nodes-bluebot` on npm. It needs n8n 1.85 or newer. On your own n8n, as the owner or an admin: ### Open Community Nodes Go to **Settings → Community Nodes** and press **Install a community node**. ### Install it Type `n8n-nodes-bluebot`, tick the box that says you understand the risks of community nodes, and press **Install**. n8n downloads it and restarts its node list. Search the nodes panel for “BlueBot” to check. If your n8n can’t install from the panel (community packages turned off, no network from the container, or a read-only image), install it by hand in n8n’s folder and restart n8n: ```bash title="A manual install" mkdir -p ~/.n8n/nodes cd ~/.n8n/nodes npm install n8n-nodes-bluebot # then restart n8n ``` In Docker, run the same inside a volume mounted at `/home/node/.n8n`, or build your own image `FROM n8nio/n8n` with the package installed there, so it survives the container being replaced. In queue mode, every worker runs nodes too, so each worker’s image needs the package. n8n Cloud installs only community nodes that n8n has verified. The BlueBot node follows n8n’s rules for verified nodes; until n8n Cloud lists it, use n8n’s **HTTP Request** node there, as described in [Without the node](#without-the-node). ## Add your credentials ### Make a key In BlueBot, open **Developers → API keys**, press **New key**, name it “n8n” and tick the scopes your workflows need. The trigger needs `webhooks:manage`, and the credential test needs `account:read`. Copy the key; it is shown once. ### Make the credential In n8n, open **Credentials**, press **Add credential** and choose **BlueBot API**. | Field | Required | What it is | | -------- | -------- | ---------------------------------------------------------------------------------- | | API Key | Yes | Your `bb_live_…` key. n8n stores it encrypted with your instance’s encryption key. | | Base URL | No | Leave it as `https://usebluebot.com/api/v1`. | Press **Save**. n8n tests it with `GET /v1/me` and shows **Connection tested successfully**, or BlueBot’s message (such as “That key isn’t valid. It may have been revoked.”) when it fails. ## The BlueBot node Pick a **Resource**, then an **Operation**. Each operation is one API call, and every field matches the API’s field of the same name. The node sends one request per input item, so 30 items in means 30 texts, paced by your sending rules. | Resource | Operations | Calls | | ---------------- | --------------------------------------------------------------------- | ---------------------------------------- | | Message | Send Text, Get, Get Many, Update, Cancel, Resolve | [Messages](https://usebluebot.com/docs/api#messages) | | File | Upload, Get | [Files](https://usebluebot.com/docs/api#files) | | Chat | Get, Get Many | [Chats](https://usebluebot.com/docs/api#chats) | | Conversation | Get, Get Many | [Conversations](https://usebluebot.com/docs/api#conversations) | | Lookup | Check iMessage | [Lookup](https://usebluebot.com/docs/api#lookup) | | Device | Get Mac, Get BlueBot Status | [Device](https://usebluebot.com/docs/api#device) | | Account | Get | [Account](https://usebluebot.com/docs/api#account) | | Settings | Get, Update | [Settings](https://usebluebot.com/docs/api#settings) | | Stop | Add, Get Many, Remove | [Stops](https://usebluebot.com/docs/api#stops) | | Template | Get, Get Many | [Templates](https://usebluebot.com/docs/api#templates) | | Event | Get, Get Many | [Events](https://usebluebot.com/docs/api#events) | | Webhook | Create, Get, Get Many, Update, Delete, Send Test Event, Rotate Secret | [Webhooks](https://usebluebot.com/docs/api#webhooks) | | Webhook Delivery | Get Many, Replay | [Webhooks](https://usebluebot.com/docs/api#webhooks) | ### Message: Send Text | Field | Type | Required | What it is | | ------------- | --------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | Send To | Options: Person, Group Chat | Yes | Who gets it. Default: Person. | | To | String | For Person | A mobile number in any format (US numbers can leave off +1), or an email address the person uses for iMessage. | | Group Chat ID | String | For Group Chat | A `chat_` id from Chat → Get Many. BlueBot can’t start a new group. | | Text | String | Unless files are added or a template is set | Up to 2,000 characters. Use expressions such as `{{ $json.first_name }}`. | Under **Add Field**: | Field | Type | What it is | | ------------------ | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Files | Options: File IDs, Binary Properties | **File IDs** takes `file_` ids from File → Upload. **Binary Properties** names binary properties on the item (such as `data`), which the node uploads first. Up to 10, each up to 25 MB. | | Service | Options: Auto, iMessage, SMS | Auto (the default) uses what the newest chat with the person used, else iMessage, and resends an iMessage that never went once as SMS. SMS needs your iPhone with Text Message Forwarding. | | Send At | Date and time | Schedules the text. It waits as `scheduled` and can be changed or canceled until then. | | Expires At | Date and time | Give up if it’s still waiting then. Default: 24 hours after it’s due. | | Metadata | Key and value pairs | Up to 20 keys of up to 40 characters, values up to 500. Echoed on every webhook about the text. | | Template ID | String | Send one of your templates instead of words, by its `tpl_` id from Template → Get Many. Leave Text empty. | | Template Variables | Name and value pairs | A value for each `{{name}}` in the template. A variable you leave out comes from the contact (`first_name`, `last_name`, `company` and custom fields) or your business name (`business`), else from the template’s own fallback. One with none of those stops the send with `invalid_request`, naming it. | | Idempotency Key | String | Defaults to one made from the execution, the node and the item’s index, so **Retry On Fail** never sends a text twice. Set your own to make a send safe across executions, such as the record’s id. | The output item is the message, usually `state: "queued"`. A text that can’t go yet (quiet hours, your Mac offline, a daily limit) still succeeds, with `hold.reason` and `hold.until`. A text to somebody on your stop list comes back `state: "canceled"` with `cancel_reason: "opted_out"`. ### Message: Get Many | Field | Type | What it is | | ---------- | ---------- | ----------------------------------------------------------------------------------------------- | | Return All | Boolean | Follow the cursor to the end. Off by default. | | Limit | Number | How many to return when Return All is off. Default 50. | | Filters | Collection | Direction (In, Out), State, Person, Contact ID, Chat ID, Source, Created After, Created Before. | Each text is one output item, newest first. ### Message: Update, Cancel and Resolve **Update** changes a scheduled text before it’s due: Text, Send At, Expires At and Metadata (which replaces the metadata whole). **Cancel** takes a Message ID and cancels a scheduled or queued text; one the Mac already took fails with `conflict`. **Resolve** settles an `unconfirmed` text with Action **Mark Sent** (the person got it) or **Send Again** (a new text with the same words). ### File: Upload and Get **Upload** takes a binary property (default `data`) and an optional Name, and outputs the file with its `file_` id. **Get** takes a File ID and outputs the file; turn on **Download** to put its bytes in a binary property as well, which is how you save a picture or voice memo someone sent you. A file’s `url` works for an hour. ### Lookup: Check iMessage Takes a Number (a phone number or email address) and outputs `service` (`imessage`, `sms` or `unknown`), `basis` and `last_seen_at`. It reads your own Messages history through your Mac and waits up to 15 seconds. A number you’ve never texted is `unknown`, because Apple gives no way to ask. ### Webhook operations **Create** takes a URL (public `https`) and Events, and outputs the webhook with its signing `secret`, the one time the API shows it. **Send Test Event** sends an example of the type you pick to that webhook. **Rotate Secret** makes a new secret; the old one signs beside it for 24 hours. **Webhook Delivery → Replay** sends one delivery again. You rarely need these with the trigger node, which manages its own webhook. ### When a call fails The node shows BlueBot’s message as the error, its hint as the description, and the code, HTTP status and request id in the details, such as “Send `to` or `chat_id`.” with “Check the fields named in the message and send it again.” Quote the request id if you write to support. - Turn on **Retry On Fail** in the node’s settings for `rate_limited` (429) and the 5xx errors. The node waits at least the `retry_after` BlueBot asks for, and its default idempotency key keeps a retried send from going twice. - Turn on **Continue On Fail** to let the other items go on when one has a bad number. The failed item’s output carries the error. - A held text is not an error, and neither is a text that fails after it was accepted. Catch those with the trigger node on **Message Failed**, **Message Expired** and **Message Unconfirmed**. ## The BlueBot Trigger node Starts a workflow when BlueBot posts an event. It makes its own webhook in your BlueBot account when the workflow is activated, pointing at n8n’s production webhook URL, and deletes it when the workflow is deactivated. | Field | Type | Required | What it is | | ---------- | ---------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | Credential | BlueBot API | Yes | Needs `webhooks:manage`. | | Events | Multi-options | Yes | Any of the [event types](https://usebluebot.com/docs/webhooks/events), such as **Message Received**, **Message Failed** or **Contact Stopped**. Default: Message Received. | | Output | Options: Record, Whole Event | No | **Record** (the default) outputs `data`: the message, contact or enrollment. **Whole Event** adds the envelope: `id`, `type`, `api_version`, `timestamp`. | The node keeps the webhook’s signing secret in the workflow and checks `webhook-signature` on every request. A request without a valid signature, or more than five minutes old, is answered 401 and never starts the workflow, so nobody who learns the URL can fake a text. To try it, press **Listen for test event**. The node makes a webhook to n8n’s test URL for as long as it listens. Then text your BlueBot number from another phone, one you’ve texted through BlueBot before (BlueBot copies only those chats unless **Which chats to copy** is set to **Everything**). The event shows in the node’s output. | You got | It means | Do next | | -------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------ | | The event in the output | It works. | Build the rest of the workflow, then activate it. | | “BlueBot couldn’t reach this URL” when listening or activating | n8n’s webhook URL isn’t public `https`, so BlueBot refused it. | Set `WEBHOOK_URL` as in [On your own server](#on-your-own-server). | | Still listening after your text | No event came. | Check that your Mac is online and the chat is one BlueBot copies. | ## On your own server BlueBot only posts to public `https` addresses on the default port, and doesn’t follow redirects. A self-hosted n8n behind a reverse proxy must tell its nodes the public address, or the trigger node registers `http://localhost:5678/…`, which BlueBot refuses. ```bash title=".env for n8n behind a proxy" WEBHOOK_URL=https://n8n.example.com/ N8N_PROXY_HOPS=1 ``` - Put n8n behind a proxy that ends TLS (Caddy, nginx, Traefik) with a real certificate. A self-signed certificate fails BlueBot’s check. - For a laptop with no public address, run a tunnel such as `cloudflared tunnel --url http://localhost:5678` and set `WEBHOOK_URL` to the address it prints. Restart n8n after changing it. - Deactivate and activate a workflow after the address changes, so the trigger node makes a new webhook at the new address. - Keep n8n’s encryption key (`N8N_ENCRYPTION_KEY`) when you move servers, or the BlueBot credential can’t be read. ## Without the node Any n8n, n8n Cloud included, can call the API with the **HTTP Request** node. ### Make a header credential Make a **Header Auth** credential with Name `Authorization` and Value `Bearer bb_live_…`. ### Send a text Add an **HTTP Request** node: Method `POST`, URL `https://usebluebot.com/api/v1/messages`, Authentication **Generic Credential Type → Header Auth** with that credential. Under **Send Headers**, add `BlueBot-Client: n8n` and an `Idempotency-Key`, such as `{{ $execution.id }}-{{ $itemIndex }}`. Under **Send Body**, choose JSON and send `to` and `text`. To receive events without the trigger node, use n8n’s **Webhook** node and make the webhook yourself with `POST /v1/webhooks`. Check each request’s signature in a **Code** node before trusting it, as [Verify signatures](https://usebluebot.com/docs/webhooks/verify) shows. --- Source: https://usebluebot.com/docs/pipedream # Pipedream > BlueBot’s Pipedream components send texts from your own number and start workflows when a text comes in. Every action and source, and how to call BlueBot from your own code steps. BlueBot on Pipedream is an app you connect once, a set of actions (Send Text, Schedule Text and the rest) and instant sources that start a workflow when BlueBot posts an event. Because Pipedream runs code too, you can also call BlueBot from a Node.js or Python step with the connected account, which is the quickest way to do something the actions don’t. Texts sent from Pipedream go through your sending rules like every other text and show `source: "pipedream"` in BlueBot. ## Connect your account ### Make a key In BlueBot, open **Developers → API keys**, press **New key**, name it “Pipedream” and tick every scope. Sources need `webhooks:manage`, the contact actions need `contacts:read` and `contacts:write`, and the connection test needs `account:read`. Copy the key. ### Connect it in Pipedream Add any BlueBot step or source, press **Connect a BlueBot account**, paste the key into **API Key** and press **Save**. Pipedream tests it with `GET /v1/me`. The connected account is the `bluebot` app prop: every component and code step that names it gets your key as `this.bluebot.$auth.api_key`, and Pipedream keeps the key out of your workflow’s code and logs. ## Sources Each source is instant. When you deploy it, it makes a webhook in your BlueBot account pointing at the source’s own HTTP endpoint, subscribed to the events it needs, and keeps the webhook’s id and signing secret in its `$.service.db`. When you delete the source, it deletes the webhook. The webhook shows under **Developers → Webhooks** in BlueBot, described as “Pipedream: ” and the source’s name. Every request is checked against `webhook-signature` with that secret before anything is emitted, and one that fails is answered 401. Each emitted event uses BlueBot’s event id as its id with `dedupe: "unique"`, so a delivery BlueBot retries never starts your workflow twice. On deploy, a source also emits up to 10 recent matching events from `GET /v1/events`, so you have real data to build the next steps with. | Source | Key | Emits | BlueBot events | | ----------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | New Incoming Text (Instant) | `bluebot-new-incoming-text-instant` | Each text somebody sends you, as the message. | `message.received` | | Text Status Changed (Instant) | `bluebot-text-status-changed-instant` | Each change of state on a text you sent. Prop **States** picks which. | `message.sent`, `message.delivered`, `message.read`, `message.failed`, `message.unconfirmed`, `message.expired`, `message.canceled` | | New Contact (Instant) | `bluebot-new-contact-instant` | Each person added to your contacts. | `contact.created` | | Contact Stopped (Instant) | `bluebot-contact-stopped-instant` | Each person who goes on your stop list. | `contact.stopped` | | Sequence Completed (Instant) | `bluebot-sequence-completed-instant` | Each person who reaches the end of a sequence or leaves it early. Prop **Sequence** narrows it to one. | `sequence.completed` | The event a source emits is the record (`steps.trigger.event` is the message, contact or enrollment, exactly as the API returns it), and the summary names it, such as “New text from +15555550123”. The envelope’s `id` is `steps.trigger.context.id` and its `timestamp` is `steps.trigger.context.ts`, in milliseconds. The record carries the rest: a message’s `state` says which change it was. New Incoming Text hears the texts of people you’ve texted through BlueBot, unless you set **Which chats to copy** to **Everything** in **Settings → Sending rules**. Its props are **Include Group Chats** (default true) and **Only From** (a number or email address). ## Actions | Action | Key | What it does | | ------------------------ | ---------------------------------- | ----------------------------------------------------------------- | | Send Text | `bluebot-send-text` | Sends a text now, or as soon as your sending rules allow. | | Send Template | `bluebot-send-template` | Sends one of your templates with the variables you give it. | | Schedule Text | `bluebot-schedule-text` | Sends a text at a time. It can be changed or canceled until then. | | Cancel Scheduled Text | `bluebot-cancel-scheduled-text` | Cancels a scheduled or queued text by its `msg_` id. | | Create or Update Contact | `bluebot-create-or-update-contact` | Finds a person by number or email and updates them, or adds them. | | Add Tag | `bluebot-add-tag` | Adds tags to a person. | | Enroll in Sequence | `bluebot-enroll-in-sequence` | Starts a person on a sequence. | | Remove from Sequence | `bluebot-remove-from-sequence` | Takes a person off one sequence or all of them. | | Find Contact | `bluebot-find-contact` | Finds a person by number or email. | | Find Conversation | `bluebot-find-conversation` | Your conversation with a person and its newest text. | | Check iMessage | `bluebot-check-imessage` | Whether your newest chat with a number used iMessage or SMS. | ### Send Text props | Prop | Type | Required | What it is | | ---------- | ----------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------- | | To | string | Unless Group Chat is set | A mobile number in any format (US numbers can leave off +1), or an iMessage email address. | | Group Chat | string, options from your group chats | No | Text an existing group chat instead. | | Text | string | Unless Files is set | Up to 2,000 characters. | | Files | string\[] | No | Up to 10 file URLs or paths in `/tmp`. Each is uploaded to BlueBot first, up to 25 MB. | | Service | string, options `auto`, `imessage`, `sms` | No | `auto` by default: the newest chat’s service, else iMessage, with one SMS retry for an iMessage that never went. | | Expires At | string (ISO 8601) | No | Give up if it’s still waiting then. Default: 24 hours after it’s due. | | Metadata | object | No | Up to 20 keys; echoed on every webhook about the text. | Schedule Text adds **Send At** (required, ISO 8601). Send Template takes **To**, **Template** (options from your templates), **Variables** (object), **Service** and **Metadata**. A variable you leave out comes from the contact or your business name, else from the template’s own fallback, and one with none of those stops the send with `invalid_request`, naming it. Find Conversation takes **Phone or Email** and returns `conversation_id`, `contact_id`, `last_message_id`, `last_message_text`, `last_message_direction`, `last_message_state`, `last_message_at` and `waiting_on_you`, or nothing when you’ve never texted them. The contact and sequence actions take the fields of the same names on the [Zapier page](https://usebluebot.com/docs/zapier), as props. Each action exports a summary, such as “Queued a text to +15555550123” or, for a held text, “Accepted a text to +15555550123; it waits for quiet hours to end”, and returns the record the API answered. Every action that sends a text carries an `Idempotency-Key` made from the workflow’s event, the action and what the step was given, so Pipedream retrying the step after an error doesn’t send twice. ## Your own code steps Add the `bluebot` app prop to a Node.js step and use the TypeScript SDK, which Pipedream installs from the import: ```js title="Node.js step" import BlueBot from "@usebluebot/sdk"; export default defineComponent({ props: { bluebot: { type: "app", app: "bluebot" }, }, async run({ steps, $ }) { const client = new BlueBot({ apiKey: this.bluebot.$auth.api_key }); const lead = steps.trigger.event; const message = await client.messages.send( { to: lead.phone, text: `Hi ${lead.first_name}, thanks for reaching out. When is a good time to talk?`, metadata: { source: "pipedream", lead_id: String(lead.id) }, }, // The same trigger event never sends twice, even when Pipedream retries this step. { idempotencyKey: `${steps.trigger.context.id}-welcome` }, ); $.export("$summary", `Text to ${message.to} is ${message.state}`); return message; }, }); ``` Or the Python SDK in a Python step: ```python title="Python step" from bluebot import BlueBot def handler(pd: "pipedream"): client = BlueBot(api_key=pd.inputs["bluebot"]["$auth"]["api_key"]) lead = pd.steps["trigger"]["event"] message = client.messages.send( to=lead["phone"], text=f"Hi {lead['first_name']}, thanks for reaching out.", idempotency_key=f"{pd.steps['trigger']['context']['id']}-welcome", ) return message.model_dump(mode="json", by_alias=True) ``` The [TypeScript](https://usebluebot.com/docs/sdks/typescript) and [Python](https://usebluebot.com/docs/sdks/python) pages list every method. ## Test a workflow ### Use your own number first In the action’s **To**, type your own mobile number, and press **Test**. | You got | It means | Do next | | ------------------------------------------------------------ | ------------------------------------------ | --------------------------------------------------------------- | | A message with `state: "queued"`, and the text on your phone | It works. | Put the real value back in **To** and deploy. | | `hold.reason: "quiet_hours"` | It’s quiet hours, so it waits for morning. | Nothing, or change quiet hours in **Settings → Sending rules**. | | `hold.reason: "device_offline"` | Your Mac isn’t checking in. | Wake it; the text goes once it’s back. | | `invalid_request` | A field didn’t fit; the message names it. | Fix that prop. [More](https://usebluebot.com/docs/errors/invalid_request) | ### Check a source Deploy the source, then text your BlueBot number from another phone you’ve texted through BlueBot before. The event shows in the source’s list within a second or two. ## When a step fails Actions throw BlueBot’s error with its message, hint, code and request id, and Pipedream shows it on the step. `rate_limited` (429) and the 5xx errors are worth retrying: turn on **Auto-retry** in the workflow’s settings, and the idempotency key keeps a retried send from going twice. A held text, and a text that fails after it was accepted, are not errors; use **Text Status Changed** to hear about those. --- Source: https://usebluebot.com/docs/text-hooks # Text Hooks > A Text Hook is an address any app can post its webhook to, and each post becomes a text from your number. Set one up, map the fields, use a preset for Calendly, Typeform, Shopify, Stripe or Jotform, and test it with curl. Most apps can call a webhook when something happens: a form is filled in, a booking is made, an order is paid. A Text Hook gives that webhook somewhere to go. You make the hook in BlueBot, paste its address into the other app, and say which posted field holds the phone number and what the text should say. From then on, every post becomes a text from your own number, with no Zapier account and no code. Rosa owns a yoga studio. She pastes her hook’s address into Calendly, maps Calendly’s phone field to the recipient, and writes “Hi `{{body.payload.first_name}}`, you’re booked. See you on the mat.” Each person who books gets that text from Rosa’s number within seconds. Texts from a hook go through your sending rules like every other text: pacing, quiet hours, the daily limits and your stop list. They show `source: "hook"` in BlueBot, with the hook’s id (`hook_…`) as `source_ref`. ## Make a hook ### Start it In BlueBot, open **Automations → Text Hooks** and press **New hook**. Name it after the app that will post to it (“Calendly bookings”). If the app is Calendly, Typeform, Shopify, Stripe or Jotform, pick it under **Preset** and BlueBot fills in the mapping for that app’s usual payload; you can change any of it. ### Copy the address The hook’s address looks like `https://usebluebot.com/h/` followed by a long random token. Copy it and paste it into the other app as a webhook URL. ### Send one real post Make the other app post once: book a test appointment, submit the form, or use the app’s **Send test** button. The hook’s page lists the post under **Recent posts** with the names of the fields it carried. ### Map the fields Fill in the settings below, picking field names from the post you sent. ### Turn it on A new hook starts off, so posts are listed but send nothing. Turn it on when the mapping is right. ## Settings | Setting | Required | What it is | | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Recipient | Yes | Who gets the text: a path into the post, such as `{{body.phone}}`, or a fixed number, such as your own to text yourself about each post. Any phone format works; US numbers can leave off the +1. | | Text | Yes | The words, up to 2,000 characters once filled in. `{{body.…}}` is replaced by that field of the post. | | Service | No | Auto (the default), iMessage or SMS, as on any send. | | Metadata | No | Up to 20 keys kept on each text and echoed on its webhooks. Values may use `{{body.…}}`, such as an order number. | | Add as a contact | No | Adds the recipient to your contacts, with first and last name from the paths you give. | | Tags | No | Tags for that contact, such as `calendly` or `lead`. | | Enroll in a sequence | No | Starts the person on a sequence instead of, or as well as, sending the text. | ### Paths into a post Everything posted is under `body`. A dot goes one level down, and a number picks an item from a list. | The post has | Write | | --------------------------------------- | ------------------------------ | | `{"phone": "+15555550123"}` | `{{body.phone}}` | | `{"customer": {"first_name": "Maria"}}` | `{{body.customer.first_name}}` | | `{"answers": [{"text": "Tuesday"}]}` | `{{body.answers.0.text}}` | | A form field named `phone` | `{{body.phone}}` | A path the post doesn’t have becomes nothing, so write texts that still read well without it: “Hi `{{body.first_name}}`, thanks” reads as “Hi , thanks” when the name is missing. A post whose recipient path is empty, or holds something that isn’t a phone number or email address, sends nothing, and **Recent posts** says why. ## What a hook accepts - `POST` only, with `Content-Type: application/json`, `application/x-www-form-urlencoded` or `multipart/form-data`. A form field whose value is JSON (Jotform’s `rawRequest`) is read as JSON, so you can write paths inside it. - Up to 64 KB of body. A bigger post is answered `413` and sends nothing. - No key: the token in the address is the credential. Treat the address like a password. Anybody who has it can make BlueBot text a number they post, though your new-people-per-day limit (50 by default) caps how many strangers that could reach. If an address leaks, delete the hook and make a new one. BlueBot answers `202` to every post it could read, whether or not it became a text, so the other app never retries a post or turns its webhook off because of a mapping problem. It answers `404` for an address that doesn’t exist or a hook that was deleted, `413` for a body over 64 KB, and `415` for another content type. ## Recent posts The hook’s page lists its last 20 posts from the past 30 days: when each came, the names of the fields it carried, and what happened (a text queued, held, or nothing and why). It never shows the values, and BlueBot never writes them to its logs, because webhooks carry people’s names, numbers and answers. To see the values, look in the other app’s webhook log, or post a test yourself. ## Presets A preset fills in the recipient, a first name for the text and the contact, and a starting text for that app’s usual webhook. The paths below are what each preset fills in; your form or store may use different field names, so check them against **Recent posts**. | App | Where you add the address | Recipient | First name | | -------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | | Calendly | A webhook subscription for `invitee.created` (on a paid Calendly plan) | `{{body.payload.text_reminder_number}}` | `{{body.payload.first_name}}` | | Typeform | Your form’s **Connect → Webhooks** | The first answer of type `phone_number`, such as `{{body.form_response.answers.2.phone_number}}` | The first short-text answer, such as `{{body.form_response.answers.0.text}}` | | Shopify | **Settings → Notifications → Webhooks**, event Customer creation, format JSON | `{{body.phone}}` | `{{body.first_name}}` | | Stripe | **Developers → Webhooks**, event `checkout.session.completed` | `{{body.data.object.customer_details.phone}}` | `{{body.data.object.customer_details.name}}`, the full name | | Jotform | Your form’s **Settings → Integrations → WebHooks** | The phone field inside `rawRequest`, such as `{{body.rawRequest.q4_phoneNumber.full}}` | `{{body.rawRequest.q3_name.first}}` | Some notes on each: - **Calendly** fills `text_reminder_number` only when the event type asks for a number for text reminders. Turn that on, or add a phone question and map its answer. - **Typeform** posts answers in the order of the form. Moving questions around changes the numbers in the paths, so check the mapping after editing the form. - **Shopify** has a phone number only when the customer gave one. For orders, use the Order creation event and `{{body.customer.phone}}`. Text only customers who agreed to texts; Shopify sends that as `{{body.sms_marketing_consent.state}}`. - **Stripe** includes a phone number only when Checkout collects one. BlueBot doesn’t check Stripe’s signature on the post, so anybody with the address could post a fake one; keep the address private. - **Jotform** names fields after their question number (`q4_…`), which differs from form to form. Every app above can also send its posts twice now and then. A hook sends one text per post, so a repeated post sends again; if that matters, have the hook enroll people in a sequence, which never starts the same person twice. ## Test it with curl Post a sample shaped like the app’s, with your own number in the recipient field. Here the recipient is `{{body.phone}}` and the text uses `{{body.first_name}}`: ```bash title="A JSON post" curl -X POST "https://usebluebot.com/h/YOUR_HOOK_TOKEN" \ -H "Content-Type: application/json" \ -d '{"phone":"+15555550123","first_name":"Maria"}' ``` The same as a form post. Use `--data-urlencode`, since a bare `+` in a form body means a space: ```bash title="A form post" curl -X POST "https://usebluebot.com/h/YOUR_HOOK_TOKEN" \ --data-urlencode "phone=+15555550123" \ --data-urlencode "first_name=Maria" ``` | You got | It means | Do next | | ---------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------ | | `202`, and a text on your phone a few seconds later | The hook works end to end. | Put the address in the other app. | | `202`, no text, and **Recent posts** says the hook is off | Posts are listed but nothing sends until you turn it on. | Turn the hook on. | | `202`, no text, and **Recent posts** says the recipient is empty | The recipient path doesn’t match a field in the post. | Pick the field name from **Recent posts**. | | `202`, and the text is held | It’s quiet hours, your Mac is offline, or a daily limit was reached. | Nothing; it goes once the hold ends. [Holds](https://usebluebot.com/docs/concepts/holds) | | `404` | The token is wrong or the hook was deleted. | Copy the address again. | | `415` | The body isn’t JSON or a form. | Send `Content-Type: application/json`. | ## When to use something else A hook sends a text when a post arrives, and that’s all. For anything that needs a decision first (only text new customers, wait until the day before, look something up), use [Zapier](https://usebluebot.com/docs/zapier), [Make](https://usebluebot.com/docs/make), [n8n](https://usebluebot.com/docs/n8n) or the [API](https://usebluebot.com/docs/api). The [recipes](https://usebluebot.com/docs/recipes) say which path suits each app. --- Source: https://usebluebot.com/docs/widget # Website widget > A “Text us” button for your website. A visitor leaves their name, number and question, and gets a text from your own number, so the conversation continues in Messages rather than in a chat window. The widget is one script tag. It puts a small **Text us** button in the corner of your site. A visitor taps it, types their name, mobile number and question, and presses **Send**. BlueBot adds them to your contacts, texts them from your own number straight away (“Thanks Dana, this is Maria at Sparkle Cleaning. I’ll text you back shortly.”), and tells you their question by email or Slack. When you reply from your iPhone, your Mac or the BlueBot inbox, they get it as an ordinary text. A chat window ends when the visitor closes the tab. A text stays on their phone, from a number they can save and reply to tomorrow. ## Add it to your site ### Make a widget In BlueBot, open **Automations → Website widget** and press **New widget**. Under **Allowed sites**, add each address the widget will run on, such as `https://sparkleclean.com` and `https://www.sparkleclean.com`. The widget works only on those, so nobody can copy your tag onto another site. ### Set it up Choose the look and the words (the options are below). The preview on the right shows the button and panel as visitors will see them. ### Copy the tag Copy the script tag from the widget’s page. Its `data-key` is the widget’s own key, starting `wk_`: ```html title="Paste before on every page" ``` The widget key is meant to be seen in your page’s source. It can only open this widget on your allowed sites; it isn’t an API key and can’t read or send anything else. ### Paste it into your site Put the tag on every page, right before ``. Where that is depends on how the site was built: | Site builder | Where the tag goes | | ------------- | --------------------------------------------------------------------------------------- | | WordPress | Your theme’s footer, or any plugin that adds code to every page. | | Squarespace | **Settings → Advanced → Code Injection → Footer** (on plans that allow code injection). | | Wix | **Settings → Custom Code → Add Custom Code**, placed in **Body - end** on all pages. | | Webflow | **Site settings → Custom code → Footer code**, then publish. | | Shopify | **Online Store → Themes → Edit code**, in `theme.liquid` right before ``. | | Your own HTML | Before `` in your layout. | ## Options Everything about the widget is set on its page in BlueBot, so changing the look or the words never means editing your site again. | Option | Default | What it does | | --------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Button label | Text us | The words on the button. | | Colors | BlueBot blue | The button’s color and the panel’s accent. Pick your brand’s; BlueBot keeps the text on them readable. | | Position | Bottom right | Bottom right or bottom left. | | Heading | Text us. We’ll reply from our phone. | The first line of the panel. | | Question prompt | What can we help with? | The label over the question box. | | Consent line | By sending, you agree to get texts from your business name (or “us” until you set one) about your question. | Shown above **Send**. | | First text | Thanks `{{first_name}}`, this is your business name. We got your question and will text you shortly. | The text BlueBot sends the visitor from your number. `{{first_name}}` and `{{last_name}}` come from the form. Until you set a business name under **Settings**, it leaves out “this is …”. | | Tags | `website` | Tags for the new contact. | | Tell me | Email | Where the visitor’s question goes: email, Slack, or both, through your notification settings. | | Allowed sites | None | The addresses it runs on. A page anywhere else shows no button. | ## What happens when a visitor sends 1. The panel checks the number as they type and won’t send one it can’t read. 2. BlueBot adds them to your contacts with `created_via: "widget"` and your tags, or updates the contact if they’re already there. 3. BlueBot queues your **First text** to them from your number, with `source: "widget"`. It goes through your sending rules like every text, so a question asked at 11 PM gets its reply after your quiet hours end, and nobody is woken by it. 4. You get their name, number and question by email or Slack, and the conversation shows in your inbox with your first text. 5. The panel thanks them and says to watch for a text from your number. A visitor on your stop list gets no text, and you still get their question. A visitor who texted STOP before and now writes to you through the widget is still stopped; they can text START to your number to hear from you again. ## Privacy and abuse - The widget sets no cookies and loads no third-party scripts. It reads nothing from the page it’s on. - A hidden field catches most bots, and each visitor can send only a few times an hour. - Strangers through the widget count toward your new-people-per-day limit (50 by default), which caps how many texts a flood of fake submissions could send. - If your site sets a Content Security Policy, allow `https://usebluebot.com` in `script-src` and `connect-src`. ## Test it Add `http://localhost:3000` (or wherever you preview your site) to **Allowed sites** while you build, and take it out afterwards. Open the page, press **Text us** and send the form with your own mobile number. | You got | It means | Do next | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | | Your first text on your phone, and the question by email or Slack | It works. | Remove any test address from **Allowed sites**. | | No button on the page | The page’s address isn’t in **Allowed sites**, or the tag isn’t on the page. | Compare the address bar with **Allowed sites**, `www` included. | | The panel says it couldn’t send | Too many tries from your connection, or the widget was turned off. | Wait a few minutes, and check the widget is on. | | The question arrived but no text | You’re in quiet hours, your Mac is offline, or your own number is on your stop list. | Look at the text in the inbox; its hold says why. [Holds](https://usebluebot.com/docs/concepts/holds) | [A “Text us” button on your website](https://usebluebot.com/docs/guides/text-us-button) walks through it for a real business and covers the replies too. --- Source: https://usebluebot.com/docs/sdks # SDKs > The TypeScript and Python SDKs wrap every API operation with typed models, automatic retries, idempotency keys, pagination and webhook verification. How they behave, and how to call the API from Go, Ruby and PHP without one. BlueBot has two official SDKs. Both cover every operation in the API, one method each, and behave the same way: they send an idempotency key on every POST, retry what is safe to retry, page through lists for you, raise typed errors that carry BlueBot’s error code, and check webhook signatures. | Language | Package | Install | Needs | | ------------------------- | ------------------------ | ----------------------------- | ----------------------------------------------- | | TypeScript and JavaScript | `@usebluebot/sdk` on npm | `npm install @usebluebot/sdk` | Node 18 or newer, Bun, Deno, or an edge runtime | | Python | `bluebot` on PyPI | `pip install bluebot` | Python 3.9 or newer | The npm package is under the `@usebluebot` scope. The plain name `bluebot` on npm belongs to somebody else and has nothing to do with BlueBot, so check the scope before you install. For the command line, there’s the [CLI](https://usebluebot.com/docs/cli). For another language, every example in these docs also shows Go, Ruby, PHP and plain `fetch`, and the [OpenAPI file](https://usebluebot.com/docs/openapi) can generate a client. ## The same call in each Every request example in the docs has a tab for each language, and your choice follows you from page to page. Sending a text: ```bash curl -X POST "https://usebluebot.com/api/v1/messages" \ -H "Authorization: Bearer $BLUEBOT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: crm-48213-confirmation" \ -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: 'crm-48213-confirmation' }); 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="crm-48213-confirmation", ) print(message) ``` ## How the SDKs are shaped - **One client, made with your key.** `new BlueBot({ apiKey })` in TypeScript, `BlueBot(api_key=...)` in Python. Both read `BLUEBOT_API_KEY` from the environment when you don’t pass a key. - **Resources mirror the API.** `client.messages.send`, `client.messages.list`, `client.webhooks.deliveries.replay`. A few single calls sit on the client itself: `client.lookup(number)`, `client.status()` and `client.me()`. [Every method](https://usebluebot.com/docs/sdks/typescript#every-method) is listed by resource. - **Path parameters come first, positionally.** `client.messages.cancel("msg_…")`, `client.webhooks.update("wh_…", { paused: true })`. - **The body or query comes next.** One object in TypeScript, in camelCase (`sendAt`, `chatId`). Keyword arguments in Python, in snake_case (`send_at=`, `chat_id=`). Keys inside `metadata` are yours and are never renamed. - **Request options come last.** `{ idempotencyKey, timeout, maxRetries, signal, headers }` in TypeScript; `idempotency_key=`, `timeout=`, `max_retries=`, `extra_headers=` in Python. - **Answers are typed records.** TypeScript answers in camelCase (`message.sendAt`, `page.nextCursor`). Python answers pydantic models in snake_case, with `message.from_` for the API’s `from`, which is a reserved word in Python. ## What every call does for you ### Idempotency keys Every POST carries an `Idempotency-Key`. If you don’t pass one, the SDK makes a random one and reuses it for each retry of that call, so a retried send never sends twice. Pass your own when the same logical send could be attempted again later, such as a job that might run twice: `crm-48213-confirmation` makes BlueBot answer the first text again for 24 hours instead of sending another. [Idempotency](https://usebluebot.com/docs/concepts/idempotency) explains the rules. ### Retries A call is tried up to three times in all (`maxRetries: 2`) when: - the connection failed or timed out; - BlueBot answered `rate_limited` (429), or any 5xx error; - BlueBot answered `conflict` (409) with a `Retry-After`, which means an earlier request with the same idempotency key is still running. Between tries the SDK waits what BlueBot asked (`Retry-After`, or `retry_after` in the error), or else half a second, then one second, then two, with a little randomness, never more than 8 seconds. When BlueBot asks for longer than 60 seconds, the SDK raises the error at once rather than hold your program up. Nothing else is retried: a `400` or a `404` will answer the same way every time. ### Timeouts Each try waits 60 seconds for an answer by default. Most calls answer in well under a second; `lookup` can take up to 15, because it asks your Mac. ### Pagination List calls answer one page, newest first. Loop over the call itself and the SDK fetches the following pages as you go: ```ts title="TypeScript" for await (const message of client.messages.list({ state: 'failed' })) { console.log(message.id, message.failure?.reason); } ``` ```python title="Python" for message in client.messages.list(state="failed"): print(message.id, message.failure.reason if message.failure else None) ``` ### Errors A refusal raises an error whose class says the kind and whose fields carry BlueBot’s answer: | Field (TypeScript / Python) | What it is | | ---------------------------- | --------------------------------------------------------------------------- | | `status` | The HTTP status, such as `429`. | | `code` | BlueBot’s stable code, such as `rate_limited`. Branch on this. | | `message` | One sentence saying what went wrong. | | `hint` | What to do next. | | `docUrl` / `doc_url` | The code’s page in these docs. | | `requestId` / `request_id` | Quote it when you write to support. | | `retryAfter` / `retry_after` | Seconds to wait, on `rate_limited`. | | `details` | For `invalid_request`, each field that didn’t fit, as `{ field, message }`. | | Class | Status | Codes | | --------------------------- | ------------- | --------------------------------------------------- | | `BadRequestError` | 400 | `invalid_request` | | `AuthenticationError` | 401 | `unauthorized` | | `PlanLapsedError` | 402 | `plan_lapsed` | | `PermissionDeniedError` | 403 | `forbidden`, `insufficient_scope`, `account_held` | | `NotFoundError` | 404 | `not_found` | | `ConflictError` | 409 | `conflict`, `idempotency_conflict`, `limit_reached` | | `PayloadTooLargeError` | 413 | `payload_too_large` | | `UnsupportedMediaTypeError` | 415 | `unsupported_media_type` | | `RateLimitError` | 429 | `rate_limited` | | `InternalServerError` | 500, 502, 503 | `internal`, `upstream_failed`, `unavailable` | All of them extend `APIError`, which extends `BlueBotError`. When BlueBot couldn’t be reached at all, the SDK raises `APIConnectionError`, or its subclass `APITimeoutError` when it waited too long. A bad webhook signature raises `WebhookVerificationError`. [Errors](https://usebluebot.com/docs/errors) has a page for every code. A held text is not an error. `client.messages.send` answers the message with `state: "queued"` and a `hold` saying why it waits, and the text goes on its own when the hold ends. ### Logging Set `BLUEBOT_LOG=debug` to have the SDK log each request’s method, path, status, time and request id. It never logs bodies, so the words of a text, a phone number in a body and your key never reach your logs. ## Without an SDK: Go, Ruby and PHP The API is plain HTTPS and JSON, and every request example in the docs has Go, Ruby and PHP tabs that use only each language’s standard library. Five things make a client solid in any language: 1. Send `Authorization: Bearer ` and, on a body, `Content-Type: application/json`. 2. Send an `Idempotency-Key` on every POST, and reuse it when you retry that request. 3. On a refusal, read `error.code` from the JSON body. Never parse `error.message`, which may change. 4. Retry 429 and 5xx answers after the `Retry-After` header’s seconds, or a short backoff. 5. Page by sending the answer’s `next_cursor` as `cursor` until `has_more` is false. Here is that last one in Go, reading every failed text: ```go title="Paging in Go" package main import ( "encoding/json" "fmt" "net/http" "net/url" "os" ) type page struct { Data []struct { ID string `json:"id"` To string `json:"to"` Failure *struct { Reason string `json:"reason"` } `json:"failure"` } `json:"data"` HasMore bool `json:"has_more"` NextCursor *string `json:"next_cursor"` } func main() { query := url.Values{"state": {"failed"}, "limit": {"100"}} for { req, _ := http.NewRequest("GET", "https://usebluebot.com/api/v1/messages?"+query.Encode(), nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("BLUEBOT_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } var p page json.NewDecoder(res.Body).Decode(&p) res.Body.Close() for _, m := range p.Data { reason := "" if m.Failure != nil { reason = m.Failure.Reason } fmt.Println(m.ID, m.To, reason) } if !p.HasMore || p.NextCursor == nil { break } query.Set("cursor", *p.NextCursor) } } ``` Reading an error in Ruby: ```ruby title="Errors in Ruby" require "net/http" require "json" require "securerandom" uri = URI("https://usebluebot.com/api/v1/messages") request = Net::HTTP::Post.new(uri) request["Authorization"] = "Bearer #{ENV.fetch("BLUEBOT_API_KEY")}" request["Content-Type"] = "application/json" request["Idempotency-Key"] = SecureRandom.uuid request.body = { to: "+15555550123", text: "Your order is ready for pickup." }.to_json response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) } body = JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess) puts "#{body["id"]} is #{body["state"]}" else error = body["error"] warn "#{error["code"]}: #{error["message"]} #{error["hint"]} (#{error["request_id"]})" sleep(response["Retry-After"].to_i) if error["code"] == "rate_limited" end ``` And in PHP: ```php title="Errors in PHP" true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('BLUEBOT_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: ' . bin2hex(random_bytes(16)), ], CURLOPT_POSTFIELDS => json_encode(['to' => '+15555550123', 'text' => 'Your order is ready for pickup.']), ]); $body = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($status >= 400) { $error = $body['error']; error_log("{$error['code']}: {$error['message']} {$error['hint']} ({$error['request_id']})"); } else { echo "{$body['id']} is {$body['state']}\n"; } ``` ## Where next - [TypeScript SDK](https://usebluebot.com/docs/sdks/typescript): Every method, pagination, errors, webhooks and types. - [Python SDK](https://usebluebot.com/docs/sdks/python): Every method in snake_case, with sync and async clients. - [CLI](https://usebluebot.com/docs/cli): Send, read and listen for webhooks from a terminal. - [OpenAPI, Postman and Insomnia](https://usebluebot.com/docs/openapi): Import the API, or generate a client for another language. --- Source: https://usebluebot.com/docs/sdks/typescript # TypeScript SDK > @usebluebot/sdk for Node, Bun, Deno and edge runtimes: the client, every method, pagination, idempotency and retries, typed errors, webhook verification and the types it exports. `@usebluebot/sdk` is BlueBot’s official SDK for TypeScript and JavaScript. It has one method for each API operation, fully typed, and it handles idempotency keys, retries, pagination and webhook signatures for you. It uses the platform’s own `fetch` and has no dependencies. ## Install ```bash title="npm" npm install @usebluebot/sdk ``` ```bash title="pnpm" pnpm add @usebluebot/sdk ``` ```bash title="yarn" yarn add @usebluebot/sdk ``` ```bash title="bun" bun add @usebluebot/sdk ``` In Deno, import it straight from npm: `import BlueBot from 'npm:@usebluebot/sdk';`. It runs on Node 18 and newer, Bun, Deno, Cloudflare Workers, Vercel and Netlify functions, and any runtime with `fetch`. It ships ES modules and CommonJS, with type declarations for both. Check the name before installing. The SDK is `@usebluebot/sdk`, under the `@usebluebot` scope; the unscoped `bluebot` package on npm is somebody else’s. ## Make a client ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY }); ``` `BlueBot` is the default export and also a named one (`import { BlueBot } from '@usebluebot/sdk'`; `const { BlueBot } = require('@usebluebot/sdk')` in CommonJS). Make one client and share it; it holds no connection of its own. | Option | Type | Default | What it does | | ------------------------- | ------------------------ | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `apiKey` | `string` | `process.env.BLUEBOT_API_KEY` | Your `bb_live_…` key. The constructor throws a `BlueBotError` if there’s none. | | `baseUrl` | `string` | `process.env.BLUEBOT_BASE_URL`, else `https://usebluebot.com/api/v1` | Change it only to reach a BlueBot you run locally. | | `timeout` | `number` | `60000` | Milliseconds to wait for each try. | | `maxRetries` | `number` | `2` | Retries after the first try, for what is safe to retry. | | `defaultHeaders` | `Record` | none | Sent on every request. | | `fetch` | `typeof fetch` | the global `fetch` | Your own `fetch`, for a proxy or for tests. | | `dangerouslyAllowBrowser` | `boolean` | `false` | The client refuses to start in a browser unless this is `true`, because a key in a web page can be read by anyone who opens it. Call BlueBot from your server instead. | `client.withOptions({ timeout: 10_000, maxRetries: 0 })` returns a copy with some options changed, sharing the rest. ## Send a text ```ts 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' }, }); console.log(message.id, message.state); // msg_1Hq2LxW9cVbN3mKpRtYs0a queued if (message.hold) console.log(`Waiting: ${message.hold.description}`); ``` The answer is the message, usually `queued`. A text that can’t go yet (quiet hours, your Mac offline, a daily limit) is accepted with a `hold` saying why and until when; it isn’t an error, and it goes on its own when the hold ends. To learn when it was sent, delivered or read, use [webhooks](https://usebluebot.com/docs/webhooks) rather than asking again and again. ## Names in and out The API speaks snake_case JSON. The SDK speaks camelCase both ways: you pass `sendAt` and `chatId`, and read `message.sendAt`, `message.chatId`, `page.hasMore` and `page.nextCursor`. Keys inside `metadata` and `customFields` are yours, so they are sent and returned exactly as written. A field that’s one word (`to`, `text`, `state`, `from`) is the same either way. | In the API | In the SDK | | ------------------------------------------------ | ----------------------------------------------- | | `send_at`, `expires_at`, `chat_id` | `sendAt`, `expiresAt`, `chatId` | | `hold.reason`, `failure.reason`, `cancel_reason` | `hold.reason`, `failure.reason`, `cancelReason` | | `has_more`, `next_cursor` | `hasMore`, `nextCursor` | | `metadata.crm_id` | `metadata.crm_id` | Times you pass may be a `Date` or an ISO 8601 string with a zone. Times you get back are ISO 8601 strings in UTC, such as `"2026-11-13T19:41:00.000Z"`. ## Messages ```ts // Schedule one. It stays a draft you can change until it's due. const reminder = await client.messages.send({ to: '+15555550123', text: 'Reminder: your cleaning is tomorrow at 10 AM. Reply C to confirm.', sendAt: new Date('2026-11-20T15:00:00Z'), metadata: { appointment_id: 'apt_9921' }, }); // Move it, or change the words, before it goes. await client.messages.update(reminder.id, { sendAt: '2026-11-21T15:00:00Z' }); // Or cancel it. A text the Mac already took can't be called back: that's a ConflictError. await client.messages.cancel(reminder.id); // Text an existing group chat instead of one person. await client.messages.send({ chatId: 'chat_1Hq2LxW9cVbN3mKpRtYs0a', text: 'Running 10 minutes late.' }); // Insist on iMessage (no SMS fallback), and give up if it hasn't gone in an hour. await client.messages.send({ to: 'maria@example.com', text: 'Here is the link you asked for.', service: 'imessage', expiresAt: new Date(Date.now() + 60 * 60 * 1000), }); // Read one. const sent = await client.messages.get('msg_1Hq2LxW9cVbN3mKpRtYs0a'); // Settle a text the Mac couldn't confirm: you checked and it went, or send it again. await client.messages.resolve('msg_1Hq2LxW9cVbN3mKpRtYs0a', { action: 'mark_sent' }); ``` `service` is `'auto'` by default: the service of the newest chat with the person, else iMessage, with one SMS retry for an iMessage that never went. [Services and SMS fallback](https://usebluebot.com/docs/concepts/services) explains the choice. ## Files Upload a file first, then send its id. A file nobody sends is deleted after a day. ```ts import { openAsBlob } from 'node:fs'; // Node 20 and newer const photo = await client.files.upload(await openAsBlob('house.jpg'), { filename: 'house.jpg' }); await client.messages.send({ to: '+15555550123', text: 'Here is the front of the house.', files: [photo.id] }); ``` `upload` takes a `Blob` or `File`, or a `Uint8Array` or `ArrayBuffer` with a `filename`. On Node 18, read the file into a `Blob` yourself: ```ts import { readFile } from 'node:fs/promises'; const bytes = await readFile('menu.pdf'); const menu = await client.files.upload(new Blob([bytes], { type: 'application/pdf' }), { filename: 'menu.pdf' }); ``` A file another app hands you as a URL comes over with `fetch`: ```ts const response = await fetch('https://example.com/flyer.png'); const flyer = await client.files.upload(await response.blob(), { filename: 'flyer.png' }); ``` Files that arrive on a text are on `message.files`. Each has a `url` that works for an hour while its `status` is `ready`; `client.files.get(id)` gives a fresh one. A voice memo may carry a `transcript`. [Files and voice memos](https://usebluebot.com/docs/sending/files) has the sizes and kinds. ## Lists and pagination Every list call answers newest first. Await it for one page, or loop over it to walk every item: ```ts // One page. const page = await client.messages.list({ direction: 'in', limit: 50 }); console.log(page.data.length, page.hasMore, page.nextCursor); // Every item, page after page. for await (const message of client.messages.list({ state: 'failed', since: '2026-11-01T00:00:00Z' })) { console.log(message.to, message.failure?.reason); } // By hand. let current = await client.chats.list({ isGroup: true }); while (current) { for (const chat of current.data) console.log(chat.name); current = current.hasNextPage() ? await current.getNextPage() : null; } ``` A page has `data`, `hasMore`, `nextCursor`, `hasNextPage()` and `getNextPage()`. `limit` is 1 to 100, 25 by default. Looping stops when you `break`, and fetches nothing more than it needs. ## Lookup, the Mac and your account ```ts // What did the newest chat with this number use? Reads your own history through your Mac. const lookup = await client.lookup('+15555550123'); console.log(lookup.service, lookup.basis); // 'imessage' 'history' // Is the Mac there, and does it need you? const device = await client.device.get(); if (device.status !== 'online') console.log(`Offline since ${device.offlineSince}`); for (const [name, check] of Object.entries(device.checks)) { if (check.state === 'needs_you') console.log(`${name}: ${check.detail}`); } console.log(`${device.today.newPeople} of ${device.today.newPeopleLimit} new people today`); // BlueBot's own status, and who this key is. const status = await client.status(); const me = await client.me(); console.log(status.sending, me.account.email, me.key?.scopes); ``` A lookup of a number you’ve never texted answers `service: 'unknown'`, since Apple offers no way to ask. [Lookups](https://usebluebot.com/docs/sending/lookups) explains what the answer rests on. ## Settings and stops ```ts const settings = await client.settings.update({ timeZone: 'America/Chicago', paceSeconds: 30, quietHours: { enabled: true, start: '20:00', end: '08:00' }, }); console.log(settings.warnings); // Pause all sending for two hours, then resume. await client.settings.update({ pausedUntil: new Date(Date.now() + 2 * 60 * 60 * 1000) }); await client.settings.update({ pausedUntil: null }); // The stop list. await client.stops.add({ person: '+15555550144' }); for await (const stop of client.stops.list()) console.log(stop.handle, stop.source); await client.stops.remove('+15555550144'); // the SDK encodes the + for the path ``` ## Events The last 30 days of events, the same ones webhooks carry, newest first: ```ts for await (const event of client.events.list({ type: ['message.received', 'message.failed'], since: '2026-11-12T00:00:00Z' })) { console.log(event.type, event.timestamp); } const event = await client.events.get('evt_1Hq2LxW9cVbN3mKpRtYs0a'); ``` `type` takes a string of comma-separated types or an array. ## Webhooks ```ts const webhook = await client.webhooks.create({ url: 'https://example.com/bluebot/webhooks', events: ['message.received', 'message.failed'], description: 'Replies into the CRM', }); // The secret is shown now and when you rotate it, never again. Store it. console.log(webhook.secret); await client.webhooks.test(webhook.id, { type: 'message.received' }); await client.webhooks.update(webhook.id, { paused: true }); // A new secret; the old one keeps signing beside it for 24 hours. const { secret } = await client.webhooks.rotateSecret(webhook.id); // Send again everything that gave up. for await (const delivery of client.webhooks.deliveries.list(webhook.id, { state: 'gave_up' })) { await client.webhooks.deliveries.replay(webhook.id, delivery.id); } await client.webhooks.delete(webhook.id); ``` ### Verify what BlueBot sends `client.webhooks.verify(payload, headers, secret)` checks a delivery’s Standard Webhooks signature and timestamp and answers the event, typed by its `type`. Pass the raw body exactly as it arrived: parsing it and serializing it again changes the bytes and breaks the signature. | Argument | Type | What it is | | ------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `payload` | `string`, `Uint8Array` or `Buffer` | The raw body. | | `headers` | `Headers` or a plain object | The request’s headers; `webhook-id`, `webhook-timestamp` and `webhook-signature` are read from it, in any case. | | `secret` | `string` | The webhook’s `whsec_…` secret. Default: `process.env.BLUEBOT_WEBHOOK_SECRET`. | | `options.tolerance` | `number` | Seconds a timestamp may be from now, either way. Default `300`. | It throws `WebhookVerificationError` when a header is missing, the timestamp is outside the tolerance, no signature matches, or the body isn’t JSON. While a secret is being rotated, a delivery carries two signatures and either one passes. ```ts title="app/api/bluebot/route.ts (Next.js)" import BlueBot, { WebhookVerificationError } from '@usebluebot/sdk'; const client = new BlueBot(); export async function POST(request: Request) { const payload = await request.text(); let event; try { event = client.webhooks.verify(payload, request.headers, process.env.BLUEBOT_WEBHOOK_SECRET); } catch (error) { if (error instanceof WebhookVerificationError) return new Response('Bad signature', { status: 400 }); throw error; } switch (event.type) { case 'message.received': // event.data is the message. console.log(`${event.data.from}: ${event.data.text ?? '(a picture)'}`); break; case 'message.failed': console.log(`${event.data.id} failed: ${event.data.failure?.reason}`); break; } return new Response(null, { status: 204 }); } ``` ```ts title="Express" import express from 'express'; import BlueBot, { WebhookVerificationError } from '@usebluebot/sdk'; const app = express(); const client = new BlueBot(); // express.raw keeps the body as bytes; express.json() would break the signature. app.post('/bluebot/webhooks', express.raw({ type: 'application/json' }), (req, res) => { try { const event = client.webhooks.verify(req.body, req.headers, process.env.BLUEBOT_WEBHOOK_SECRET); if (event.type === 'contact.stopped') console.log(`${event.data.handle} stopped`); res.sendStatus(204); } catch (error) { if (error instanceof WebhookVerificationError) return res.sendStatus(400); throw error; } }); ``` Answer quickly with any 2xx and do slow work afterwards; BlueBot waits 10 seconds for an answer. The same event can arrive twice, so skip an `event.id` you’ve already handled. To verify without a client or an API key, such as in a function that only receives webhooks, import `verifyWebhook`, which takes the same arguments: `verifyWebhook(payload, headers, secret)`. ## Idempotency and retries Every POST carries an `Idempotency-Key`. The SDK makes one when you don’t pass it and reuses it for that call’s retries. Pass your own when your code might run the same send twice, such as a job queue that can deliver a job again: ```ts await client.messages.send( { to: lead.phone, text: `Hi ${lead.firstName}, thanks for your interest.` }, { idempotencyKey: `welcome-${lead.id}` }, ); ``` For 24 hours, the same key with the same request answers the first text again; the same key with a different request is a `ConflictError` with code `idempotency_conflict`. The SDK retries a call (twice by default) after a connection failure or timeout, a 429, a 5xx, or a 409 that carries `Retry-After`. It waits what BlueBot asks, or backs off from half a second to at most 8 seconds. [SDKs](https://usebluebot.com/docs/sdks#retries) has the details. ### Request options Every method takes one last, optional argument: | Option | Type | What it does | | ---------------- | ------------------------ | --------------------------------------- | | `idempotencyKey` | `string` | The `Idempotency-Key` for this POST. | | `timeout` | `number` | Milliseconds for each try of this call. | | `maxRetries` | `number` | Retries for this call. | | `signal` | `AbortSignal` | Cancels the call, retries included. | | `headers` | `Record` | Extra headers for this call. | A method with no body or query takes the options right after its path parameters: `client.messages.cancel(id, { idempotencyKey: 'cancel-9921' })`. ### The response itself Add `.withResponse()` to any call to get the HTTP response and request id along with the record: ```ts const { data: message, response, requestId } = await client.messages .send({ to: '+15555550123', text: 'Hello again.' }, { idempotencyKey: 'hello-again-1' }) .withResponse(); console.log(requestId, response.headers.get('Idempotent-Replayed')); // 'true' when BlueBot answered an earlier send console.log(response.headers.get('RateLimit-Remaining')); ``` ## Errors ```ts import BlueBot, { APIError, BadRequestError, RateLimitError, APIConnectionError } from '@usebluebot/sdk'; try { await client.messages.send({ to: '555', text: 'Hi' }); } catch (error) { if (error instanceof BadRequestError) { for (const { field, message } of error.details ?? []) console.log(`${field}: ${message}`); } else if (error instanceof RateLimitError) { console.log(`Slow down; try again in ${error.retryAfter} seconds`); } else if (error instanceof APIError) { console.error(`${error.code}: ${error.message} ${error.hint} (${error.requestId})`); } else if (error instanceof APIConnectionError) { console.error('BlueBot could not be reached'); } else { throw error; } } ``` Every `APIError` has `status`, `code`, `message`, `hint`, `docUrl`, `requestId`, `retryAfter`, `details` and `headers`. Branch on `code`, never on `message`. The classes are `BadRequestError` (400), `AuthenticationError` (401), `PlanLapsedError` (402), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `PayloadTooLargeError` (413), `UnsupportedMediaTypeError` (415), `RateLimitError` (429) and `InternalServerError` (5xx), all extending `APIError`, which extends `BlueBotError`. `APIConnectionError` (and its subclass `APITimeoutError`) and `WebhookVerificationError` extend `BlueBotError` too. [Errors](https://usebluebot.com/docs/errors) has a page for each code. ## Types Everything the SDK takes and answers is exported as a type: ```ts import type { Message, MessageState, Hold, HoldReason, FileObject, Chat, Contact, Stop, Lookup, Device, Status, Me, Settings, Webhook, WebhookWithSecret, WebhookDelivery, WebhookEvent, MessageReceivedEvent, ContactStoppedEvent, MessageSendParams, MessageListParams, Page, RequestOptions, } from '@usebluebot/sdk'; ``` Records are named after the API’s objects (`FileObject` for a file, to stay clear of the DOM’s `File`). Each event type is `Event` in PascalCase (`MessageReceivedEvent`, `DeviceCheckFailedEvent`), and `WebhookEvent` is the union of them all, so a `switch` on `event.type` narrows `event.data`. Each method’s argument is `Params`, such as `WebhookCreateParams`. The types are generated from the [OpenAPI file](https://usebluebot.com/docs/openapi), so they match the API exactly. ## Logging Set `BLUEBOT_LOG=debug` to log each request’s method, path, status, duration and request id to standard error, or `BLUEBOT_LOG=info` for retries and errors only. Bodies are never logged, so the words of texts and your key stay out of your logs. ## Testing your code Point the client at a stand-in with the `fetch` option, and turn retries off so a failure shows at once: ```ts import BlueBot from '@usebluebot/sdk'; const client = new BlueBot({ apiKey: 'bb_test_unused', maxRetries: 0, fetch: async (url, init) => Response.json({ id: 'msg_1Hq2LxW9cVbN3mKpRtYs0a', object: 'message', state: 'queued' /* … */ }, { status: 201 }), }); ``` A `bb_test_` key works only against a BlueBot running on your own machine, never against `usebluebot.com`, so a test can’t text anybody by mistake. ## Every method Every API operation and the method that calls it. The Python SDK’s name is beside each. ### Messages | TypeScript | Python | Calls | | --------------------------- | --------------------------- | ---------------------------------------------------------------------- | | `client.messages.list()` | `client.messages.list()` | [`GET /v1/messages`](https://usebluebot.com/docs/api/messages/list-messages) | | `client.messages.send()` | `client.messages.send()` | [`POST /v1/messages`](https://usebluebot.com/docs/api/messages/send-message) | | `client.messages.get()` | `client.messages.get()` | [`GET /v1/messages/{id}`](https://usebluebot.com/docs/api/messages/get-message) | | `client.messages.update()` | `client.messages.update()` | [`PATCH /v1/messages/{id}`](https://usebluebot.com/docs/api/messages/update-message) | | `client.messages.cancel()` | `client.messages.cancel()` | [`POST /v1/messages/{id}/cancel`](https://usebluebot.com/docs/api/messages/cancel-message) | | `client.messages.resolve()` | `client.messages.resolve()` | [`POST /v1/messages/{id}/resolve`](https://usebluebot.com/docs/api/messages/resolve-message) | ### Files | TypeScript | Python | Calls | | ----------------------- | ----------------------- | ------------------------------------------------ | | `client.files.upload()` | `client.files.upload()` | [`POST /v1/files`](https://usebluebot.com/docs/api/files/upload-file) | | `client.files.get()` | `client.files.get()` | [`GET /v1/files/{id}`](https://usebluebot.com/docs/api/files/get-file) | ### Chats | TypeScript | Python | Calls | | --------------------- | --------------------- | ------------------------------------------------ | | `client.chats.list()` | `client.chats.list()` | [`GET /v1/chats`](https://usebluebot.com/docs/api/chats/list-chats) | | `client.chats.get()` | `client.chats.get()` | [`GET /v1/chats/{id}`](https://usebluebot.com/docs/api/chats/get-chat) | ### Lookup | TypeScript | Python | Calls | | ----------------- | ----------------- | -------------------------------------------------- | | `client.lookup()` | `client.lookup()` | [`GET /v1/lookup`](https://usebluebot.com/docs/api/lookup/lookup-number) | ### Device | TypeScript | Python | Calls | | --------------------- | --------------------- | ----------------------------------------------- | | `client.device.get()` | `client.device.get()` | [`GET /v1/device`](https://usebluebot.com/docs/api/device/get-device) | | `client.status()` | `client.status()` | [`GET /v1/status`](https://usebluebot.com/docs/api/device/get-status) | ### Settings | TypeScript | Python | Calls | | -------------------------- | -------------------------- | ---------------------------------------------------------- | | `client.settings.get()` | `client.settings.get()` | [`GET /v1/settings`](https://usebluebot.com/docs/api/settings/get-settings) | | `client.settings.update()` | `client.settings.update()` | [`PATCH /v1/settings`](https://usebluebot.com/docs/api/settings/update-settings) | ### Stops | TypeScript | Python | Calls | | ----------------------- | ----------------------- | ---------------------------------------------------------- | | `client.stops.list()` | `client.stops.list()` | [`GET /v1/stops`](https://usebluebot.com/docs/api/stops/list-stops) | | `client.stops.add()` | `client.stops.add()` | [`POST /v1/stops`](https://usebluebot.com/docs/api/stops/add-stop) | | `client.stops.remove()` | `client.stops.remove()` | [`DELETE /v1/stops/{person}`](https://usebluebot.com/docs/api/stops/remove-stop) | ### Events | TypeScript | Python | Calls | | ---------------------- | ---------------------- | --------------------------------------------------- | | `client.events.list()` | `client.events.list()` | [`GET /v1/events`](https://usebluebot.com/docs/api/events/list-events) | | `client.events.get()` | `client.events.get()` | [`GET /v1/events/{id}`](https://usebluebot.com/docs/api/events/get-event) | ### Webhooks | TypeScript | Python | Calls | | ------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------- | | `client.webhooks.list()` | `client.webhooks.list()` | [`GET /v1/webhooks`](https://usebluebot.com/docs/api/webhooks/list-webhooks) | | `client.webhooks.create()` | `client.webhooks.create()` | [`POST /v1/webhooks`](https://usebluebot.com/docs/api/webhooks/create-webhook) | | `client.webhooks.get()` | `client.webhooks.get()` | [`GET /v1/webhooks/{id}`](https://usebluebot.com/docs/api/webhooks/get-webhook) | | `client.webhooks.update()` | `client.webhooks.update()` | [`PATCH /v1/webhooks/{id}`](https://usebluebot.com/docs/api/webhooks/update-webhook) | | `client.webhooks.delete()` | `client.webhooks.delete()` | [`DELETE /v1/webhooks/{id}`](https://usebluebot.com/docs/api/webhooks/delete-webhook) | | `client.webhooks.test()` | `client.webhooks.test()` | [`POST /v1/webhooks/{id}/test`](https://usebluebot.com/docs/api/webhooks/test-webhook) | | `client.webhooks.rotateSecret()` | `client.webhooks.rotate_secret()` | [`POST /v1/webhooks/{id}/rotate-secret`](https://usebluebot.com/docs/api/webhooks/rotate-webhook-secret) | | `client.webhooks.deliveries.list()` | `client.webhooks.deliveries.list()` | [`GET /v1/webhooks/{id}/deliveries`](https://usebluebot.com/docs/api/webhooks/list-deliveries) | | `client.webhooks.deliveries.replay()` | `client.webhooks.deliveries.replay()` | [`POST /v1/webhooks/{id}/deliveries/{delivery_id}/replay`](https://usebluebot.com/docs/api/webhooks/replay-delivery) | ### Account | TypeScript | Python | Calls | | ------------- | ------------- | ---------------------------------------- | | `client.me()` | `client.me()` | [`GET /v1/me`](https://usebluebot.com/docs/api/account/get-me) | ### Conversations | TypeScript | Python | Calls | | -------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------- | | `client.conversations.list()` | `client.conversations.list()` | [`GET /v1/conversations`](https://usebluebot.com/docs/api/conversations/list-conversations) | | `client.conversations.get()` | `client.conversations.get()` | [`GET /v1/conversations/{id}`](https://usebluebot.com/docs/api/conversations/get-conversation) | | `client.conversations.update()` | `client.conversations.update()` | [`PATCH /v1/conversations/{id}`](https://usebluebot.com/docs/api/conversations/update-conversation) | | `client.conversations.messages.list()` | `client.conversations.messages.list()` | [`GET /v1/conversations/{id}/messages`](https://usebluebot.com/docs/api/conversations/list-conversation-messages) | ### Analytics | TypeScript | Python | Calls | | ------------------------ | ------------------------ | -------------------------------------------------------- | | `client.analytics.get()` | `client.analytics.get()` | [`GET /v1/analytics`](https://usebluebot.com/docs/api/analytics/get-analytics) | ### Contacts | TypeScript | Python | Calls | | -------------------------- | -------------------------- | --------------------------------------------------------------- | | `client.contacts.list()` | `client.contacts.list()` | [`GET /v1/contacts`](https://usebluebot.com/docs/api/contacts/list-contacts) | | `client.contacts.create()` | `client.contacts.create()` | [`POST /v1/contacts`](https://usebluebot.com/docs/api/contacts/create-contact) | | `client.contacts.get()` | `client.contacts.get()` | [`GET /v1/contacts/{id}`](https://usebluebot.com/docs/api/contacts/get-contact) | | `client.contacts.update()` | `client.contacts.update()` | [`PATCH /v1/contacts/{id}`](https://usebluebot.com/docs/api/contacts/update-contact) | | `client.contacts.delete()` | `client.contacts.delete()` | [`DELETE /v1/contacts/{id}`](https://usebluebot.com/docs/api/contacts/delete-contact) | ### Templates | TypeScript | Python | Calls | | --------------------------- | --------------------------- | ------------------------------------------------------------------ | | `client.templates.list()` | `client.templates.list()` | [`GET /v1/templates`](https://usebluebot.com/docs/api/templates/list-templates) | | `client.templates.create()` | `client.templates.create()` | [`POST /v1/templates`](https://usebluebot.com/docs/api/templates/create-template) | | `client.templates.get()` | `client.templates.get()` | [`GET /v1/templates/{id}`](https://usebluebot.com/docs/api/templates/get-template) | | `client.templates.update()` | `client.templates.update()` | [`PATCH /v1/templates/{id}`](https://usebluebot.com/docs/api/templates/update-template) | | `client.templates.delete()` | `client.templates.delete()` | [`DELETE /v1/templates/{id}`](https://usebluebot.com/docs/api/templates/delete-template) | ### Broadcasts | TypeScript | Python | Calls | | ------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------- | | `client.broadcasts.list()` | `client.broadcasts.list()` | [`GET /v1/broadcasts`](https://usebluebot.com/docs/api/broadcasts/list-broadcasts) | | `client.broadcasts.create()` | `client.broadcasts.create()` | [`POST /v1/broadcasts`](https://usebluebot.com/docs/api/broadcasts/create-broadcast) | | `client.broadcasts.preview()` | `client.broadcasts.preview()` | [`POST /v1/broadcasts/preview`](https://usebluebot.com/docs/api/broadcasts/preview-broadcast) | | `client.broadcasts.get()` | `client.broadcasts.get()` | [`GET /v1/broadcasts/{id}`](https://usebluebot.com/docs/api/broadcasts/get-broadcast) | | `client.broadcasts.recipients.list()` | `client.broadcasts.recipients.list()` | [`GET /v1/broadcasts/{id}/recipients`](https://usebluebot.com/docs/api/broadcasts/list-broadcast-recipients) | | `client.broadcasts.cancel()` | `client.broadcasts.cancel()` | [`POST /v1/broadcasts/{id}/cancel`](https://usebluebot.com/docs/api/broadcasts/cancel-broadcast) | ### Sequences | TypeScript | Python | Calls | | --------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------- | | `client.sequences.list()` | `client.sequences.list()` | [`GET /v1/sequences`](https://usebluebot.com/docs/api/sequences/list-sequences) | | `client.sequences.create()` | `client.sequences.create()` | [`POST /v1/sequences`](https://usebluebot.com/docs/api/sequences/create-sequence) | | `client.sequences.get()` | `client.sequences.get()` | [`GET /v1/sequences/{id}`](https://usebluebot.com/docs/api/sequences/get-sequence) | | `client.sequences.update()` | `client.sequences.update()` | [`PATCH /v1/sequences/{id}`](https://usebluebot.com/docs/api/sequences/update-sequence) | | `client.sequences.enrollments.list()` | `client.sequences.enrollments.list()` | [`GET /v1/sequences/{id}/enrollments`](https://usebluebot.com/docs/api/sequences/list-enrollments) | | `client.sequences.enrollments.create()` | `client.sequences.enrollments.create()` | [`POST /v1/sequences/{id}/enrollments`](https://usebluebot.com/docs/api/sequences/enroll-in-sequence) | | `client.enrollments.get()` | `client.enrollments.get()` | [`GET /v1/enrollments/{id}`](https://usebluebot.com/docs/api/sequences/get-enrollment) | | `client.enrollments.cancel()` | `client.enrollments.cancel()` | [`POST /v1/enrollments/{id}/cancel`](https://usebluebot.com/docs/api/sequences/cancel-enrollment) | ### Text Hooks | TypeScript | Python | Calls | | ----------------------- | ----------------------- | ---------------------------------------------------------------- | | `client.hooks.list()` | `client.hooks.list()` | [`GET /v1/hooks`](https://usebluebot.com/docs/api/text-hooks/list-text-hooks) | | `client.hooks.create()` | `client.hooks.create()` | [`POST /v1/hooks`](https://usebluebot.com/docs/api/text-hooks/create-text-hook) | | `client.hooks.get()` | `client.hooks.get()` | [`GET /v1/hooks/{id}`](https://usebluebot.com/docs/api/text-hooks/get-text-hook) | | `client.hooks.update()` | `client.hooks.update()` | [`PATCH /v1/hooks/{id}`](https://usebluebot.com/docs/api/text-hooks/update-text-hook) | | `client.hooks.delete()` | `client.hooks.delete()` | [`DELETE /v1/hooks/{id}`](https://usebluebot.com/docs/api/text-hooks/delete-text-hook) | ### Automations | TypeScript | Python | Calls | | ----------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------- | | `client.automations.rules.list()` | `client.automations.rules.list()` | [`GET /v1/automations/rules`](https://usebluebot.com/docs/api/automations/list-keyword-rules) | | `client.automations.rules.create()` | `client.automations.rules.create()` | [`POST /v1/automations/rules`](https://usebluebot.com/docs/api/automations/create-keyword-rule) | | `client.automations.rules.get()` | `client.automations.rules.get()` | [`GET /v1/automations/rules/{id}`](https://usebluebot.com/docs/api/automations/get-keyword-rule) | | `client.automations.rules.update()` | `client.automations.rules.update()` | [`PATCH /v1/automations/rules/{id}`](https://usebluebot.com/docs/api/automations/update-keyword-rule) | | `client.automations.rules.delete()` | `client.automations.rules.delete()` | [`DELETE /v1/automations/rules/{id}`](https://usebluebot.com/docs/api/automations/delete-keyword-rule) | | `client.automations.away.get()` | `client.automations.away.get()` | [`GET /v1/automations/away`](https://usebluebot.com/docs/api/automations/get-away-message) | | `client.automations.away.update()` | `client.automations.away.update()` | [`PATCH /v1/automations/away`](https://usebluebot.com/docs/api/automations/update-away-message) | --- Source: https://usebluebot.com/docs/sdks/python # Python SDK > The bluebot package for Python 3.9 and newer, sync and async on httpx with pydantic models. The client, every method, pagination, idempotency and retries, typed errors and webhook verification. `bluebot` is BlueBot’s official Python SDK. It has a synchronous client, `BlueBot`, and an asynchronous one, `AsyncBlueBot`, with one method for each API operation. Answers are pydantic models, fully typed for your editor and for mypy or pyright, and the SDK handles idempotency keys, retries, pagination and webhook signatures for you. ## Install ```bash title="pip" pip install bluebot ``` ```bash title="uv" uv add bluebot ``` ```bash title="poetry" poetry add bluebot ``` It needs Python 3.9 or newer and brings `httpx` and `pydantic` (version 2). ## Make a client ```python import os from bluebot import BlueBot client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"]) ``` | Argument | Type | Default | What it does | | ----------------- | ------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- | | `api_key` | `str` | `BLUEBOT_API_KEY` from the environment | Your `bb_live_…` key. The constructor raises `BlueBotError` if there’s none. | | `base_url` | `str` | `BLUEBOT_BASE_URL`, else `https://usebluebot.com/api/v1` | Change it only to reach a BlueBot you run locally. | | `timeout` | `float` or `httpx.Timeout` | `60.0` | Seconds to wait for each try. | | `max_retries` | `int` | `2` | Retries after the first try, for what is safe to retry. | | `default_headers` | `dict[str, str]` | none | Sent on every request. | | `http_client` | `httpx.Client` (`httpx.AsyncClient` for `AsyncBlueBot`) | one of its own | Pass your own client to use a proxy or a custom transport. | Make one client and share it: it keeps a connection pool. Close it when you’re done, or use it as a context manager: ```python with BlueBot() as client: client.messages.send(to="+15555550123", text="Your table for four is ready.") ``` `client.with_options(timeout=10, max_retries=0)` returns a copy with some options changed, sharing the connection pool. ## Send a text ```python message = client.messages.send( to="+15555550123", text="Hi Maria, the showing at 412 Oak St is confirmed for 3 PM.", metadata={"crm_id": "48213"}, ) print(message.id, message.state) # msg_1Hq2LxW9cVbN3mKpRtYs0a queued if message.hold: print(f"Waiting: {message.hold.description}") ``` The answer is the message, usually `queued`. A text that can’t go yet (quiet hours, your Mac offline, a daily limit) is accepted with a `hold` saying why and until when; it isn’t an error, and it goes on its own when the hold ends. Use [webhooks](https://usebluebot.com/docs/webhooks) to learn when it was sent, delivered or read. ## Models Answers are pydantic models with the API’s field names in snake_case, so `message.send_at`, `message.chat_id` and `page.next_cursor` read as the API spells them. Two things differ from the JSON: - `from` is a reserved word in Python, so it’s `message.from_`. Everything else keeps its name. - Times are `datetime` objects in UTC, and empty fields are `None`. To get the API’s JSON back, with `from` spelled as it is in the API: ```python data = message.model_dump(mode="json", by_alias=True) ``` `metadata` is a plain `dict[str, str]`, returned exactly as you sent it. Times you pass may be a `datetime` with a time zone or an ISO 8601 string. A `datetime` without a zone raises `ValueError` before anything is sent, since BlueBot can’t guess which one you meant. ## Messages ```python from datetime import datetime, timedelta, timezone # Schedule one. It stays a draft you can change until it's due. reminder = client.messages.send( to="+15555550123", text="Reminder: your cleaning is tomorrow at 10 AM. Reply C to confirm.", send_at=datetime(2026, 11, 20, 15, 0, tzinfo=timezone.utc), metadata={"appointment_id": "apt_9921"}, ) # Move it, or change the words, before it goes. client.messages.update(reminder.id, send_at="2026-11-21T15:00:00Z") # Or cancel it. A text the Mac already took can't be called back: that's a ConflictError. client.messages.cancel(reminder.id) # Text an existing group chat instead of one person. client.messages.send(chat_id="chat_1Hq2LxW9cVbN3mKpRtYs0a", text="Running 10 minutes late.") # Insist on iMessage (no SMS fallback), and give up if it hasn't gone in an hour. client.messages.send( to="maria@example.com", text="Here is the link you asked for.", service="imessage", expires_at=datetime.now(timezone.utc) + timedelta(hours=1), ) # Read one. sent = client.messages.get("msg_1Hq2LxW9cVbN3mKpRtYs0a") # Settle a text the Mac couldn't confirm: you checked and it went, or send it again. client.messages.resolve("msg_1Hq2LxW9cVbN3mKpRtYs0a", action="mark_sent") ``` `service` is `"auto"` by default: the service of the newest chat with the person, else iMessage, with one SMS retry for an iMessage that never went. [Services and SMS fallback](https://usebluebot.com/docs/concepts/services) explains the choice. ## Files Upload first, then send the file’s id. A file nobody sends is deleted after a day. ```python from pathlib import Path photo = client.files.upload(Path("house.jpg")) client.messages.send(to="+15555550123", text="Here is the front of the house.", files=[photo.id]) ``` `upload` takes a `pathlib.Path`, an open binary file (`open("house.jpg", "rb")`), `bytes` with a `filename=`, or a `(filename, content, content_type)` tuple. A file another app gives you as a URL: ```python import httpx flyer = httpx.get("https://example.com/flyer.png") upload = client.files.upload(flyer.content, filename="flyer.png") ``` Files that arrive on a text are on `message.files`, each with a `url` that works for an hour while its `status` is `"ready"`; `client.files.get(file_id)` gives a fresh one. A voice memo may carry a `transcript`. ## Lists and pagination Every list call answers a page, newest first. Use `.data` for that page, or loop over the call to walk every item: ```python # One page. page = client.messages.list(direction="in", limit=50) print(len(page.data), page.has_more, page.next_cursor) # Every item, page after page. for message in client.messages.list(state="failed", since="2026-11-01T00:00:00Z"): print(message.to, message.failure.reason if message.failure else None) # By hand. page = client.chats.list(is_group=True) while True: for chat in page.data: print(chat.name) if not page.has_next_page(): break page = page.get_next_page() ``` `limit` is 1 to 100, 25 by default. A loop fetches the next page only when it reaches it, and fetches nothing more after you `break`. ## Lookup, the Mac and your account ```python lookup = client.lookup("+15555550123") print(lookup.service, lookup.basis) # imessage history device = client.device.get() if device.status != "online": print(f"Offline since {device.offline_since}") for name, check in device.checks: if check.state == "needs_you": print(f"{name}: {check.detail}") print(f"{device.today.new_people} of {device.today.new_people_limit} new people today") status = client.status() me = client.me() print(status.sending, me.account.email, me.key.scopes if me.key else None) ``` A lookup of a number you’ve never texted answers `service="unknown"`; Apple offers no way to ask. ## Settings and stops ```python settings = client.settings.update( time_zone="America/Chicago", pace_seconds=30, quiet_hours={"enabled": True, "start": "20:00", "end": "08:00"}, ) print(settings.warnings) # Pause all sending for two hours, then resume. client.settings.update(paused_until=datetime.now(timezone.utc) + timedelta(hours=2)) client.settings.update(paused_until=None) client.stops.add(person="+15555550144") for stop in client.stops.list(): print(stop.handle, stop.source) client.stops.remove("+15555550144") # the SDK encodes the + for the path ``` ## Events ```python for event in client.events.list(type=["message.received", "message.failed"], since="2026-11-12T00:00:00Z"): print(event.type, event.timestamp) event = client.events.get("evt_1Hq2LxW9cVbN3mKpRtYs0a") ``` `type` takes a comma-separated string or a list. ## Webhooks ```python webhook = client.webhooks.create( url="https://example.com/bluebot/webhooks", events=["message.received", "message.failed"], description="Replies into the CRM", ) print(webhook.secret) # shown now and on rotation, never again: store it client.webhooks.test(webhook.id, type="message.received") client.webhooks.update(webhook.id, paused=True) secret = client.webhooks.rotate_secret(webhook.id).secret for delivery in client.webhooks.deliveries.list(webhook.id, state="gave_up"): client.webhooks.deliveries.replay(webhook.id, delivery.id) client.webhooks.delete(webhook.id) ``` ### Verify what BlueBot sends `client.webhooks.verify(payload, headers, secret=None, tolerance=300)` checks a delivery’s Standard Webhooks signature and timestamp and returns the event as a model. Pass the raw body, as `bytes` or `str`, exactly as it arrived. `secret` defaults to `BLUEBOT_WEBHOOK_SECRET` from the environment. It raises `bluebot.WebhookVerificationError` when a header is missing, the timestamp is more than `tolerance` seconds from now, no signature matches, or the body isn’t JSON. During a secret rotation a delivery carries two signatures, and either passes. ```python title="Flask" import os from flask import Flask, abort, request from bluebot import BlueBot, WebhookVerificationError app = Flask(__name__) client = BlueBot() @app.post("/bluebot/webhooks") def bluebot_webhook(): try: event = client.webhooks.verify( request.get_data(), # the raw body, byte for byte request.headers, secret=os.environ["BLUEBOT_WEBHOOK_SECRET"], ) except WebhookVerificationError: abort(400) if event.type == "message.received": message = event.data # the message print(f"{message.from_}: {message.text}") return "", 204 ``` ```python title="FastAPI" import os from fastapi import FastAPI, HTTPException, Request, Response from bluebot import BlueBot, WebhookVerificationError app = FastAPI() client = BlueBot() @app.post("/bluebot/webhooks") async def bluebot_webhook(request: Request): try: event = client.webhooks.verify(await request.body(), request.headers, secret=os.environ["BLUEBOT_WEBHOOK_SECRET"]) except WebhookVerificationError: raise HTTPException(status_code=400) if event.type == "contact.stopped": print(f"{event.data.handle} stopped") return Response(status_code=204) ``` `verify` does no I/O, so it is the same plain method on `AsyncBlueBot`. To verify without a client or an API key, use `from bluebot import verify_webhook` with the same arguments. Answer within 10 seconds, and skip an `event.id` you’ve already handled, since the same event can arrive twice. ## Async `AsyncBlueBot` has the same methods, each a coroutine. Lists are awaitable for one page and async-iterable for every item: ```python import asyncio from bluebot import AsyncBlueBot async def main(): async with AsyncBlueBot() as client: message = await client.messages.send(to="+15555550123", text="Your order shipped today.") print(message.state) async for received in client.messages.list(direction="in", limit=100): print(received.from_, received.text) page = await client.messages.list(state="failed") print(len(page.data)) asyncio.run(main()) ``` Firing many sends at once doesn’t make texts go faster: BlueBot paces texts from your Mac (20 seconds apart by default) whatever order they arrive in. ## Idempotency and retries Every POST carries an `Idempotency-Key`. The SDK makes one when you don’t pass it and reuses it for that call’s retries. Pass your own when your code might run the same send twice, such as a task queue that can deliver a task again: ```python client.messages.send( to=lead.phone, text=f"Hi {lead.first_name}, thanks for your interest.", idempotency_key=f"welcome-{lead.id}", ) ``` For 24 hours, the same key with the same request answers the first text again; the same key with a different request raises `ConflictError` with `code == "idempotency_conflict"`. The SDK retries (twice by default) after a connection failure or timeout, a 429, a 5xx, or a 409 that carries `Retry-After`, waiting what BlueBot asks or backing off from half a second to at most 8 seconds. Every method also takes these keyword arguments: | Argument | What it does | | ----------------- | ------------------------------------ | | `idempotency_key` | The `Idempotency-Key` for this POST. | | `timeout` | Seconds for each try of this call. | | `max_retries` | Retries for this call. | | `extra_headers` | Extra headers for this call. | ### The response itself `with_raw_response` before a method answers the HTTP response, with the record a `.parse()` away: ```python raw = client.messages.with_raw_response.send(to="+15555550123", text="Hello again.", idempotency_key="hello-again-1") print(raw.headers.get("Request-Id"), raw.headers.get("Idempotent-Replayed")) message = raw.parse() ``` ## Errors ```python import bluebot try: client.messages.send(to="555", text="Hi") except bluebot.BadRequestError as error: for detail in error.details or []: print(f"{detail.field}: {detail.message}") except bluebot.RateLimitError as error: print(f"Slow down; try again in {error.retry_after} seconds") except bluebot.APIError as error: print(f"{error.code}: {error.message} {error.hint} ({error.request_id})") except bluebot.APIConnectionError: print("BlueBot could not be reached") ``` Every `APIError` has `status`, `code`, `message`, `hint`, `doc_url`, `request_id`, `retry_after`, `details` and `headers`. Branch on `code`, never on `message`. The classes are `BadRequestError` (400), `AuthenticationError` (401), `PlanLapsedError` (402), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `PayloadTooLargeError` (413), `UnsupportedMediaTypeError` (415), `RateLimitError` (429) and `InternalServerError` (5xx), all subclasses of `APIError`, which subclasses `BlueBotError`. `APIConnectionError` (and its subclass `APITimeoutError`) and `WebhookVerificationError` subclass `BlueBotError` too. ## Types The models are importable for annotations: ```python from bluebot.types import Message, Hold, FileObject, Chat, Contact, Lookup, Device, Settings, Webhook, WebhookDelivery from bluebot.types import WebhookEvent, MessageReceivedEvent, ContactStoppedEvent ``` Each event type is `Event` (`MessageReceivedEvent`, `DeviceCheckFailedEvent`), and `WebhookEvent` is their union, discriminated on `type`. A file is `FileObject`, as in TypeScript. The models are generated from the [OpenAPI file](https://usebluebot.com/docs/openapi). The SDK builds them from BlueBot’s answers without validating, so a field it doesn’t know, or one that’s missing, never raises, and an older SDK keeps working when the API adds a field. ## Logging Set `BLUEBOT_LOG=debug` to log each request’s method, path, status, duration and request id through the standard `logging` module (logger `bluebot`), or `BLUEBOT_LOG=info` for retries and errors only. The path is logged as the API writes it, such as `DELETE /stops/{person}`, so a phone number or an id in it stays out. Bodies are never logged, so the words of texts and your key stay out of your logs too. ## Testing your code The SDK sends everything through `httpx`, so `respx` or `httpx.MockTransport` can stand in for BlueBot: ```python import httpx from bluebot import BlueBot def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(201, json={"id": "msg_1Hq2LxW9cVbN3mKpRtYs0a", "object": "message", "state": "queued"}) client = BlueBot(api_key="bb_test_unused", max_retries=0, http_client=httpx.Client(transport=httpx.MockTransport(handler))) ``` A `bb_test_` key works only against a BlueBot running on your own machine, never against `usebluebot.com`, so a test can’t text anybody by mistake. ## Every method Every API operation, the method that calls it and the TypeScript name beside it. ### Messages | TypeScript | Python | Calls | | --------------------------- | --------------------------- | ---------------------------------------------------------------------- | | `client.messages.list()` | `client.messages.list()` | [`GET /v1/messages`](https://usebluebot.com/docs/api/messages/list-messages) | | `client.messages.send()` | `client.messages.send()` | [`POST /v1/messages`](https://usebluebot.com/docs/api/messages/send-message) | | `client.messages.get()` | `client.messages.get()` | [`GET /v1/messages/{id}`](https://usebluebot.com/docs/api/messages/get-message) | | `client.messages.update()` | `client.messages.update()` | [`PATCH /v1/messages/{id}`](https://usebluebot.com/docs/api/messages/update-message) | | `client.messages.cancel()` | `client.messages.cancel()` | [`POST /v1/messages/{id}/cancel`](https://usebluebot.com/docs/api/messages/cancel-message) | | `client.messages.resolve()` | `client.messages.resolve()` | [`POST /v1/messages/{id}/resolve`](https://usebluebot.com/docs/api/messages/resolve-message) | ### Files | TypeScript | Python | Calls | | ----------------------- | ----------------------- | ------------------------------------------------ | | `client.files.upload()` | `client.files.upload()` | [`POST /v1/files`](https://usebluebot.com/docs/api/files/upload-file) | | `client.files.get()` | `client.files.get()` | [`GET /v1/files/{id}`](https://usebluebot.com/docs/api/files/get-file) | ### Chats | TypeScript | Python | Calls | | --------------------- | --------------------- | ------------------------------------------------ | | `client.chats.list()` | `client.chats.list()` | [`GET /v1/chats`](https://usebluebot.com/docs/api/chats/list-chats) | | `client.chats.get()` | `client.chats.get()` | [`GET /v1/chats/{id}`](https://usebluebot.com/docs/api/chats/get-chat) | ### Lookup | TypeScript | Python | Calls | | ----------------- | ----------------- | -------------------------------------------------- | | `client.lookup()` | `client.lookup()` | [`GET /v1/lookup`](https://usebluebot.com/docs/api/lookup/lookup-number) | ### Device | TypeScript | Python | Calls | | --------------------- | --------------------- | ----------------------------------------------- | | `client.device.get()` | `client.device.get()` | [`GET /v1/device`](https://usebluebot.com/docs/api/device/get-device) | | `client.status()` | `client.status()` | [`GET /v1/status`](https://usebluebot.com/docs/api/device/get-status) | ### Settings | TypeScript | Python | Calls | | -------------------------- | -------------------------- | ---------------------------------------------------------- | | `client.settings.get()` | `client.settings.get()` | [`GET /v1/settings`](https://usebluebot.com/docs/api/settings/get-settings) | | `client.settings.update()` | `client.settings.update()` | [`PATCH /v1/settings`](https://usebluebot.com/docs/api/settings/update-settings) | ### Stops | TypeScript | Python | Calls | | ----------------------- | ----------------------- | ---------------------------------------------------------- | | `client.stops.list()` | `client.stops.list()` | [`GET /v1/stops`](https://usebluebot.com/docs/api/stops/list-stops) | | `client.stops.add()` | `client.stops.add()` | [`POST /v1/stops`](https://usebluebot.com/docs/api/stops/add-stop) | | `client.stops.remove()` | `client.stops.remove()` | [`DELETE /v1/stops/{person}`](https://usebluebot.com/docs/api/stops/remove-stop) | ### Events | TypeScript | Python | Calls | | ---------------------- | ---------------------- | --------------------------------------------------- | | `client.events.list()` | `client.events.list()` | [`GET /v1/events`](https://usebluebot.com/docs/api/events/list-events) | | `client.events.get()` | `client.events.get()` | [`GET /v1/events/{id}`](https://usebluebot.com/docs/api/events/get-event) | ### Webhooks | TypeScript | Python | Calls | | ------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------- | | `client.webhooks.list()` | `client.webhooks.list()` | [`GET /v1/webhooks`](https://usebluebot.com/docs/api/webhooks/list-webhooks) | | `client.webhooks.create()` | `client.webhooks.create()` | [`POST /v1/webhooks`](https://usebluebot.com/docs/api/webhooks/create-webhook) | | `client.webhooks.get()` | `client.webhooks.get()` | [`GET /v1/webhooks/{id}`](https://usebluebot.com/docs/api/webhooks/get-webhook) | | `client.webhooks.update()` | `client.webhooks.update()` | [`PATCH /v1/webhooks/{id}`](https://usebluebot.com/docs/api/webhooks/update-webhook) | | `client.webhooks.delete()` | `client.webhooks.delete()` | [`DELETE /v1/webhooks/{id}`](https://usebluebot.com/docs/api/webhooks/delete-webhook) | | `client.webhooks.test()` | `client.webhooks.test()` | [`POST /v1/webhooks/{id}/test`](https://usebluebot.com/docs/api/webhooks/test-webhook) | | `client.webhooks.rotateSecret()` | `client.webhooks.rotate_secret()` | [`POST /v1/webhooks/{id}/rotate-secret`](https://usebluebot.com/docs/api/webhooks/rotate-webhook-secret) | | `client.webhooks.deliveries.list()` | `client.webhooks.deliveries.list()` | [`GET /v1/webhooks/{id}/deliveries`](https://usebluebot.com/docs/api/webhooks/list-deliveries) | | `client.webhooks.deliveries.replay()` | `client.webhooks.deliveries.replay()` | [`POST /v1/webhooks/{id}/deliveries/{delivery_id}/replay`](https://usebluebot.com/docs/api/webhooks/replay-delivery) | ### Account | TypeScript | Python | Calls | | ------------- | ------------- | ---------------------------------------- | | `client.me()` | `client.me()` | [`GET /v1/me`](https://usebluebot.com/docs/api/account/get-me) | ### Conversations | TypeScript | Python | Calls | | -------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------- | | `client.conversations.list()` | `client.conversations.list()` | [`GET /v1/conversations`](https://usebluebot.com/docs/api/conversations/list-conversations) | | `client.conversations.get()` | `client.conversations.get()` | [`GET /v1/conversations/{id}`](https://usebluebot.com/docs/api/conversations/get-conversation) | | `client.conversations.update()` | `client.conversations.update()` | [`PATCH /v1/conversations/{id}`](https://usebluebot.com/docs/api/conversations/update-conversation) | | `client.conversations.messages.list()` | `client.conversations.messages.list()` | [`GET /v1/conversations/{id}/messages`](https://usebluebot.com/docs/api/conversations/list-conversation-messages) | ### Analytics | TypeScript | Python | Calls | | ------------------------ | ------------------------ | -------------------------------------------------------- | | `client.analytics.get()` | `client.analytics.get()` | [`GET /v1/analytics`](https://usebluebot.com/docs/api/analytics/get-analytics) | ### Contacts | TypeScript | Python | Calls | | -------------------------- | -------------------------- | --------------------------------------------------------------- | | `client.contacts.list()` | `client.contacts.list()` | [`GET /v1/contacts`](https://usebluebot.com/docs/api/contacts/list-contacts) | | `client.contacts.create()` | `client.contacts.create()` | [`POST /v1/contacts`](https://usebluebot.com/docs/api/contacts/create-contact) | | `client.contacts.get()` | `client.contacts.get()` | [`GET /v1/contacts/{id}`](https://usebluebot.com/docs/api/contacts/get-contact) | | `client.contacts.update()` | `client.contacts.update()` | [`PATCH /v1/contacts/{id}`](https://usebluebot.com/docs/api/contacts/update-contact) | | `client.contacts.delete()` | `client.contacts.delete()` | [`DELETE /v1/contacts/{id}`](https://usebluebot.com/docs/api/contacts/delete-contact) | ### Templates | TypeScript | Python | Calls | | --------------------------- | --------------------------- | ------------------------------------------------------------------ | | `client.templates.list()` | `client.templates.list()` | [`GET /v1/templates`](https://usebluebot.com/docs/api/templates/list-templates) | | `client.templates.create()` | `client.templates.create()` | [`POST /v1/templates`](https://usebluebot.com/docs/api/templates/create-template) | | `client.templates.get()` | `client.templates.get()` | [`GET /v1/templates/{id}`](https://usebluebot.com/docs/api/templates/get-template) | | `client.templates.update()` | `client.templates.update()` | [`PATCH /v1/templates/{id}`](https://usebluebot.com/docs/api/templates/update-template) | | `client.templates.delete()` | `client.templates.delete()` | [`DELETE /v1/templates/{id}`](https://usebluebot.com/docs/api/templates/delete-template) | ### Broadcasts | TypeScript | Python | Calls | | ------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------- | | `client.broadcasts.list()` | `client.broadcasts.list()` | [`GET /v1/broadcasts`](https://usebluebot.com/docs/api/broadcasts/list-broadcasts) | | `client.broadcasts.create()` | `client.broadcasts.create()` | [`POST /v1/broadcasts`](https://usebluebot.com/docs/api/broadcasts/create-broadcast) | | `client.broadcasts.preview()` | `client.broadcasts.preview()` | [`POST /v1/broadcasts/preview`](https://usebluebot.com/docs/api/broadcasts/preview-broadcast) | | `client.broadcasts.get()` | `client.broadcasts.get()` | [`GET /v1/broadcasts/{id}`](https://usebluebot.com/docs/api/broadcasts/get-broadcast) | | `client.broadcasts.recipients.list()` | `client.broadcasts.recipients.list()` | [`GET /v1/broadcasts/{id}/recipients`](https://usebluebot.com/docs/api/broadcasts/list-broadcast-recipients) | | `client.broadcasts.cancel()` | `client.broadcasts.cancel()` | [`POST /v1/broadcasts/{id}/cancel`](https://usebluebot.com/docs/api/broadcasts/cancel-broadcast) | ### Sequences | TypeScript | Python | Calls | | --------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------- | | `client.sequences.list()` | `client.sequences.list()` | [`GET /v1/sequences`](https://usebluebot.com/docs/api/sequences/list-sequences) | | `client.sequences.create()` | `client.sequences.create()` | [`POST /v1/sequences`](https://usebluebot.com/docs/api/sequences/create-sequence) | | `client.sequences.get()` | `client.sequences.get()` | [`GET /v1/sequences/{id}`](https://usebluebot.com/docs/api/sequences/get-sequence) | | `client.sequences.update()` | `client.sequences.update()` | [`PATCH /v1/sequences/{id}`](https://usebluebot.com/docs/api/sequences/update-sequence) | | `client.sequences.enrollments.list()` | `client.sequences.enrollments.list()` | [`GET /v1/sequences/{id}/enrollments`](https://usebluebot.com/docs/api/sequences/list-enrollments) | | `client.sequences.enrollments.create()` | `client.sequences.enrollments.create()` | [`POST /v1/sequences/{id}/enrollments`](https://usebluebot.com/docs/api/sequences/enroll-in-sequence) | | `client.enrollments.get()` | `client.enrollments.get()` | [`GET /v1/enrollments/{id}`](https://usebluebot.com/docs/api/sequences/get-enrollment) | | `client.enrollments.cancel()` | `client.enrollments.cancel()` | [`POST /v1/enrollments/{id}/cancel`](https://usebluebot.com/docs/api/sequences/cancel-enrollment) | ### Text Hooks | TypeScript | Python | Calls | | ----------------------- | ----------------------- | ---------------------------------------------------------------- | | `client.hooks.list()` | `client.hooks.list()` | [`GET /v1/hooks`](https://usebluebot.com/docs/api/text-hooks/list-text-hooks) | | `client.hooks.create()` | `client.hooks.create()` | [`POST /v1/hooks`](https://usebluebot.com/docs/api/text-hooks/create-text-hook) | | `client.hooks.get()` | `client.hooks.get()` | [`GET /v1/hooks/{id}`](https://usebluebot.com/docs/api/text-hooks/get-text-hook) | | `client.hooks.update()` | `client.hooks.update()` | [`PATCH /v1/hooks/{id}`](https://usebluebot.com/docs/api/text-hooks/update-text-hook) | | `client.hooks.delete()` | `client.hooks.delete()` | [`DELETE /v1/hooks/{id}`](https://usebluebot.com/docs/api/text-hooks/delete-text-hook) | ### Automations | TypeScript | Python | Calls | | ----------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------- | | `client.automations.rules.list()` | `client.automations.rules.list()` | [`GET /v1/automations/rules`](https://usebluebot.com/docs/api/automations/list-keyword-rules) | | `client.automations.rules.create()` | `client.automations.rules.create()` | [`POST /v1/automations/rules`](https://usebluebot.com/docs/api/automations/create-keyword-rule) | | `client.automations.rules.get()` | `client.automations.rules.get()` | [`GET /v1/automations/rules/{id}`](https://usebluebot.com/docs/api/automations/get-keyword-rule) | | `client.automations.rules.update()` | `client.automations.rules.update()` | [`PATCH /v1/automations/rules/{id}`](https://usebluebot.com/docs/api/automations/update-keyword-rule) | | `client.automations.rules.delete()` | `client.automations.rules.delete()` | [`DELETE /v1/automations/rules/{id}`](https://usebluebot.com/docs/api/automations/delete-keyword-rule) | | `client.automations.away.get()` | `client.automations.away.get()` | [`GET /v1/automations/away`](https://usebluebot.com/docs/api/automations/get-away-message) | | `client.automations.away.update()` | `client.automations.away.update()` | [`PATCH /v1/automations/away`](https://usebluebot.com/docs/api/automations/update-away-message) | --- Source: https://usebluebot.com/docs/cli # CLI > The bluebot command sends and reads texts, checks your Mac, manages webhooks and forwards events to a handler on your laptop with no tunnel. Every command, flag, setting and exit code. The BlueBot CLI is the `bluebot` command. It does from a terminal what the dashboard and the API do: send a text, read your texts, check that your Mac is ready, manage webhooks and stops. Its most useful command is `bluebot listen`, which forwards your account’s events to a webhook handler running on your own machine, signed the same way as a real delivery, so you can build the handler without a tunnel. ## Install The package is `@usebluebot/cli`; the command it installs is `bluebot`. It needs Node 18 or newer. ```bash npm install --global @usebluebot/cli bluebot --version ``` To run it once without installing, use `npx @usebluebot/cli `. The plain npm name `bluebot` belongs to somebody else, so install the scoped package. ## Sign in ```bash bluebot login ``` `login` asks for an API key and checks it with `GET /v1/me`. Make the key in the dashboard under **Developers → API keys**, named “CLI”; tick every scope to use every command, or only the ones you need (`listen` needs `messages:read`, the `webhooks` commands need `webhooks:manage`, `status` and `whoami` need `account:read`). The key is typed hidden and stored in your system’s keychain: the macOS Keychain, Windows Credential Manager, or the Secret Service on Linux. Where there’s no keychain, such as a container, it goes in a `credentials.json` file in the config folder that only you can read (mode `0600`). When your terminal shows colour, `login` greets you with BlueBot drawn in dots. With `--json`, `NO_COLOR` or no terminal, it prints plain text. | Flag | What it does | | ------------------ | ------------------------------------------------------------------------------------------------------------- | | `--stdin` | Read the key from standard input instead of asking, for scripts: `printf %s "$KEY" \| bluebot login --stdin`. | | `--profile ` | Store it under this profile. Default `default`. | Use profiles for more than one account or key, such as `--profile work`. `bluebot logout` forgets a profile’s key (`--all` forgets every one); revoke the key itself in the dashboard. ```bash bluebot whoami ``` ```text Signed in to maria@sparkleclean.com (acct_1Hq2LxW9cVbN3mKpRtYs0a) Key CLI (bb_live_7Fh2…), live Scopes messages:send, messages:read, contacts:read, contacts:write, webhooks:manage, account:read Profile default ``` ## Global flags Every command takes these. | Flag | What it does | | ------------------ | ------------------------------------------------------------------------------------------------------------------------ | | `--json` | Print BlueBot’s answer as JSON, exactly as the API returns it (snake_case), with no decoration. | | `--api-key ` | Use this key for one command instead of the stored one. | | `--profile ` | Use this profile’s key. | | `--base-url ` | Talk to another BlueBot, such as one running locally (`http://localhost:3100/api/v1`). | | `--no-color` | No colour, as `NO_COLOR` does. | | `-y`, `--yes` | Don’t ask before deleting or replacing something. Without a terminal, commands that would ask refuse unless this is set. | | `-h`, `--help` | Help for the command. | | `-v`, `--version` | The CLI’s version. | ## Send a text ```bash bluebot send +15555550123 "Your table for four is ready." ``` ```text → +1 555-555-0123 queued msg_1Hq2LxW9cVbN3mKpRtYs0a ``` `bluebot send [text]`. `` is a phone number in any format (US numbers can leave off +1) or an email address the person uses for iMessage. Leave out the text, or write `-`, to read it from standard input: `fortune | bluebot send +15555550123 -`. | Flag | What it does | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--file ` | Attach a file, up to 25 MB. Repeat for up to 10. Each is uploaded first. | | `--at