Skip to the page

SDKs and tools

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

npm install @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

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

OptionTypeDefaultWhat it does
apiKeystringprocess.env.BLUEBOT_API_KEYYour bb_live_… key. The constructor throws a BlueBotError if there’s none.
baseUrlstringprocess.env.BLUEBOT_BASE_URL, else https://usebluebot.com/api/v1Change it only to reach a BlueBot you run locally.
timeoutnumber60000Milliseconds to wait for each try.
maxRetriesnumber2Retries after the first try, for what is safe to retry.
defaultHeadersRecord<string, string>noneSent on every request.
fetchtypeof fetchthe global fetchYour own fetch, for a proxy or for tests.
dangerouslyAllowBrowserbooleanfalseThe 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

TypeScript
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 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 APIIn the SDK
send_at, expires_at, chat_idsendAt, expiresAt, chatId
hold.reason, failure.reason, cancel_reasonhold.reason, failure.reason, cancelReason
has_more, next_cursorhasMore, nextCursor
metadata.crm_idmetadata.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

TypeScript
// 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 explains the choice.

Files

Upload a file first, then send its id. A file nobody sends is deleted after a day.

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

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

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

TypeScript
// 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

TypeScript
// 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 explains what the answer rests on.

Settings and stops

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

TypeScript
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

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

ArgumentTypeWhat it is
payloadstring, Uint8Array or BufferThe raw body.
headersHeaders or a plain objectThe request’s headers; webhook-id, webhook-timestamp and webhook-signature are read from it, in any case.
secretstringThe webhook’s whsec_… secret. Default: process.env.BLUEBOT_WEBHOOK_SECRET.
options.tolerancenumberSeconds 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.

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 });
}
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:

TypeScript
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 has the details.

Request options

Every method takes one last, optional argument:

OptionTypeWhat it does
idempotencyKeystringThe Idempotency-Key for this POST.
timeoutnumberMilliseconds for each try of this call.
maxRetriesnumberRetries for this call.
signalAbortSignalCancels the call, retries included.
headersRecord<string, string>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:

TypeScript
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

TypeScript
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 has a page for each code.

Types

Everything the SDK takes and answers is exported as a type:

TypeScript
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 <Type>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 <Resource><Method>Params, such as WebhookCreateParams. The types are generated from the OpenAPI file, 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:

TypeScript
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

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