Official JavaScript SDK for textbee.dev, the open source SMS gateway that turns an Android phone into an SMS API.
Zero dependencies, TypeScript types included, works in Node 18+, Bun, Deno, Cloudflare Workers, and Vercel Edge.
Writing Python? Use the official Python SDK: pip install textbee.
npm install @textbee/sdkpnpm add @textbee/sdkGet an API key from the textbee dashboard, then:
import { Textbee } from '@textbee/sdk'
const textbee = new Textbee({ apiKey: process.env.TEXTBEE_API_KEY })
await textbee.sendSms({
recipients: ['+12025550123'],
message: 'Hello from textbee!',
})sendSms needs only message and recipients. Everything else is optional.
| Option | Type | What it does |
|---|---|---|
deviceId |
string |
Which phone sends the message. Omit it and textbee uses your default device, or the enabled device with the most recent heartbeat. |
simSubscriptionId |
number |
Which SIM sends the message on a multi-SIM phone. Omit it and the phone uses its configured preferred SIM, or the system default. |
scheduledAt |
string | Date |
Send later instead of now. ISO 8601 or a Date, must be in the future. |
await textbee.sendSms({
recipients: ['+12025550123'],
message: 'Your appointment is tomorrow at 9am',
deviceId: '65f0000000000000000000aa',
simSubscriptionId: 2,
scheduledAt: new Date(Date.now() + 60 * 60 * 1000),
})Open the textbee Android app, go to Dashboard, and find the SIM Cards section. Each SIM shows its subscription id with a copy button.
Be aware that this value is not validated. If the id does not match a SIM currently in the phone it is ignored, and the message goes out from the preferred or default SIM instead. Nothing errors, so confirm which SIM was used by checking the number the message arrived from.
const devices = await textbee.getDevices()
const device = await textbee.getDevice(deviceId)
// Change which device handles sends that omit deviceId
await textbee.setDefaultDevice(deviceId)History is account-level: one call covers every device, and deviceIds narrows it.
// Paginated history across the whole account, filterable and searchable
const { data, meta } = await textbee.getMessages({
direction: 'sent', // 'all' | 'sent' | 'received'
deviceIds: [deviceId], // omit for every device
status: 'delivered', // delivery state; status=failed lists failed sends
search: 'invoice',
from: '2026-08-01', // dates are UTC; datetimes need an explicit timezone
to: '2026-09-01T00:00:00Z', // exclusive, so windows never double-count
page: 1,
limit: 50,
})
// direction on each message is lowercase and feeds straight back into filters
data.filter((m) => m.direction === 'sent')
// Which recipients of a bulk send failed: filter by the batch a send returned
const { smsBatchId } = await textbee.sendSms({ recipients, message })
const failed = await textbee.getMessages({ smsBatchId, status: 'failed' })
// Drain everything matching a filter: iterateMessages follows the
// pagination cursor for you until there is nothing left
for await (const message of textbee.iterateMessages({ direction: 'received', order: 'asc' })) {
console.log(message.sender, message.message)
}
// A single message and its current status
const sms = await textbee.getSms(deviceId, smsId)
// A whole batch, using the smsBatchId returned by sendSms. This needs the id
// of the device that sent it; when textbee picked the device, use
// getMessages({ smsBatchId }) instead.
const { batch, messages } = await textbee.getSmsBatch(deviceId, smsBatchId)getSms and getSmsBatch return the Sms type. It differs from the Message that getMessages returns: device is the device id string, not an object with the phone's name and model, and direction is absent (read type).
batch.status and the batch counts come from the messages in the batch, so they change as each message reports. successCount counts messages sent or delivered, and failureCount counts failed ones. pendingCount, dispatchedCount, sentCount, deliveredCount and unknownCount break the rest down by state.
textbee signs each webhook delivery with your signing secret and sends a timestamped signature in the X-Textbee-Signature header:
X-Textbee-Signature: t=1762300000,v1=5f2b...
v1 is the hex HMAC-SHA256 of <t>.<raw body>. verifyWebhookRequest reads the signature header, checks v1 against the raw body in constant time, and rejects the request when t is more than 300 seconds from now. The signed timestamp makes a captured delivery useless after that window.
Verify the raw body exactly as received. A parsed and re-serialized body can differ by one byte and fail.
import express from 'express'
import { verifyWebhookRequest } from '@textbee/sdk'
const app = express()
app.post('/webhooks/textbee', express.raw({ type: 'application/json' }), async (req, res) => {
const valid = await verifyWebhookRequest({
payload: req.body, // a Buffer, from express.raw
headers: req.headers,
signingSecret: process.env.TEXTBEE_WEBHOOK_SECRET,
})
if (!valid) return res.sendStatus(401)
const event = JSON.parse(req.body.toString('utf8'))
// Retries carry the same idempotencyKey. Skip keys you have already handled.
res.sendStatus(200)
})payload takes a string or bytes (Buffer, Uint8Array). headers takes Node's req.headers, a plain object, or a Fetch Headers object, and header names match in any case.
Options:
toleranceSeconds: the largest allowed distance betweentand now, in either direction. Defaults to300. Keep your server clock in sync (NTP).allowLegacy: an explicit opt-in for old receivers. Off by default. When it istrueand the signature header is absent, the request is checked againstX-Signatureinstead, which has no replay protection.
To check the signature header value yourself, call verifyTimestampedWebhookSignature({ payload, signatureHeader, signingSecret }). It takes the same toleranceSeconds option.
Retries and duplicates: a failed delivery is retried with a fresh t, so each attempt passes the freshness check. Every payload carries an idempotencyKey. Store the keys you have processed and skip repeats.
Deprecated: verifyWebhookSignature checks only X-Signature. It still works because textbee still sends that header, but it has no replay protection. See textbee.dev/docs/webhooks for the upgrade steps.
Pure helpers for working with SMS text and phone numbers. No API key, no network calls, and they are useful with any SMS provider, not just textbee. Import only what you need and the rest is tree-shaken away.
Carriers bill per segment, not per message. A message stays in the 7-bit GSM alphabet at 160 characters per segment, but a single character outside that alphabet, one emoji or one curly quote, switches the whole message to UCS-2 and drops the limit to 70.
import { countSmsSegments, getSmsEncoding, findNonGsm7Characters } from '@textbee/sdk'
countSmsSegments('Your code is 123456')
// { encoding: 'gsm-7', length: 19, segments: 1, remainingInSegment: 141 }
countSmsSegments('Your code is 123456 🎉')
// { encoding: 'ucs-2', length: 22, segments: 1, remainingInSegment: 48 }
getSmsEncoding('plain ascii') // 'gsm-7'
findNonGsm7Characters('Hi 🎉') // ['🎉']Longer messages are split, and concatenation headers shrink each segment to 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.
Text pasted from a word processor or a CMS is full of curly quotes, ellipses, and non-breaking spaces. sanitizeForGsm7 swaps them for plain equivalents so a message does not silently cost three times as much.
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 untouched. Check the result with getSmsEncoding and see what is left with findNonGsm7Characters.
textbee does not require international format. Local numbers are sent as written and reach recipients in the same country as the sending SIM. Short codes are sent as written too. They work only in the sending SIM's country, and some work only on one carrier. International format works for every recipient, so use these helpers when you send to more than one country or match replies to stored numbers.
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 are format-only helpers, not libphonenumber. They know nothing about country dialing plans, so a well-formed but unassigned number still passes. Input that cannot be normalized returns null; an unusable defaultCountryCode throws a TypeError.
Any non-2xx response throws a TextbeeError carrying the status and the parsed body. Network failures reject with the underlying fetch error instead.
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)
} else {
throw error
}
}new Textbee({
apiKey: 'your-api-key',
baseUrl: 'https://api.textbee.dev/api/v1', // the default
})Point baseUrl at your own instance. The /api/v1 suffix is optional, so all of these reach the same API:
new Textbee({ apiKey, baseUrl: 'https://sms.example.com' })
new Textbee({ apiKey, baseUrl: 'https://sms.example.com/api/v1' })
new Textbee({ apiKey, baseUrl: 'http://localhost:3001' })If the API sits under a path prefix, give the prefix: https://example.com/textbee reaches https://example.com/textbee/api/v1. The SDK adds /api/v1 unless the path already contains it, the same rule the textbee MCP server uses for TEXTBEE_BASE_URL. A value with no scheme gets https://. An invalid URL, or one with a username or password, throws a TypeError instead of falling back to the hosted API, so a typo never sends your key to textbee.dev.
The SDK focuses on sending and reading messages. Bulk send and a few device operations are still REST only, documented at textbee.dev/docs.
Questions or feedback? Join the community on Discord or email support@textbee.dev.
MIT. Part of the textbee project.