
Bulk SMS for Developers: Send Personalized Mass SMS Free with textbee
Send bulk SMS with textbee: the recipients array, CSV lists, personalized templates, per-recipient delivery status, and honest throughput limits.
TL;DR
- Send bulk SMS with textbee by posting a
recipientsarray toPOST /gateway/send-sms. One call, one batch, onesmsBatchIdyou use to read per-recipient delivery status later. - Every recipient in the array gets the same
messagebody. Personalized copy means one call per recipient, which is also what gives you per-recipient tracking. - Read delivery status from
GET /gateway/messages?smsBatchId=.... Statuses arepending,dispatched,sent,delivered,failed, andunknown. - "Free" is real but bounded: the free plan covers a small daily and monthly quota, and your carrier plan still pays for the actual SMS.
- Throughput is bounded by the SIM, not the API. Expect hundreds to a low few thousand messages per day per device, and pace your sends.
Bulk SMS means sending one message, or one personalized message each, to a list of recipients. This guide covers the exact API shapes, working Node.js and Python loops, phone normalization, opt-out filtering, delivery tracking, and the limits that actually bite.
How does textbee bulk send work?
The send endpoint takes an array. Every number in recipients gets the same message body, in one HTTP call, dispatched through one Android device:
curl -X POST "https://api.textbee.dev/api/v1/gateway/send-sms" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipients": ["+15550101", "+15550102", "+15550103"],
"message": "Reminder: your appointment is tomorrow at 10am. Reply to confirm."
}'The response confirms the batch was accepted:
{
"data": {
"success": true,
"message": "SMS added to queue for processing",
"smsBatchId": "66f0a1c2e4b0d3f19a2b7c48",
"recipientCount": 3
}
}Two things worth internalizing. First, acceptance is not delivery: the phone still has to be online and the carrier still has to take the message. Second, smsBatchId is the handle for everything that comes after, so store it with your job record. Large batches also return estimatedCompletionAt, because the queue releases them in waves paced to the device send delay rather than dumping them on the phone at once.
By default the send uses your default device, or the enabled device with the most recent heartbeat. Add an optional deviceId to pin a specific phone.
Free bulk SMS API: what is actually free
The free plan sends and receives real SMS, registers one device, and includes webhooks, with a daily and monthly message quota (currently 50 per day and 300 per month, per the pricing docs, checked September 2026). That is enough to build and test a bulk flow end to end without paying anything.
Be clear about what "free" means here. textbee does not charge per message, but your mobile carrier still does, because the messages leave your own SIM under your own plan. If your carrier plan includes unlimited SMS, your marginal cost really is zero. If it charges per SMS, that charge does not disappear. The Pro plan raises the quota and the device count for a flat monthly fee, and the current numbers are on the pricing page. For the wider picture of what free SMS gateways can and cannot do, see free SMS gateway options.
How do you send personalized bulk SMS in Node.js?
One message body per call means personalization is one call per recipient. That sounds wasteful and is not: you get a batch id per recipient, failures are isolated, and nobody sees anyone else's number.
import fs from 'fs';
import { parse } from 'csv-parse/sync';
const API_KEY = process.env.TEXTBEE_API_KEY;
const DELAY_MS = 1500; // pace sends so the carrier does not throttle you
async function sendSms(recipients, message) {
const res = await fetch('https://api.textbee.dev/api/v1/gateway/send-sms', {
method: 'POST',
headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ recipients, message }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return res.json();
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function sendBulkPersonalized(contacts, template) {
const results = [];
for (const contact of contacts) {
if (contact.opted_out === 'true') {
console.log(`skipped (opted out): ${contact.phone}`);
continue;
}
const message = template
.replace('{{name}}', contact.name)
.replace('{{date}}', contact.appointment_date)
.replace('{{time}}', contact.appointment_time);
try {
const { data } = await sendSms([contact.phone], message);
results.push({ phone: contact.phone, batchId: data.smsBatchId, status: 'accepted' });
} catch (err) {
console.error(`failed: ${contact.phone}: ${err.message}`);
results.push({ phone: contact.phone, status: 'failed', error: err.message });
}
await sleep(DELAY_MS);
}
return results;
}
const csv = fs.readFileSync('contacts.csv', 'utf-8');
const contacts = parse(csv, { columns: true, skip_empty_lines: true });
const template = 'Hi {{name}}, reminder: {{date}} at {{time}}. Reply YES to confirm.';
sendBulkPersonalized(contacts, template).then((results) => {
const accepted = results.filter((r) => r.status === 'accepted').length;
console.log(`accepted: ${accepted}, failed: ${results.length - accepted}`);
});The CSV it reads:
name,phone,appointment_date,appointment_time,opted_out
Sarah,+15550101,September 11,10:00am,false
Marcus,+15550102,September 11,11:30am,false
Priya,+15550103,September 12,2:00pm,trueIf you would rather not write the loop at all, the dashboard does the same thing from a CSV upload with {{ columnName }} placeholders. See sending bulk SMS.
The same loop in Python
import csv
import os
import time
import requests
API_KEY = os.environ["TEXTBEE_API_KEY"]
DELAY_S = 1.5
def send_sms(recipients: list, message: str) -> dict:
response = requests.post(
"https://api.textbee.dev/api/v1/gateway/send-sms",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
json={"recipients": recipients, "message": message},
timeout=10,
)
response.raise_for_status()
return response.json()
def send_bulk_personalized(csv_path: str, template: str) -> list:
results = []
with open(csv_path, newline="") as f:
for contact in csv.DictReader(f):
if contact.get("opted_out", "false").lower() == "true":
continue
message = (
template.replace("{{name}}", contact["name"])
.replace("{{date}}", contact["appointment_date"])
.replace("{{time}}", contact["appointment_time"])
)
try:
data = send_sms([contact["phone"]], message)["data"]
results.append({"phone": contact["phone"], "batch_id": data["smsBatchId"]})
except requests.HTTPError as exc:
print(f"failed: {contact['phone']}: {exc}")
results.append({"phone": contact["phone"], "error": str(exc)})
time.sleep(DELAY_S)
return resultsWhen everyone gets the same message
Announcements, outage alerts, and staff notices do not need personalization. Put every number in one array and send once:
const recipients = ['+15550101', '+15550102', '+15550103'];
await sendSms(recipients, 'Staff meeting moved to 3pm today. Same room.');One call, one smsBatchId, one thing to track. This is the cheaper path in every sense.
Clean the numbers before you send
Never trust a CSV. Normalize every number to E.164 first, and quarantine the ones that fail instead of dropping them silently:
function normalizePhone(raw) {
const trimmed = raw.trim();
if (trimmed.startsWith('+')) return trimmed.replace(/\s/g, '');
const digits = trimmed.replace(/\D/g, '');
if (digits.length === 10) return `+1${digits}`;
if (digits.length === 11 && digits[0] === '1') return `+${digits}`;
throw new Error(`cannot normalize: ${raw}`);
}Then filter against your opt-out list before anything leaves your server:
const optedOut = new Set(await loadOptOutList()); // E.164 strings
const eligible = contacts.filter((c) => !optedOut.has(normalizePhone(c.phone)));Keep that list current by processing STOP replies as they arrive, using an inbound webhook. The compliance checklist covers what consent you need before a bulk send and what you have to store.
How do you check per-recipient delivery status?
Read the account messages endpoint and filter by the batch id you saved:
curl "https://api.textbee.dev/api/v1/gateway/messages?smsBatchId=YOUR_BATCH_ID&limit=100" \
-H "x-api-key: YOUR_API_KEY"Each item carries recipient, status, and the timestamps for each transition. To list only the recipients that failed, add &status=failed. The statuses you will see on outbound messages are:
| Status | What it means |
|---|---|
pending | Accepted by the API, waiting in the queue |
dispatched | Handed to the push service for the device |
sent | The device reported the message as sent |
delivered | The carrier confirmed delivery |
failed | The send failed, with the reason on the message |
unknown | No status recorded, treat as indeterminate |
Not every carrier returns delivery receipts, so a message can stay at sent and still have arrived. Use failed as your retry signal, not the absence of delivered.
Throughput: what one phone can actually do
This is the part where an Android gateway differs from a carrier API. Your throughput ceiling is the SIM, not the server. In practice one consumer SIM sustains hundreds to a low few thousand messages a day, and the exact number depends on your carrier's anti-spam behavior, not on anything textbee controls.
The pacing below is our recommendation, not a carrier-published limit. Start conservative and watch the failed count before you tighten it:
| List size | Recommended pattern |
|---|---|
| Up to a few hundred | One array or a simple loop, no special handling |
| Low thousands | 1 to 2 seconds between sends, spread over hours |
| Higher, or time-boxed | Split across multiple devices |
The 1.5 second delay in the examples is a safe starting point. Carrier throttling usually shows up as silent delay or rising failures rather than an error from the API, so watch delivery status rather than assuming. Above what one device handles comfortably, add a second device and split the list.
What does bulk SMS cost elsewhere?
For US traffic, Twilio lists $0.0083 per outbound message segment on a long code, plus a per-segment carrier fee of $0.0035 to $0.005 depending on the destination carrier, plus $1.15 per month to lease the number (checked September 2026, Twilio US SMS pricing). At 2,000 US messages a month that is roughly $25 to $27 before 10DLC registration fees. The full Twilio cost breakdown walks through the fees that do not appear on the headline rate.
textbee's cost does not move with volume, because there is no per-message fee: you pay a flat plan and your SIM sends the messages. That is the whole trade. You give up global carrier routing and guaranteed throughput, and you get a cost that stays flat as your list grows.
Other bulk platforms publish rates that change by tier and top-up amount, and several do not list a US per-message price publicly at all, so compare against your own destinations rather than a blog table.
Frequently asked questions
How many recipients can I put in one API call?
There is no small fixed cap, but very large arrays are not free: the queue releases them in waves paced to your device, so a huge batch takes a long time to drain. Splitting into batches of 50 to 100 gives you a batch id per chunk, faster feedback, and easier retries when part of a list fails.
Can I personalize messages without one call per recipient?
No. The recipients array sends one message body to everyone in it, so different copy per person means a separate call per person. That is the same cost in messages, and it gives you a per-recipient batch id and independent failure handling, which is what you want anyway.
How fast can I send?
Roughly 30 to 40 messages a minute, using a 1.5 second gap, is a safe sustained rate on a consumer SIM. That is our recommendation rather than a published carrier limit. Bursting faster risks throttling, which typically appears as silent delay or a rising failed count. Test at low volume, then ramp while watching status.
Can I schedule a bulk send?
Yes. The send endpoint accepts an optional scheduledAt field, an ISO 8601 timestamp in the future. Omit it to send now. Scheduled messages are listed as a paid plan feature on the pricing docs, so check your plan before you build a flow around it. Otherwise, run your send loop from cron.
What happens if the send fails partway through the list?
The loops above log each failure and continue, which is the right behavior. Collect the failures, wait out a backoff, then retry them as a fresh batch. Check the reasons first: bad numbers should be quarantined, while a cluster of failures at the same moment usually means throttling or a device that went offline.
Get started
- Create a textbee account, free tier, no credit card
- Download the Android app
- Follow the 5-minute quickstart
- Read the compliance checklist before your first bulk send
You may also like

Best Free SMS Gateway in 2026 (No Monthly Fee, No Credit Card Trap)
Honest comparison of free SMS gateway options in 2026: textbee, Twilio trial, Vonage sandbox, TextLocal, and more. What's actually free vs what's a trial disguised as free.

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.

SMS Message Templates: 2FA, Order Updates, and Reminders
Ready-to-use SMS templates for 2FA, order confirmation, shipping, appointment reminders, and alerts. Copy, customize, and use with textbee.dev.