JavaScript SDK
Send SMS from Node.js and TypeScript with the official @textbee/sdk package, a zero dependency wrapper around the textbee.dev REST API.
Updated
@textbee/sdk is the official JavaScript and TypeScript client for textbee.dev. It wraps the same REST API documented elsewhere in these docs, so anything you can do with the SDK you can also do with plain HTTP.
It has zero dependencies and ships its own TypeScript types. It runs on Node 18 or newer, Bun, Deno, Cloudflare Workers and Vercel Edge.
If you would rather call the API directly, see Sending SMS.
Install
pnpm add @textbee/sdkQuickstart
Generate an API key in your dashboard, then send a message:
import { Textbee } from '@textbee/sdk'
const textbee = new Textbee({ apiKey: process.env.TEXTBEE_API_KEY })
await textbee.sendSms({
recipients: ['+12025550123'],
message: 'Hello from textbee!',
})Never hardcode your API key. Read it from an environment variable so it stays out of your source control.
Send options
sendSms requires only message and recipients. The rest are optional.
| Option | Type | Purpose |
|---|---|---|
deviceId | string | Which phone sends the message |
simSubscriptionId | number | Which SIM sends it on a multi-SIM phone |
scheduledAt | string or Date | Send later instead of now |
This call uses all three options:
await textbee.sendSms({
recipients: ['+12025550123'],
message: 'Your appointment is tomorrow at 9am',
deviceId: '65f0000000000000000000aa',
simSubscriptionId: 2,
scheduledAt: new Date(Date.now() + 60 * 60 * 1000),
})sendSms returns the send response, including smsBatchId. A successful call means textbee accepted the message. It does not mean the message was delivered. See Delivery status and message states.
deviceId
Leave it out and textbee chooses the sender for you: your default device first, and otherwise the enabled device with the most recent heartbeat. Pass one to force a specific phone. You can list your device ids with getDevices(). See Managing devices.
A malformed id fails the request rather than falling back to another phone, so a typo can never send from the wrong device.
simSubscriptionId
Find this value in the textbee Android app under Dashboard, in the SIM Cards section. Each SIM shows its subscription id next to it with a copy button. The id can change when a SIM is removed and reinserted or swapped, so check it in the app again after any change to the SIMs.
Leave it out and the phone decides: it uses the preferred SIM set in the app's settings, or the system default if none is set.
This value is not validated. If the id does not match a SIM in the phone, the phone ignores it and sends from the preferred or default SIM. Nothing fails and no error is returned. If the SIM matters, confirm which number the message arrived from. See Choosing a SIM.
scheduledAt
Accepts an ISO 8601 string or a Date. It must be in the future, and you can schedule up to 72 hours ahead. Omit it to send immediately. See Scheduled messages.
Devices
These calls read your devices and change the default one:
const devices = await textbee.getDevices()
const device = await textbee.getDevice(deviceId)
// Change which device handles sends that omit deviceId
await textbee.setDefaultDevice(deviceId)Message history and delivery status
History is account-level. getMessages covers every device on the account, and it is paginated, filterable and searchable:
const { data, meta } = await textbee.getMessages({
direction: 'received', // 'all' | 'sent' | 'received'
page: 1,
limit: 50,
search: 'invoice',
})
console.log(meta.total, meta.totalPages)Narrow it with any combination of filters:
// Specific devices, instead of the whole account
await textbee.getMessages({ deviceIds: [deviceId] })
// Which recipients of a bulk send failed
const { smsBatchId } = await textbee.sendSms({ recipients, message })
await textbee.getMessages({ smsBatchId, status: 'failed' })
// A date window. from is inclusive, to is exclusive, so back to back
// windows never count a message twice. Datetimes need an explicit
// timezone; a bare date is read as UTC midnight.
await textbee.getMessages({ from: '2026-08-01', to: '2026-09-01' })The direction field on each message is lowercase, so you can pass it straight back as a filter. Message history and polling lists every filter.
Polling without duplicates
Page numbers drift when new messages arrive mid-read. iterateMessages follows the pagination cursor instead, so it yields every message exactly once:
for await (const message of textbee.iterateMessages({
direction: 'received',
order: 'asc',
from: lastRunTimestamp,
})) {
await handleMessage(message)
}Break out of the loop and it stops fetching. To resume on your next run, keep meta.nextCursor from getMessages and pass it back as cursor.
One message or one batch
getSms returns one message with its current status. getSmsBatch returns a batch and its messages, using the smsBatchId that sendSms returned:
// One message and its current status
const sms = await textbee.getSms(deviceId, smsId)
// A batch, using the smsBatchId returned by sendSms
const { batch, messages } = await textbee.getSmsBatch(deviceId, smsBatchId)Delivery status and message states explains each status and timestamp on the result.
Verifying webhooks
textbee signs every webhook delivery with HMAC-SHA256 and sends the hex digest in the X-Signature header. verifyWebhookSignature checks it with a constant-time compare.
Pass the raw request body rather than a re-serialized object whenever your framework exposes it. Re-serializing a parsed object usually produces the same bytes, but not always, and a mismatch there looks like an invalid signature.
import { verifyWebhookSignature } from '@textbee/sdk'
app.post(
'/webhooks/textbee',
express.raw({ type: 'application/json' }),
async (req, res) => {
const valid = await verifyWebhookSignature({
payload: req.body.toString('utf8'),
signature: req.get('x-signature'),
signingSecret: process.env.TEXTBEE_WEBHOOK_SECRET,
})
if (!valid) return res.sendStatus(401)
const event = JSON.parse(req.body.toString('utf8'))
// handle the event
res.sendStatus(200)
},
)The function returns a promise, so await it. Use the signing secret of the subscription that sent the request. See Webhooks for retries and idempotency, and the Webhook events reference for every payload.
SMS utilities
The SDK exports pure helpers for SMS text and phone numbers. They need no API key and make no network calls. Import only what you need, and your bundler removes the rest.
Segments and encoding
A message in the 7-bit GSM alphabet fits 160 characters in one segment. One character outside that alphabet, such as one emoji or one curly quote, switches the whole message to UCS-2. The limit then drops to 70 characters.
import { countSmsSegments, getSmsEncoding, findNonGsm7Characters } from '@textbee/sdk'
countSmsSegments("It's ready: code 123456")
// { encoding: 'gsm-7', length: 23, segments: 1, remainingInSegment: 137 }
// The same text with a curly apostrophe
countSmsSegments('It’s ready: code 123456')
// { encoding: 'ucs-2', length: 23, segments: 1, remainingInSegment: 47 }
getSmsEncoding('plain ascii') // 'gsm-7'
findNonGsm7Characters('It’s ready') // ['’']The phone splits a longer message into segments. Each segment then holds 153 characters (GSM-7) or 67 (UCS-2). remainingInSegment counts single-unit characters, so a two-unit character such as an emoji or € may not fit even when it reads as 1.
Keeping messages in GSM-7
Text pasted from a word processor or a CMS often has curly quotes, ellipses and non-breaking spaces. sanitizeForGsm7 replaces them with plain equivalents so the message stays in GSM-7 and needs fewer segments.
import { sanitizeForGsm7, countSmsSegments } from '@textbee/sdk'
const pasted = '“Your order shipped…”'
countSmsSegments(pasted).encoding // 'ucs-2'
const clean = sanitizeForGsm7(pasted) // '"Your order shipped..."'
countSmsSegments(clean).encoding // 'gsm-7'
// Optionally strip accents that GSM-7 does not carry. Letters it does carry,
// like é, ü, and ñ, are always left alone.
sanitizeForGsm7('naïve', { transliterateAccents: true }) // 'naive'It is best effort: characters with no safe equivalent pass through unchanged. Check the result with getSmsEncoding, and list what is left with findNonGsm7Characters.
Phone number helpers
isValidE164 checks the E.164 format that recipients expects. normalizePhoneNumber converts common formats to E.164:
import { isValidE164, normalizePhoneNumber } from '@textbee/sdk'
isValidE164('+12025550123') // true
isValidE164('202-555-0123') // false
normalizePhoneNumber('+1 (202) 555-0123') // '+12025550123'
normalizePhoneNumber('0012025550123') // '+12025550123'
normalizePhoneNumber('(202) 555-0123', { defaultCountryCode: '1' }) // '+12025550123'
normalizePhoneNumber('not a number') // nullThese helpers check format only. They know nothing about dialing plans, so a well-formed but unassigned number still passes. Input that cannot be normalized returns null. An unusable defaultCountryCode throws a TypeError.
Handling errors
Any non-2xx response throws a TextbeeError. It carries the HTTP status, the message, and the parsed response body. Network failures reject with the underlying fetch error instead, so you can tell the two apart.
import { TextbeeError } from '@textbee/sdk'
try {
await textbee.sendSms({ recipients: ['+12025550123'], message: 'hi' })
} catch (error) {
if (error instanceof TextbeeError) {
console.error(error.status, error.message, error.body)
} else {
throw error
}
}Common statuses: 400 for a rejected request (for example no enabled device), 401 for a missing or revoked API key, and 429 when a plan limit is used up. See Messages not sending.
Client options
The constructor takes the API key and an optional base URL:
new Textbee({
apiKey: 'your-api-key',
baseUrl: 'https://api.textbee.dev/api/v1', // override for self-hosted instances
})For a self-hosted instance, set baseUrl to your API URL including /api/v1. See Self-hosting.
What the SDK does not cover yet
The SDK focuses on sending and reading messages. Bulk sending and some device management operations are REST only for now. Use the Sending bulk SMS guide for bulk sends.
Links
- Package on npm: @textbee/sdk
- Source and issues: github.com/textbee/textbee-js
- Message history and polling: every filter the SDK passes through
- Delivery status and message states: what each status means