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/sdkIn 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
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<string, string> | 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
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 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
// 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. explains the choice.
Files
Upload a file first, then send its id. A file nobody sends is deleted after a day.
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:
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:
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. 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:
// 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
// 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. explains what the answer rests on.
Settings and stops
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 pathEvents
The last 30 days of events, the same ones webhooks carry, newest first:
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
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.
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 });
}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:
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. 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<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:
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
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. has a page for each code.
Types
Everything the SDK takes and answers is exported as a type:
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 , 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:
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() | |
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() |