
Migrate from Twilio to an Android SMS Gateway: Step by Step (2026)
How to migrate SMS from Twilio to an Android phone running textbee: map every Twilio concept to its replacement, swap the send call, rewrite the status and inbound webhooks, run both side by side, then cut over. Node.js and Python code, before and after.
Migrating from Twilio to an Android SMS gateway takes four code changes and one phone. You install the textbee app on an Android phone with a SIM, swap client.messages.create for one HTTP call, replace the Twilio status callback and inbound webhook with textbee webhook events, and run both providers side by side for a week before you cut over. Your sender changes from a rented Twilio number to the SIM in the phone, 10DLC registration goes away, and the bill changes from per message to a flat plan. This guide walks through each step with the Twilio code on the left and the textbee code on the right.
TL;DR
- What changes: credentials (Account SID and Auth Token become one API key), the sender (a rented number becomes your SIM), the send call, the status callback and the inbound webhook. Everything else in your app stays.
- What does not change: E.164 phone numbers, your message copy, your consent and opt-out handling, and the idea of a webhook that tells you what happened.
- Time: an afternoon for the code, plus a week of running both providers in parallel.
- Cost after: a flat plan. Free for 300 messages a month, $14.99 a month for 5,000, $29.99 a month for 25,000 (textbee list prices, checked 2026-10-06). No number rental, no carrier surcharge, no 10DLC fees.
- Keep Twilio for: voice, WhatsApp, MMS, short codes, Verify, and volumes above a hundred thousand messages a month. The textbee vs Twilio comparison covers the line in detail.
Should you migrate at all?
Move a message flow off Twilio when it is transactional, sent to people who know you, and under a few tens of thousands of messages a month. Appointment reminders, order updates, OTP for a small app, alerts from a server, replies to customers: these are good candidates. The recipient sees a real mobile number they can text back, and the cost is fixed.
Keep a flow on Twilio when it needs something a phone cannot do. Voice calls, WhatsApp, MMS, a short code, carrier-grade throughput for bursts of thousands of messages a minute, or Twilio Verify with its fraud controls. Many teams move the human-facing messages and keep the rest, which is the pattern described in textbee vs Twilio.
| Provider | Pricing model | Free tier | Self-host | Own number | API | Coverage |
|---|---|---|---|---|---|---|
| Twilio | Per message, plus number rental and carrier fees | Trial credit, sending limited to verified numbers | No | No, rented virtual numbers | REST, SDKs in 7+ languages, webhooks | 180+ countries |
| textbee | Flat monthly plan, messages included, no per-message fee | 300 messages a month, 50 a day, no card | Yes, MIT licensed | Yes, the SIM in your phone | REST, JavaScript SDK, MCP server, webhooks | Any country where your phone has a SIM |
The Twilio alternatives post compares the other cloud providers if your reason for leaving is price alone and a phone is not an option.
What maps to what
Every Twilio concept has a textbee counterpart. Use this table as the migration checklist for your code.
| Twilio | textbee | Notes |
|---|---|---|
| Account SID and Auth Token | One API key in the x-api-key header | Create it in the dashboard under API keys |
from number, Messaging Service | The SIM in the phone, deviceId to pick a phone | Omit deviceId and textbee uses your default device |
client.messages.create() | POST /gateway/send-sms or textbee.sendSms() | recipients is an array, so one call can send to many |
MessageSid | smsBatchId for the call, smsId per recipient | One batch has one message per recipient |
statusCallback URL, form-encoded MessageStatus | Webhook subscription with MESSAGE_SENT, MESSAGE_DELIVERED, MESSAGE_FAILED | JSON body, HMAC-SHA256 signature in X-Signature |
Inbound message webhook with From, To, Body | MESSAGE_RECEIVED event with sender, message, receivedAt | Turn on Receive SMS in the app |
TwiML <Message> reply | Call POST /gateway/send-sms from the webhook handler | Reply in a background job, return 2xx first |
messages.list() | GET /gateway/messages | Account-wide, filterable by direction, status, batch and date |
scheduleType: 'fixed', sendAt | scheduledAt, up to 72 hours ahead | ISO 8601 with a timezone |
| 10DLC brand and campaign registration | None | The SIM sends as a normal subscriber. Consent rules still apply |
Request signature X-Twilio-Signature | X-Signature, HMAC-SHA256 of the raw body | Compare in constant time |
Step 1: Set up the phone
The phone is the part of the stack you now own, so set it up like a server.
- Pick a spare Android phone (Android 7.0 or newer) and put in a SIM with an SMS plan. An unlimited-text plan is the simplest option.
- Install the textbee app from the download page and sign in.
- Register the device by scanning the QR code from the dashboard. Name it, for example
prod-1. - Turn on Receive SMS in the app if you handle replies.
- Open the app's Device health screen and follow its checks. Battery optimization is the one that bites: Android pauses apps it thinks are idle, and a paused app means messages wait. The keep the phone online page has the settings for each phone maker.
- Leave the phone plugged in, on Wi-Fi, somewhere with signal.
For production, register a second phone and set one as the default. If the default goes offline, pass the other phone's deviceId in your send call. Managing devices explains defaults and heartbeats.
Step 2: Get an API key and send one message
Create an API key in the dashboard under API keys. It replaces both the Account SID and the Auth Token. Store it as TEXTBEE_API_KEY next to your old Twilio variables and send a test message from the terminal:
curl -X POST https://api.textbee.dev/api/v1/gateway/send-sms \
-H "x-api-key: $TEXTBEE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"recipients": ["+12015550123"], "message": "textbee migration test"}'The response returns smsBatchId and recipientCount. A 200 means textbee stored the message and queued it for the phone. It is the same promise Twilio makes with queued: accepted, not delivered. Delivery arrives through webhooks in step 4.
Step 3: Swap the send call
This is the one change most apps need. Keep the function signature your app already calls and change only the body.
Node.js
Before, with the Twilio SDK:
import twilio from 'twilio'
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
export async function sendSms(to, body) {
const message = await client.messages.create({
from: process.env.TWILIO_FROM_NUMBER,
to,
body,
statusCallback: 'https://example.com/twilio/status',
})
return message.sid
}After, with plain fetch:
export async function sendSms(to, body) {
const res = await fetch('https://api.textbee.dev/api/v1/gateway/send-sms', {
method: 'POST',
headers: { 'x-api-key': process.env.TEXTBEE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ recipients: [to], message: body }),
})
if (!res.ok) throw new Error(`textbee send failed: HTTP ${res.status}`)
const { data } = await res.json()
return data.smsBatchId
}Or with the @textbee/sdk package, which adds types and error classes:
import { Textbee } from '@textbee/sdk'
const textbee = new Textbee({ apiKey: process.env.TEXTBEE_API_KEY })
export async function sendSms(to, body) {
const { smsBatchId } = await textbee.sendSms({ recipients: [to], message: body })
return smsBatchId
}Three differences to notice:
- There is no
from. The sender is the SIM. PassdeviceIdonly when you want a specific phone. - There is no
statusCallbackper message. Status events go to the webhook subscription you create once in step 4. recipientsis an array. If your app loops over recipients and calls Twilio once each, you can send one request instead. Each recipient still counts as one message against your plan.
Python
Before:
import os
from twilio.rest import Client
client = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"])
def send_sms(to: str, body: str) -> str:
message = client.messages.create(
from_=os.environ["TWILIO_FROM_NUMBER"],
to=to,
body=body,
status_callback="https://example.com/twilio/status",
)
return message.sidAfter, with requests:
import os
import requests
def send_sms(to: str, body: str) -> str:
res = requests.post(
"https://api.textbee.dev/api/v1/gateway/send-sms",
headers={"x-api-key": os.environ["TEXTBEE_API_KEY"]},
json={"recipients": [to], "message": body},
timeout=30,
)
res.raise_for_status()
return res.json()["data"]["smsBatchId"]Error handling
Twilio raises on an invalid number or an unregistered sender. textbee returns 400 when there is no enabled device to send from or your email is not verified, 401 on a bad key, and 429 when your plan limit is used up. Nothing is sent on a 429, so the right move is to alert and retry after the limit resets, not to loop. The sending SMS page lists each code.
Phone numbers stay in E.164 format. If your Twilio integration already normalizes to +12015550123, nothing changes here.
Step 4: Replace the status callback
Twilio POSTs a form-encoded body to the statusCallback URL on every status change, with MessageSid, MessageStatus and sometimes ErrorCode. textbee sends JSON to a webhook subscription you create once, and signs each request.
First, create the subscription. You can do it in the dashboard under Webhooks, or with one API call:
curl -X POST https://api.textbee.dev/api/v1/webhooks \
-H "x-api-key: $TEXTBEE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Production",
"deliveryUrl": "https://example.com/textbee/webhook",
"signingSecret": "3f9a1c7e5b2d8f4a6c0e9b1d7a3f5c8e2a4b6c8d",
"events": ["MESSAGE_SENT", "MESSAGE_DELIVERED", "MESSAGE_FAILED", "MESSAGE_RECEIVED"]
}'Generate the secret with openssl rand -hex 32 and keep it on your server only.
Then map the statuses. Your database probably has a column that stores Twilio's status strings. This is how they line up:
Twilio MessageStatus | textbee event | textbee status | Final? |
|---|---|---|---|
queued, accepted, scheduled | none (the 200 response) | pending | No |
sending | none | dispatched | No |
sent | MESSAGE_SENT | sent | No |
delivered | MESSAGE_DELIVERED | delivered | Yes |
undelivered, failed | MESSAGE_FAILED | failed | Yes |
| (no equivalent) | UNKNOWN_STATE | as reported by the phone | No |
One difference matters: on Twilio, delivered is common because carriers send delivery receipts to the platform. On a phone, delivered depends on whether the carrier sends a delivery report to the handset, and some never do. Treat sent as success and delivered as a bonus. Do not block a workflow on delivered. The delivery status page explains each state and timestamp.
Node.js handler, before and after
Before, Twilio's status callback with signature validation:
import express from 'express'
import twilio from 'twilio'
const app = express()
app.post('/twilio/status', express.urlencoded({ extended: false }), (req, res) => {
const signature = req.header('X-Twilio-Signature')
const url = 'https://example.com/twilio/status'
if (!twilio.validateRequest(process.env.TWILIO_AUTH_TOKEN, signature, url, req.body)) {
return res.sendStatus(403)
}
const { MessageSid, MessageStatus, ErrorCode } = req.body
updateStatus(MessageSid, MessageStatus, ErrorCode)
res.sendStatus(204)
})After, the textbee webhook. It verifies the HMAC over the raw body, drops duplicates by idempotencyKey, and switches on the event:
import crypto from 'crypto'
import express from 'express'
const app = express()
const SECRET = process.env.TEXTBEE_WEBHOOK_SECRET
app.post('/textbee/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex')
const given = req.header('X-Signature') ?? ''
if (expected.length !== given.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) {
return res.sendStatus(401)
}
const event = JSON.parse(req.body)
if (await seenBefore(event.idempotencyKey)) return res.sendStatus(200)
switch (event.webhookEvent) {
case 'MESSAGE_SENT':
case 'MESSAGE_DELIVERED':
case 'MESSAGE_FAILED':
// smsId is the per-recipient message, smsBatchId is what sendSms returned
await updateStatus(event.smsId, event.status, event.errorCode)
break
case 'MESSAGE_RECEIVED':
await queueInbound({ from: event.sender, text: event.message, at: event.receivedAt })
break
default:
await logForReview(event)
}
res.sendStatus(200)
})Two rules carry over from Twilio but are stricter here. Return a 2xx quickly and do slow work in a background job, because a timeout counts as a failed attempt. And never move a message backwards: textbee does not guarantee event order, so a retried MESSAGE_SENT can arrive after MESSAGE_DELIVERED. Keep delivered and failed as final states. The webhook events reference has the full payload for each event.
Python handler
The same check in Flask:
import hashlib
import hmac
import json
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["TEXTBEE_WEBHOOK_SECRET"].encode()
@app.post("/textbee/webhook")
def textbee_webhook():
raw = request.get_data()
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Signature", "")):
abort(401)
event = json.loads(raw)
if seen_before(event["idempotencyKey"]):
return "", 200
kind = event["webhookEvent"]
if kind in ("MESSAGE_SENT", "MESSAGE_DELIVERED", "MESSAGE_FAILED"):
update_status(event["smsId"], event["status"], event.get("errorCode"))
elif kind == "MESSAGE_RECEIVED":
queue_inbound(event["sender"], event["message"], event["receivedAt"])
else:
log_for_review(event)
return "", 200Step 5: Replace the inbound webhook and TwiML replies
On Twilio, an incoming message hits your webhook with From, To and Body, and you reply by returning TwiML. On textbee, the MESSAGE_RECEIVED event carries sender, message and receivedAt, and you reply by sending a message like any other.
Before:
app.post('/twilio/inbound', express.urlencoded({ extended: false }), (req, res) => {
const { From, Body } = req.body
const reply = Body.trim().toUpperCase() === 'YES' ? 'Confirmed. See you tomorrow.' : 'Reply YES to confirm.'
res.type('text/xml').send(`<Response><Message>${reply}</Message></Response>`)
})After, inside the handler from step 4:
async function queueInbound({ from, text }) {
const reply = text.trim().toUpperCase() === 'YES' ? 'Confirmed. See you tomorrow.' : 'Reply YES to confirm.'
await sendSms(from, reply) // the function from step 3
}Two things to note:
- The reply goes out from the same SIM that received the message, so the conversation stays in one thread on the customer's phone.
- Turn on Receive SMS in the app, or the event never fires. A phone that was offline uploads its inbox when it reconnects, and textbee skips the event for anything older than 48 hours. The message history endpoint still returns those messages.
If your app polls messages.list() instead of using a webhook, GET /gateway/messages?direction=received&order=asc&from=<last run> is the equivalent. The message history page shows how to follow meta.nextCursor so you never read a message twice.
Step 6: Run both providers side by side, then cut over
Do not flip everything on a Friday. Route by a flag and compare for a week.
- Add a provider flag per message flow, for example
SMS_PROVIDER=twilio|textbee, and read it insendSms. Start with a low-risk flow such as internal alerts. - Send a slice through textbee. Ten percent of reminders, or every message to one test segment.
- Compare the numbers. On the textbee side,
GET /gateway/messages?direction=sent&status=failed&from=2026-10-05lists every failed send with itserrorCodeanderrorMessage. Countsentplusdeliveredagainstfailed, and compare with the same week on Twilio. - Watch the phone. The dashboard shows the last heartbeat for each device. If it goes stale, the Device health screen in the app tells you which setting to fix.
- Check daily limits. The free plan sends 50 messages a day. Upgrade before the parallel run if your flow is bigger, or the
429responses will look like failures. - Cut over one flow at a time. Keep the Twilio code path behind the flag for a month so you can fall back without a deploy.
- Tell customers the new number. Replies and callbacks now go to the SIM, not the old Twilio number. Update email signatures, booking confirmations and any "text us at" copy.
- Release the Twilio number once replies to it stop. Until then, forward its inbound webhook to a handler that posts a notice to your team.
What happens to the sender number?
This is the question every migration raises, so it gets its own section. Twilio messages come from a number you rent. textbee messages come from the SIM in the phone. textbee does not port numbers and does not know about your Twilio number.
In practice this works out well for transactional messages. Recipients see a mobile number that looks like a person, can be called back, and is not shared with other senders. Reply rates on appointment reminders tend to go up for that reason. The trade is that you own the number: if the SIM changes, the number changes. Put the SIM in a phone you keep, and treat the number like you treat a domain.
If the old Twilio number is printed on physical material, keep it on Twilio for inbound only and forward replies to your team until the material is replaced.
What it costs after the move
The Twilio totals below use its public US list prices: $0.0083 per segment, a $0.0045 carrier fee and a $1.15 local number, checked 2026-09-04 for the best SMS APIs roundup. 10DLC campaign fees come on top. The textbee totals are the plan price from the pricing page, checked 2026-10-06. Your SIM plan is extra on textbee, and an unlimited-text plan makes it a fixed number too. The Twilio pricing post itemizes every Twilio line.
| Messages a month | Twilio, all-in estimate | textbee, monthly billing | textbee, yearly billing |
|---|---|---|---|
| 1,000 | about $14 | $14.99 (Pro, 5,000 included) | $8.33 (Pro, $99.99 a year) |
| 5,000 | about $65 | $14.99 (Pro) | $8.33 (Pro) |
| 25,000 | about $321 | $29.99 (Scale, 25,000 included) | $16.67 (Scale, $199.99 a year) |
At one thousand messages a month the two are level on monthly billing, and textbee wins only on yearly billing or below 300 messages, where the free plan covers the whole flow with no card. The gap opens from there, because the textbee price does not move with volume inside a plan. The SMS cost calculator runs your own numbers.
Migration checklist
- Phone set up, registered, Device health checks green, plugged in.
TEXTBEE_API_KEYin your secrets, test message delivered from the terminal.sendSmsswapped,fromandstatusCallbackremoved,recipientsas an array.- Webhook subscription created with the four events, secret stored.
- Status handler verifies
X-Signature, deduplicates byidempotencyKey, mapssent,delivered,failed. - Inbound handler reads
senderandmessage, replies with a send call from a background job. - Provider flag in place, one flow on textbee, failed sends compared for a week.
- Daily and monthly limits checked against the plan.
- Customer-facing copy updated with the new number.
- Twilio path kept behind the flag for a month, then removed.
Frequently asked questions
Can I keep my Twilio phone number?
Not as the sender. textbee sends from the SIM in the phone, and it does not port numbers. Keep the Twilio number for inbound only during the transition, forward its replies to your team, and release it when replies stop.
Do I still need 10DLC registration?
No. 10DLC is a registration for application traffic sent through carrier A2P routes, which is what Twilio uses. A SIM in a phone sends as a normal subscriber. Consent, opt-out and content rules still apply to you, and your carrier's terms of service still apply to the SIM. Send SMS in the US without 10DLC covers where the line is.
How much throughput can one phone handle?
The phone sends one message per Send Delay gap, five seconds by default, which is about 700 messages an hour per phone. That covers reminders, notifications and OTP for most small and mid-sized apps. It does not cover a marketing blast to fifty thousand people in ten minutes. For that, keep a cloud provider or add more phones.
Does the API key replace both the Account SID and the Auth Token?
Yes. One key in the x-api-key header authenticates every request. Create separate keys for production and staging so you can revoke one without touching the other.
What happens when the phone is offline?
Sends stay pending until the phone reconnects, then go out in order. Incoming messages upload when the phone is back. Nothing is lost in a short outage. For a long one, pass a second device's deviceId in your send call, or keep Twilio behind the provider flag as the fallback.
Can I migrate scheduled messages?
Yes, for anything up to 72 hours ahead. Pass scheduledAt as an ISO 8601 time with a timezone and textbee holds the message as pending until then. For reminders further out, keep the schedule in your own job queue, which is what most apps do already.
Is there an SDK like Twilio's?
For JavaScript and TypeScript, yes: @textbee/sdk has zero dependencies and runs on Node, Bun, Deno and edge runtimes. For other languages, the REST API is two endpoints and a webhook, and the SMS API by language page has samples for each.
Can I run textbee and Twilio at the same time?
Yes, and you should during the migration. The provider flag in step 6 is the whole mechanism. After the migration, many teams keep Twilio for the flows a phone cannot serve and route the rest to textbee.
Start the migration
- Download textbee for Android and register the phone.
- Create an API key and send the test message from step 2.
- Read the webhook docs for the full payload and retry rules.
- Compare plans before the parallel run so the daily limit does not surprise you.
Get more textbee in Google Search
Add textbee.dev as a preferred source. Our posts show up more often in your Google results.
You may also like

textbee vs Twilio: Full Comparison (2026) - Pricing, Features, Which to Pick
A detailed textbee vs Twilio comparison: real 2026 pricing math, feature breakdown, migration notes, and guidance on which SMS solution fits your use case.

Twilio SMS Pricing 2026: The Real Monthly Cost (With Hidden Fees)
Twilio's $0.0083/segment headline price hides carrier fees, number rental, and 10DLC registration costs. Here's what you actually pay at 500, 2,000, and 10,000 messages/month in 2026.

How to Integrate an SMS Gateway into Your App (Step by Step, 2026)
SMS gateway integration in six steps: pick a gateway, get an API key, send from your backend, receive replies by webhook, track delivery status, and go to production. Code in curl, Node.js and Python.