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 bluebotIt needs Python 3.9 or newer and brings httpx and pydantic (version 2).
Make a client
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:
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
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 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:
fromis a reserved word in Python, so it’smessage.from_. Everything else keeps its name.- Times are
datetimeobjects in UTC, and empty fields areNone.
To get the API’s JSON back, with from spelled as it is in the API:
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
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. explains the choice.
Files
Upload first, then send the file’s id. A file nobody sends is deleted after a day.
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:
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:
# 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
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
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 pathEvents
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
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.
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 "", 204import 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:
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:
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:
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
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:
from bluebot.types import Message, Hold, FileObject, Chat, Contact, Lookup, Device, Settings, Webhook, WebhookDelivery
from bluebot.types import WebhookEvent, MessageReceivedEvent, ContactStoppedEventEach 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 . 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:
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() | |
client.messages.send() | client.messages.send() | |
client.messages.get() | client.messages.get() | |
client.messages.update() | client.messages.update() | |
client.messages.cancel() | client.messages.cancel() | |
client.messages.resolve() | client.messages.resolve() |
Files
| TypeScript | Python | Calls |
|---|---|---|
client.files.upload() | client.files.upload() | |
client.files.get() | client.files.get() |
Chats
| TypeScript | Python | Calls |
|---|---|---|
client.chats.list() | client.chats.list() | |
client.chats.get() | client.chats.get() |
Lookup
| TypeScript | Python | Calls |
|---|---|---|
client.lookup() | client.lookup() |
Device
| TypeScript | Python | Calls |
|---|---|---|
client.device.get() | client.device.get() | |
client.status() | client.status() |
Settings
| TypeScript | Python | Calls |
|---|---|---|
client.settings.get() | client.settings.get() | |
client.settings.update() | client.settings.update() |
Stops
| TypeScript | Python | Calls |
|---|---|---|
client.stops.list() | client.stops.list() | |
client.stops.add() | client.stops.add() | |
client.stops.remove() | client.stops.remove() |
Events
| TypeScript | Python | Calls |
|---|---|---|
client.events.list() | client.events.list() | |
client.events.get() | client.events.get() |
Webhooks
| TypeScript | Python | Calls |
|---|---|---|
client.webhooks.list() | client.webhooks.list() | |
client.webhooks.create() | client.webhooks.create() | |
client.webhooks.get() | client.webhooks.get() | |
client.webhooks.update() | client.webhooks.update() | |
client.webhooks.delete() | client.webhooks.delete() | |
client.webhooks.test() | client.webhooks.test() | |
client.webhooks.rotateSecret() | client.webhooks.rotate_secret() | |
client.webhooks.deliveries.list() | client.webhooks.deliveries.list() | |
client.webhooks.deliveries.replay() | client.webhooks.deliveries.replay() |
Account
| TypeScript | Python | Calls |
|---|---|---|
client.me() | client.me() |
Conversations
| TypeScript | Python | Calls |
|---|---|---|
client.conversations.list() | client.conversations.list() | |
client.conversations.get() | client.conversations.get() | |
client.conversations.update() | client.conversations.update() | |
client.conversations.messages.list() | client.conversations.messages.list() |
Analytics
| TypeScript | Python | Calls |
|---|---|---|
client.analytics.get() | client.analytics.get() |
Contacts
| TypeScript | Python | Calls |
|---|---|---|
client.contacts.list() | client.contacts.list() | |
client.contacts.create() | client.contacts.create() | |
client.contacts.get() | client.contacts.get() | |
client.contacts.update() | client.contacts.update() | |
client.contacts.delete() | client.contacts.delete() |
Templates
| TypeScript | Python | Calls |
|---|---|---|
client.templates.list() | client.templates.list() | |
client.templates.create() | client.templates.create() | |
client.templates.get() | client.templates.get() | |
client.templates.update() | client.templates.update() | |
client.templates.delete() | client.templates.delete() |
Broadcasts
| TypeScript | Python | Calls |
|---|---|---|
client.broadcasts.list() | client.broadcasts.list() | |
client.broadcasts.create() | client.broadcasts.create() | |
client.broadcasts.preview() | client.broadcasts.preview() | |
client.broadcasts.get() | client.broadcasts.get() | |
client.broadcasts.recipients.list() | client.broadcasts.recipients.list() | |
client.broadcasts.cancel() | client.broadcasts.cancel() |
Sequences
| TypeScript | Python | Calls |
|---|---|---|
client.sequences.list() | client.sequences.list() | |
client.sequences.create() | client.sequences.create() | |
client.sequences.get() | client.sequences.get() | |
client.sequences.update() | client.sequences.update() | |
client.sequences.enrollments.list() | client.sequences.enrollments.list() | |
client.sequences.enrollments.create() | client.sequences.enrollments.create() | |
client.enrollments.get() | client.enrollments.get() | |
client.enrollments.cancel() | client.enrollments.cancel() |
Text Hooks
| TypeScript | Python | Calls |
|---|---|---|
client.hooks.list() | client.hooks.list() | |
client.hooks.create() | client.hooks.create() | |
client.hooks.get() | client.hooks.get() | |
client.hooks.update() | client.hooks.update() | |
client.hooks.delete() | client.hooks.delete() |
Automations
| TypeScript | Python | Calls |
|---|---|---|
client.automations.rules.list() | client.automations.rules.list() | |
client.automations.rules.create() | client.automations.rules.create() | |
client.automations.rules.get() | client.automations.rules.get() | |
client.automations.rules.update() | client.automations.rules.update() | |
client.automations.rules.delete() | client.automations.rules.delete() | |
client.automations.away.get() | client.automations.away.get() | |
client.automations.away.update() | client.automations.away.update() |