Send and receive SMS in JavaScript and TypeScript with a REST API
Published and updated
You need an API key and one Android phone with a SIM registered as a device. From JavaScript and TypeScript you POST to /gateway/send-sms with the x-api-key header, receive replies through a signed webhook or by polling /gateway/messages with a cursor, and the phone sends from its own number. The Pro plan, up to 5,000 messages for $9.99 a month, with no per message fee.
Prerequisites
- A textbee account with an API key from the dashboard, kept in the TEXTBEE_API_KEY environment variable and never in source control.
- An Android phone with a SIM, registered as a device on that account. The API picks your default device, so the code below names none.
- Node 18 or newer, where fetch is built in. Save the samples as .mjs files or set "type": "module" in package.json. They type check unchanged as .ts files, and the official SDK below adds types and error handling if you want them.
Send an SMS
One POST to the account-level endpoint. The phone registered on your account sends the message over its SIM and the response carries the batch id to follow.
const BASE_URL = process.env.TEXTBEE_BASE_URL ?? 'https://api.textbee.dev/api/v1'
const API_KEY = process.env.TEXTBEE_API_KEY
async function sendSms(recipients, message) {
const response = await fetch(`${BASE_URL}/gateway/send-sms`, {
method: 'POST',
headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ recipients, message }),
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const { data } = await response.json()
return data
}
const result = await sendSms(['+12015550123'], 'Hello from textbee')
console.log(result.smsBatchId, result.recipientCount)Receive SMS with a webhook
textbee POSTs each event to your URL and signs the raw body with HMAC-SHA256 using the secret you set, sent in the X-Signature header. Verify over the exact bytes, deduplicate on idempotencyKey, and answer 200 quickly.
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http'
const SECRET = process.env.TEXTBEE_WEBHOOK_SECRET
const seen = new Set() // use your database in production
function verify(rawBody, signature) {
const expected = createHmac('sha256', SECRET).update(rawBody).digest('hex')
return signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}
createServer((request, response) => {
const chunks = []
request.on('data', (chunk) => chunks.push(chunk))
request.on('end', () => {
const rawBody = Buffer.concat(chunks)
if (!verify(rawBody, request.headers['x-signature'] ?? '')) {
response.writeHead(401).end()
return
}
const event = JSON.parse(rawBody.toString())
if (!seen.has(event.idempotencyKey)) {
seen.add(event.idempotencyKey)
if (event.webhookEvent === 'MESSAGE_RECEIVED') console.log(`${event.sender}: ${event.message}`)
}
response.writeHead(200).end()
})
}).listen(process.env.PORT ?? 3000)Poll for new messages with a cursor
The pull option. Ask for received messages in ascending order from a start time, then follow meta.nextCursor until meta.hasMore is false. Store the last cursor and resume from it on the next poll, so nothing is missed or read twice.
const BASE_URL = process.env.TEXTBEE_BASE_URL ?? 'https://api.textbee.dev/api/v1'
const API_KEY = process.env.TEXTBEE_API_KEY
async function fetchPage(params) {
const response = await fetch(`${BASE_URL}/gateway/messages?${new URLSearchParams(params)}`, {
headers: { 'x-api-key': API_KEY },
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
const params = { direction: 'received', order: 'asc', limit: '50', from: '2026-09-01T00:00:00Z' }
let cursor
do {
const page = await fetchPage(cursor ? { ...params, cursor } : params)
for (const message of page.data) console.log(message._id, message.sender, message.message)
cursor = page.meta.hasMore ? page.meta.nextCursor : undefined // store it to resume the next poll
} while (cursor)Handle errors
A 401 means the key is missing or revoked, a 400 means the request or the device state was rejected, and a 429 means a plan or batch limit was hit. The body carries a message field that says which. Retry only the 429, once, after a pause.
const BASE_URL = process.env.TEXTBEE_BASE_URL ?? 'https://api.textbee.dev/api/v1'
const API_KEY = process.env.TEXTBEE_API_KEY
async function sendSms(recipients, message, retried = false) {
const response = await fetch(`${BASE_URL}/gateway/send-sms`, {
method: 'POST',
headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ recipients, message }),
})
if (response.ok) return (await response.json()).data
const detail = (await response.json().catch(() => ({}))).message ?? response.statusText
if (response.status === 429 && !retried) {
await new Promise((resolve) => setTimeout(resolve, 2000)) // a plan limit or a burst; one retry
return sendSms(recipients, message, true)
}
if (response.status === 401) throw new Error(`API key rejected: ${detail}`)
if (response.status === 400) throw new Error(`Request rejected: ${detail}`)
throw new Error(`HTTP ${response.status}: ${detail}`)
}
console.log(await sendSms(['+12015550123'], 'Hello from textbee'))Using it in Node
The samples are plain Node with no framework. Put sendSms in a module and import it wherever a message should go out. Keep the key in process.env and never in the client bundle.
Using it in Express
Mount the webhook with express.raw({ type: "application/json" }) on that route only, so req.body is the raw Buffer the signature was computed over, then verify and JSON.parse it yourself. Respond 200 before doing slow work.
Using it in Next.js
In a route handler read the body with await request.text(), verify the signature, then parse. Sending belongs in a route handler or server action, never in a client component, because the API key must stay on the server.
Using the SDK
The official @textbee/sdk package wraps these calls with TypeScript types, error classes and a webhook verifier. Install it with your package manager and call textbee.sendSms({ recipients, message }). It has zero dependencies and runs on Node 18 or newer, Bun, Deno, Cloudflare Workers and Vercel Edge. SDK documentation.
Honest limits
One phone sends roughly 10 to 15 messages a minute, so a large blast takes time to drain. Carriers can filter bulk patterns on consumer SIMs. Marketing messages still need consent from the recipient under the local rules.
Frequently asked questions
Should I use the SDK or plain fetch?
Either works, and both hit the same endpoints. The SDK gives you types, named error classes and a webhook verifier; plain fetch keeps your dependency list empty. The samples above use fetch so they run anywhere.
Can I call the API from the browser?
Do not. The API key grants access to your whole account. Call it from a server, a route handler or an edge function and let the browser call that.
Do the samples work in TypeScript?
Yes. Rename them to .ts, add types for the response shapes or use the SDK, which ships its own.
How do I test without sending a real message?
Set TEXTBEE_BASE_URL to a local stub that returns the documented response shape. The published samples are executed against exactly that kind of stub.
Read next
- Send SMS from Node.js: No Twilio, Just Your Android Phone
- How to Receive SMS and Process Webhooks with textbee
- Node.js SMS guides
- API reference
- OTP and verification guides
- SMS gateway for the United States
- Webhook, defined
- E.164 phone number format, defined
- Send and receive SMS in PHP with a REST API
- Send and receive SMS in Java with a REST API
- All languages