Skip to the page

SDKs and tools

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

pip install 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"])
ArgumentTypeDefaultWhat it does
api_keystrBLUEBOT_API_KEY from the environmentYour bb_live_… key. The constructor raises BlueBotError if there’s none.
base_urlstrBLUEBOT_BASE_URL, else https://usebluebot.com/api/v1Change it only to reach a BlueBot you run locally.
timeoutfloat or httpx.Timeout60.0Seconds to wait for each try.
max_retriesint2Retries after the first try, for what is safe to retry.
default_headersdict[str, str]noneSent on every request.
http_clienthttpx.Client (httpx.AsyncClient for AsyncBlueBot)one of its ownPass 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 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 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.

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

ArgumentWhat it does
idempotency_keyThe Idempotency-Key for this POST.
timeoutSeconds for each try of this call.
max_retriesRetries for this call.
extra_headersExtra 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 <Type>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. 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

TypeScriptPythonCalls
client.messages.list()client.messages.list()GET /v1/messages
client.messages.send()client.messages.send()POST /v1/messages
client.messages.get()client.messages.get()GET /v1/messages/{id}
client.messages.update()client.messages.update()PATCH /v1/messages/{id}
client.messages.cancel()client.messages.cancel()POST /v1/messages/{id}/cancel
client.messages.resolve()client.messages.resolve()POST /v1/messages/{id}/resolve

Files

TypeScriptPythonCalls
client.files.upload()client.files.upload()POST /v1/files
client.files.get()client.files.get()GET /v1/files/{id}

Chats

TypeScriptPythonCalls
client.chats.list()client.chats.list()GET /v1/chats
client.chats.get()client.chats.get()GET /v1/chats/{id}

Lookup

TypeScriptPythonCalls
client.lookup()client.lookup()GET /v1/lookup

Device

TypeScriptPythonCalls
client.device.get()client.device.get()GET /v1/device
client.status()client.status()GET /v1/status

Settings

TypeScriptPythonCalls
client.settings.get()client.settings.get()GET /v1/settings
client.settings.update()client.settings.update()PATCH /v1/settings

Stops

TypeScriptPythonCalls
client.stops.list()client.stops.list()GET /v1/stops
client.stops.add()client.stops.add()POST /v1/stops
client.stops.remove()client.stops.remove()DELETE /v1/stops/{person}

Events

TypeScriptPythonCalls
client.events.list()client.events.list()GET /v1/events
client.events.get()client.events.get()GET /v1/events/{id}

Webhooks

TypeScriptPythonCalls
client.webhooks.list()client.webhooks.list()GET /v1/webhooks
client.webhooks.create()client.webhooks.create()POST /v1/webhooks
client.webhooks.get()client.webhooks.get()GET /v1/webhooks/{id}
client.webhooks.update()client.webhooks.update()PATCH /v1/webhooks/{id}
client.webhooks.delete()client.webhooks.delete()DELETE /v1/webhooks/{id}
client.webhooks.test()client.webhooks.test()POST /v1/webhooks/{id}/test
client.webhooks.rotateSecret()client.webhooks.rotate_secret()POST /v1/webhooks/{id}/rotate-secret
client.webhooks.deliveries.list()client.webhooks.deliveries.list()GET /v1/webhooks/{id}/deliveries
client.webhooks.deliveries.replay()client.webhooks.deliveries.replay()POST /v1/webhooks/{id}/deliveries/{delivery_id}/replay

Account

TypeScriptPythonCalls
client.me()client.me()GET /v1/me

Conversations

TypeScriptPythonCalls
client.conversations.list()client.conversations.list()GET /v1/conversations
client.conversations.get()client.conversations.get()GET /v1/conversations/{id}
client.conversations.update()client.conversations.update()PATCH /v1/conversations/{id}
client.conversations.messages.list()client.conversations.messages.list()GET /v1/conversations/{id}/messages

Analytics

TypeScriptPythonCalls
client.analytics.get()client.analytics.get()GET /v1/analytics

Contacts

TypeScriptPythonCalls
client.contacts.list()client.contacts.list()GET /v1/contacts
client.contacts.create()client.contacts.create()POST /v1/contacts
client.contacts.get()client.contacts.get()GET /v1/contacts/{id}
client.contacts.update()client.contacts.update()PATCH /v1/contacts/{id}
client.contacts.delete()client.contacts.delete()DELETE /v1/contacts/{id}

Templates

TypeScriptPythonCalls
client.templates.list()client.templates.list()GET /v1/templates
client.templates.create()client.templates.create()POST /v1/templates
client.templates.get()client.templates.get()GET /v1/templates/{id}
client.templates.update()client.templates.update()PATCH /v1/templates/{id}
client.templates.delete()client.templates.delete()DELETE /v1/templates/{id}

Broadcasts

TypeScriptPythonCalls
client.broadcasts.list()client.broadcasts.list()GET /v1/broadcasts
client.broadcasts.create()client.broadcasts.create()POST /v1/broadcasts
client.broadcasts.preview()client.broadcasts.preview()POST /v1/broadcasts/preview
client.broadcasts.get()client.broadcasts.get()GET /v1/broadcasts/{id}
client.broadcasts.recipients.list()client.broadcasts.recipients.list()GET /v1/broadcasts/{id}/recipients
client.broadcasts.cancel()client.broadcasts.cancel()POST /v1/broadcasts/{id}/cancel

Sequences

TypeScriptPythonCalls
client.sequences.list()client.sequences.list()GET /v1/sequences
client.sequences.create()client.sequences.create()POST /v1/sequences
client.sequences.get()client.sequences.get()GET /v1/sequences/{id}
client.sequences.update()client.sequences.update()PATCH /v1/sequences/{id}
client.sequences.enrollments.list()client.sequences.enrollments.list()GET /v1/sequences/{id}/enrollments
client.sequences.enrollments.create()client.sequences.enrollments.create()POST /v1/sequences/{id}/enrollments
client.enrollments.get()client.enrollments.get()GET /v1/enrollments/{id}
client.enrollments.cancel()client.enrollments.cancel()POST /v1/enrollments/{id}/cancel

Text Hooks

TypeScriptPythonCalls
client.hooks.list()client.hooks.list()GET /v1/hooks
client.hooks.create()client.hooks.create()POST /v1/hooks
client.hooks.get()client.hooks.get()GET /v1/hooks/{id}
client.hooks.update()client.hooks.update()PATCH /v1/hooks/{id}
client.hooks.delete()client.hooks.delete()DELETE /v1/hooks/{id}

Automations

TypeScriptPythonCalls
client.automations.rules.list()client.automations.rules.list()GET /v1/automations/rules
client.automations.rules.create()client.automations.rules.create()POST /v1/automations/rules
client.automations.rules.get()client.automations.rules.get()GET /v1/automations/rules/{id}
client.automations.rules.update()client.automations.rules.update()PATCH /v1/automations/rules/{id}
client.automations.rules.delete()client.automations.rules.delete()DELETE /v1/automations/rules/{id}
client.automations.away.get()client.automations.away.get()GET /v1/automations/away
client.automations.away.update()client.automations.away.update()PATCH /v1/automations/away