code examples
code examples
Build a Bulk SMS Broadcasting System with Twilio, Node.js, and Vite (React/Vue)
Learn how to build a production-ready bulk SMS broadcasting system using Twilio's Programmable Messaging API with Node.js, Express, and modern JavaScript frameworks (React or Vue with Vite). This comprehensive guide covers concurrent message sending, rate limit management, webhook delivery tracking, and deployment strategies.
In project root
Learn how to build a production-ready bulk SMS broadcasting system using Twilio's Programmable Messaging API with Node.js, Express, and modern JavaScript frameworks (React or Vue with Vite). This comprehensive guide covers concurrent message sending, rate limit management, webhook delivery tracking, and deployment strategies for sending hundreds to thousands of SMS messages efficiently.
Perfect for developers building notification systems, marketing campaigns, emergency alerts, or any application requiring mass texting capabilities. By the end, you'll have a fully functional full-stack SMS broadcast application with a modern web interface and real-time delivery status tracking.
System Architecture:
+------------------+ +---------------------+ +-----------------+ +--------------+
| React/Vue Client | ----> | Node.js/Express API | ----> | Twilio Messages | ----> | User Phones |
| (Vite Frontend) | | (Backend Server) | | API | | (Recipients) |
+------------------+ +---------------------+ +-----------------+ +--------------+
| ^
| | (Status Updates)
v |
+---------------------+
| Webhook Handler |
| (/webhooks/status) |
+---------------------+Prerequisites:
- A Twilio account. Sign up here if you don't have one.
- Node.js 18+ (LTS version recommended) installed on your system.
npmoryarnpackage manager.- A Twilio phone number capable of sending SMS. You can get one from the Twilio Console.
- Your Twilio Account SID and Auth Token from the Twilio Console.
- ngrok installed for testing webhooks locally. A free account is sufficient.
- Basic knowledge of JavaScript, React or Vue, and REST APIs.
- (Recommended) US A2P 10DLC registration if sending to US numbers. See A2P 10DLC registration.
1. Setting Up the Project
We'll create a monorepo structure with separate backend and frontend directories.
-
Create Project Directory: Open your terminal and create a new directory for your project.
bashmkdir twilio-bulk-sms-app cd twilio-bulk-sms-app -
Create Backend Directory: Set up the Express backend.
bashmkdir backend cd backend npm init -y -
Install Backend Dependencies: We need several packages for the backend:
twilio: The official Twilio Node.js library (version 4.x or later recommended).express: Web application framework for building the API and webhook handlers.dotenv: To load environment variables from a.envfile.p-limit: A utility to limit concurrency, crucial for managing Twilio API rate limits when sending bulk messages.cors: To enable cross-origin requests from the frontend.
bashnpm install twilio express dotenv p-limit cors
Why p-limit? Twilio has a concurrent API request limit of 100 requests across your account. The p-limit library prevents exceeding this limit by controlling how many API calls execute simultaneously, avoiding 429 Too Many Requests errors.
-
Backend Project Structure: Create the basic files. Your backend structure will look like this:
textbackend/ ├── node_modules/ ├── .env ├── .gitignore ├── server.js └── package.json- Create the
server.jsfile:touch server.js - Create the
.envfile:touch .env - Create the
.gitignorefile:touch .gitignore
- Create the
-
Configure Backend
.gitignore: Addnode_modulesand.envto your.gitignorefile.textnode_modules/ .env -
Configure Environment Variables (
.env): Open the.envfile and add the following. Replace placeholders with your actual Twilio credentials.dotenv# Twilio Credentials (from https://console.twilio.com) TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_AUTH_TOKEN=your_auth_token_here TWILIO_FROM_NUMBER=+15551234567 # Server Configuration PORT=3001 FRONTEND_URL=http://localhost:5173 # Concurrency Limit for Bulk Sending TWILIO_CONCURRENCY_LIMIT=10TWILIO_ACCOUNT_SID: Your Account SID from the Twilio Console.TWILIO_AUTH_TOKEN: Your Auth Token from the Twilio Console (keep this secret).TWILIO_FROM_NUMBER: Your Twilio phone number in E.164 format (e.g.,+15551234567).PORT: The port your Express server will run on (3001 to avoid conflicts with Vite's default 5173).FRONTEND_URL: URL of your frontend for CORS configuration.TWILIO_CONCURRENCY_LIMIT: Maximum concurrent API requests. Twilio allows up to 100 concurrent requests per account, but start with 10 and adjust based on testing. See Twilio Rate Limits documentation.
Rate Limit Context: Twilio enforces different throughput limits based on number type:
- Long codes: ~1 message per second (MPS)
- Toll-free (verified): 3-25+ MPS
- Short codes: 100+ MPS
- A2P 10DLC (registered): 4-200+ MPS depending on campaign type
The concurrency limit controls parallel API calls, while MPS limits control actual message delivery speed. Both must be managed for optimal performance. Source: Twilio messaging throughput documentation.
-
Create Frontend with Vite: Navigate back to the project root and create a Vite project. Choose React or Vue based on your preference.
bashcd .. npm create vite@latest frontend -- --template react # OR for Vue: # npm create vite@latest frontend -- --template vue -
Install Frontend Dependencies: Navigate to the frontend directory and install dependencies.
bashcd frontend npm install npm install axios -
Frontend Project Structure: Your frontend structure (React example) will be:
textfrontend/ ├── node_modules/ ├── public/ ├── src/ │ ├── App.jsx │ ├── main.jsx │ └── index.css ├── index.html ├── package.json └── vite.config.js
2. Integrating with Twilio Backend
Now, let's set up the backend to communicate with Twilio.
Why Twilio Programmable Messaging API? Unlike some providers that offer batch APIs, Twilio requires individual API calls per message. This approach provides granular control and status tracking for each message but requires careful concurrency management. See Twilio's bulk messaging guidance.
- Configure Twilio Client:
Open
backend/server.jsand add the following setup code:
// backend/server.js
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const twilio = require('twilio');
const pLimit = require('p-limit');
// Basic Input Validation
const isValidE164 = (phoneNumber) => /^\+[1-9]\d{1,14}$/.test(phoneNumber);
// --- Twilio Setup ---
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;
const fromNumber = process.env.TWILIO_FROM_NUMBER;
// Critical configuration checks - exit if missing
if (!accountSid || !authToken || !fromNumber) {
console.error('Error: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, or TWILIO_FROM_NUMBER missing in .env file. Exiting.');
process.exit(1);
}
// Initialize Twilio client
const client = twilio(accountSid, authToken);
// --- Concurrency Limiter ---
const concurrencyLimit = parseInt(process.env.TWILIO_CONCURRENCY_LIMIT, 10) || 10;
const limit = pLimit(concurrencyLimit);
console.log(`Concurrency limit set to: ${concurrencyLimit}`);
// --- Express App Setup ---
const app = express();
const port = process.env.PORT || 3001;
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
// Middlewares
app.use(cors({
origin: frontendUrl,
credentials: true
}));
app.use(express.json()); // Parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies (for webhooks)
// Simple logging middleware
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'Twilio Bulk SMS Backend' });
});
// API endpoints and webhook handlers will be added here
// --- Start Server ---
app.listen(port, () => {
console.log(`Backend server listening on http://localhost:${port}`);
console.log(` Twilio Account SID: ${accountSid ? accountSid.substring(0, 10) + '...' : 'MISSING!'}`);
console.log(` From Number: ${fromNumber || 'MISSING!'}`);
console.log(` Concurrency Limit: ${concurrencyLimit}`);
console.log('Backend ready to receive requests.');
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\nShutting down server...');
process.exit(0);
});CORS Configuration: The cors middleware enables the frontend (running on port 5173) to make requests to the backend (port 3001). In production, restrict origin to your actual frontend domain.
3. Implementing the Core Broadcasting Logic
Let's write the core function to handle sending messages in bulk, respecting concurrency limits.
Add this function to backend/server.js before the app.listen call:
// backend/server.js (Add this function)
/**
* Sends SMS messages to multiple recipients using the Twilio Programmable Messaging API
* with concurrency limiting to respect Twilio's 100 concurrent request limit.
*
* @param {string[]} recipients - An array of phone numbers in E.164 format.
* @param {string} message - The text message content (max 1600 characters).
* @returns {Promise<object[]>} - A promise that resolves to an array of results for each send attempt.
*/
async function sendBulkSms(recipients, message) {
console.log(`Initiating bulk send to ${recipients.length} recipients...`);
const results = [];
// Create an array of promises, each wrapped by the concurrency limiter
const sendPromises = recipients.map((recipient, index) =>
limit(async () => {
// Validate E.164 format before sending
if (!isValidE164(recipient)) {
console.warn(`[${index + 1}/${recipients.length}] Invalid phone number format skipped: ${recipient}`);
return { to: recipient, status: 'skipped', reason: 'Invalid E.164 format' };
}
console.log(`[${index + 1}/${recipients.length}] Sending to ${recipient}...`);
try {
const response = await client.messages.create({
body: message,
from: fromNumber,
to: recipient,
// Optional: Add statusCallback for webhook delivery updates
// statusCallback: 'https://your-domain.com/webhooks/status'
});
console.log(` ✓ Message dispatched to ${recipient}. SID: ${response.sid}`);
results.push({
to: recipient,
status: 'submitted',
messageSid: response.sid,
twilioStatus: response.status
});
return {
to: recipient,
status: 'submitted',
messageSid: response.sid,
twilioStatus: response.status
};
} catch (err) {
// Handle Twilio-specific errors
let errorMessage = err.message;
let errorCode = null;
if (err.code) {
errorCode = err.code;
console.error(` ✗ Error ${err.code} sending to ${recipient}: ${err.message}`);
} else {
console.error(` ✗ Error sending to ${recipient}:`, err.message);
}
results.push({
to: recipient,
status: 'failed',
error: errorMessage,
errorCode: errorCode
});
return {
to: recipient,
status: 'failed',
error: errorMessage,
errorCode: errorCode
};
}
})
);
// Wait for all limited promises to settle (complete or fail)
await Promise.allSettled(sendPromises);
const successCount = results.filter(r => r.status === 'submitted').length;
const failedCount = results.filter(r => r.status === 'failed').length;
const skippedCount = results.filter(r => r.status === 'skipped').length;
console.log(`Bulk send completed: ${successCount} submitted, ${failedCount} failed, ${skippedCount} skipped`);
return results;
}Key Implementation Details:
- p-limit Usage: Each
client.messages.create()call is wrapped inlimit(async () => {...}), ensuring no more thanTWILIO_CONCURRENCY_LIMITrequests execute simultaneously. - Individual API Calls: Twilio requires one API call per recipient. There is no batch send endpoint. Source: Twilio bulk messaging FAQ.
- Error Handling: Each message send is wrapped in try-catch to prevent one failure from stopping the entire batch.
- Status Tracking: The function returns detailed results including Twilio message SIDs for tracking.
Memory Considerations for Large Batches: For very large recipient lists (>10,000), consider:
- Processing in chunks of 1,000-5,000 recipients
- Using a job queue system (BullMQ, Agenda) instead of in-memory arrays
- Streaming results to a database rather than accumulating in memory
4. Building the API Layer
Let's create Express endpoints to receive broadcast requests from the frontend.
Add the following code in backend/server.js, after the sendBulkSms function:
// backend/server.js
// --- API Endpoints ---
/**
* POST /api/broadcast
* Initiates a bulk SMS broadcast to multiple recipients.
* Returns 202 Accepted immediately while processing in background.
*/
app.post('/api/broadcast', async (req, res) => {
const { recipients, message } = req.body;
// --- Input Validation ---
if (!Array.isArray(recipients) || recipients.length === 0) {
return res.status(400).json({
success: false,
message: 'Missing or invalid `recipients` array in request body.'
});
}
if (typeof message !== 'string' || message.trim() === '') {
return res.status(400).json({
success: false,
message: 'Missing or invalid `message` string in request body.'
});
}
// Validate message length (Twilio supports up to 1600 characters)
if (message.length > 1600) {
return res.status(400).json({
success: false,
message: 'Message exceeds maximum length of 1600 characters.'
});
}
// Limit payload size (recommended to prevent abuse)
if (recipients.length > 10000) {
return res.status(400).json({
success: false,
message: 'Too many recipients. Maximum allowed: 10000.'
});
}
console.log(`Received broadcast request for ${recipients.length} recipients.`);
try {
// Use setImmediate to allow the response to be sent quickly
// before the potentially long-running bulk send operation completes.
setImmediate(async () => {
try {
const results = await sendBulkSms(recipients, message);
console.log('Background bulk send processing finished.');
// In production, store results in database or send to monitoring service
} catch (backgroundError) {
console.error('Error during background bulk send processing:', backgroundError);
}
});
// Respond immediately with 202 Accepted
res.status(202).json({
success: true,
message: `Broadcast accepted for ${recipients.length} recipients. Processing initiated.`,
recipientCount: recipients.length,
// Note: Detailed results are not returned here due to async processing.
// Use status webhooks or query endpoints for tracking individual message outcomes.
});
} catch (error) {
console.error('Error initiating broadcast:', error);
res.status(500).json({
success: false,
message: 'Internal Server Error initiating broadcast.'
});
}
});
/**
* POST /webhooks/status
* Receives delivery status updates from Twilio for sent messages.
*/
app.post('/webhooks/status', (req, res) => {
console.log('Received Status Webhook from Twilio:');
console.log(JSON.stringify(req.body, null, 2));
const { MessageSid, MessageStatus, To, From, ErrorCode, ErrorMessage } = req.body;
// Process the status update
console.log(`[Webhook] SID: ${MessageSid}, To: ${To}, Status: ${MessageStatus}`);
if (ErrorCode) {
console.error(`[Webhook] Error ${ErrorCode}: ${ErrorMessage}`);
}
// TODO: Store status in database for tracking
// Example: await db.updateMessageStatus(MessageSid, MessageStatus, ErrorCode);
// TODO: Trigger real-time updates to frontend via WebSocket or Server-Sent Events
// Acknowledge the webhook immediately with 200 OK
// Twilio expects a response within a few seconds to prevent retries
res.status(200).end();
});API Design Decisions:
- 202 Accepted Response: Returns immediately without waiting for all messages to send. This prevents HTTP timeout issues for large batches. The alternative synchronous approach (waiting for all sends) works only for small batches (<100 recipients).
- Asynchronous Processing: Uses
setImmediatefor simplicity. For production, use a proper job queue (BullMQ, Agenda, or AWS SQS) to handle server restarts and provide job persistence. - Webhook Verification: The current implementation trusts all webhook requests. In production, verify webhook signatures to prevent spoofing. See Twilio webhook security.
Status Callback Configuration: To receive delivery updates, you must:
- Uncomment the
statusCallbackparameter insendBulkSms - Set it to your public webhook URL (e.g.,
https://your-domain.com/webhooks/status) - During local development, use ngrok to create a public URL
5. Building the Frontend Interface
Now let's create the React frontend. For Vue, the concepts are similar with different syntax.
React Implementation
Replace frontend/src/App.jsx with:
// frontend/src/App.jsx
import { useState } from 'react';
import './App.css';
const API_BASE_URL = 'http://localhost:3001';
function App() {
const [recipients, setRecipients] = useState('');
const [message, setMessage] = useState('');
const [status, setStatus] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setStatus('');
setLoading(true);
// Parse recipients from textarea (one per line or comma-separated)
const recipientList = recipients
.split(/[\n,]/)
.map(num => num.trim())
.filter(num => num.length > 0);
if (recipientList.length === 0) {
setStatus('Please enter at least one recipient phone number.');
setLoading(false);
return;
}
if (!message.trim()) {
setStatus('Please enter a message.');
setLoading(false);
return;
}
try {
const response = await fetch(`${API_BASE_URL}/api/broadcast`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipients: recipientList,
message: message.trim(),
}),
});
const data = await response.json();
if (response.ok) {
setStatus(`✓ ${data.message}`);
// Clear form on success
setRecipients('');
setMessage('');
} else {
setStatus(`✗ Error: ${data.message}`);
}
} catch (error) {
setStatus(`✗ Network error: ${error.message}`);
console.error('Error sending broadcast:', error);
} finally {
setLoading(false);
}
};
return (
<div className="App">
<header>
<h1>📱 Twilio Bulk SMS Broadcaster</h1>
<p>Send SMS messages to multiple recipients</p>
</header>
<main>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="recipients">
Recipients (E.164 format, one per line or comma-separated)
</label>
<textarea
id="recipients"
value={recipients}
onChange={(e) => setRecipients(e.target.value)}
placeholder="+15551234567 +15559876543 +447700900123"
rows="6"
disabled={loading}
/>
<small>
Example: +15551234567 (US), +447700900123 (UK)
</small>
</div>
<div className="form-group">
<label htmlFor="message">
Message ({message.length}/1600 characters)
</label>
<textarea
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Enter your message here..."
rows="4"
maxLength="1600"
disabled={loading}
/>
</div>
<button type="submit" disabled={loading}>
{loading ? 'Sending...' : 'Send Broadcast'}
</button>
</form>
{status && (
<div className={`status ${status.startsWith('✓') ? 'success' : 'error'}`}>
{status}
</div>
)}
</main>
<footer>
<p>Powered by Twilio Programmable Messaging API</p>
</footer>
</div>
);
}
export default App;Update frontend/src/App.css:
/* frontend/src/App.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.App {
max-width: 700px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
header {
background: linear-gradient(135deg, #F22F46 0%, #C2185B 100%);
color: white;
padding: 40px 30px;
text-align: center;
}
header h1 {
font-size: 2rem;
margin-bottom: 8px;
}
header p {
opacity: 0.9;
font-size: 1rem;
}
main {
padding: 40px 30px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
textarea {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s;
}
textarea:focus {
outline: none;
border-color: #667eea;
}
textarea:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
small {
display: block;
margin-top: 6px;
color: #666;
font-size: 12px;
}
button {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.status {
margin-top: 24px;
padding: 16px;
border-radius: 8px;
font-weight: 500;
}
.status.success {
background: #e8f5e9;
color: #2e7d32;
border: 1px solid #a5d6a7;
}
.status.error {
background: #ffebee;
color: #c62828;
border: 1px solid #ef9a9a;
}
footer {
background: #f5f5f5;
padding: 20px;
text-align: center;
color: #666;
font-size: 14px;
}Vue Implementation (Alternative)
If you chose Vue, replace frontend/src/App.vue with:
<!-- frontend/src/App.vue -->
<script setup>
import { ref, computed } from 'vue';
const API_BASE_URL = 'http://localhost:3001';
const recipients = ref('');
const message = ref('');
const status = ref('');
const loading = ref(false);
const messageLength = computed(() => message.value.length);
const handleSubmit = async () => {
status.value = '';
loading.value = true;
const recipientList = recipients.value
.split(/[\n,]/)
.map(num => num.trim())
.filter(num => num.length > 0);
if (recipientList.length === 0) {
status.value = 'Please enter at least one recipient phone number.';
loading.value = false;
return;
}
if (!message.value.trim()) {
status.value = 'Please enter a message.';
loading.value = false;
return;
}
try {
const response = await fetch(`${API_BASE_URL}/api/broadcast`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipients: recipientList,
message: message.value.trim(),
}),
});
const data = await response.json();
if (response.ok) {
status.value = `✓ ${data.message}`;
recipients.value = '';
message.value = '';
} else {
status.value = `✗ Error: ${data.message}`;
}
} catch (error) {
status.value = `✗ Network error: ${error.message}`;
console.error('Error sending broadcast:', error);
} finally {
loading.value = false;
}
};
</script>
<template>
<div class="app">
<header>
<h1>📱 Twilio Bulk SMS Broadcaster</h1>
<p>Send SMS messages to multiple recipients</p>
</header>
<main>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="recipients">
Recipients (E.164 format, one per line or comma-separated)
</label>
<textarea
id="recipients"
v-model="recipients"
placeholder="+15551234567 +15559876543 +447700900123"
rows="6"
:disabled="loading"
/>
<small>Example: +15551234567 (US), +447700900123 (UK)</small>
</div>
<div class="form-group">
<label for="message">
Message ({{ messageLength }}/1600 characters)
</label>
<textarea
id="message"
v-model="message"
placeholder="Enter your message here..."
rows="4"
maxlength="1600"
:disabled="loading"
/>
</div>
<button type="submit" :disabled="loading">
{{ loading ? 'Sending...' : 'Send Broadcast' }}
</button>
</form>
<div
v-if="status"
:class="['status', status.startsWith('✓') ? 'success' : 'error']"
>
{{ status }}
</div>
</main>
<footer>
<p>Powered by Twilio Programmable Messaging API</p>
</footer>
</div>
</template>
<style scoped>
/* Same CSS as React version - copy from App.css above */
/* Replace .App with .app */
</style>6. Testing Your Bulk SMS Application
Local Development Setup
-
Start the Backend: In the
backenddirectory:bashnode server.jsYou should see:
Backend server listening on http://localhost:3001 Twilio Account SID: ACxxxxxxxx... From Number: +15551234567 Concurrency Limit: 10 Backend ready to receive requests. -
Start the Frontend: In a new terminal, navigate to the
frontenddirectory:bashnpm run devVite will start the development server, typically on
http://localhost:5173. -
Test the Application:
-
Open
http://localhost:5173in your browser -
Enter test phone numbers in E.164 format (one per line):
+15551234567 +15559876543 -
Enter a test message
-
Click "Send Broadcast"
-
You should see a success message: "✓ Broadcast accepted for 2 recipients. Processing initiated."
-
Check the backend console logs to see messages being sent
-
Check your test phones for received SMS messages
-
Testing Webhooks Locally
To test delivery status webhooks:
-
Start ngrok: In a new terminal:
bashngrok http 3001Copy the HTTPS URL (e.g.,
https://abc123.ngrok.io). -
Update Webhook Configuration: In
backend/server.js, uncomment thestatusCallbackline in thesendBulkSmsfunction and update it:javascriptstatusCallback: 'https://abc123.ngrok.io/webhooks/status' -
Restart the Backend: Stop and restart
node server.js. -
Send a Test Message: Use the frontend to send a message. Watch the backend console for webhook logs showing delivery status updates (
queued,sent,delivered,failed, etc.).
7. Error Handling and Logging Strategy
The current implementation includes:
API Endpoint (/api/broadcast):
- Validates input format and size (400 Bad Request for invalid data)
- Handles internal errors (500 Internal Server Error)
- Uses
setImmediatefor background processing with separate error logging
Bulk Send Function (sendBulkSms):
- Try-catch for each individual send to isolate failures
- Logs detailed error messages including Twilio error codes
- Returns structured results with status for each recipient
Webhook Handler (/webhooks/status):
- Logs all incoming status updates
- Always returns 200 OK to prevent Twilio retries
- Includes error code handling for failed deliveries
Common Twilio Error Codes:
21211: Invalid 'To' phone number21408: Permission to send to this number not enabled21610: Attempt to send to unsubscribed recipient30007: Carrier violation (content filtering by carrier)30008: Unknown destination handset
Full error code reference: Twilio Error Code Reference
Production Logging Recommendations:
- Replace
console.*with structured logging (Winston, Pino, or Bunyan) - Log to centralized service (Datadog, Splunk, CloudWatch)
- Set up alerts for high error rates
- Track metrics: messages sent, failure rate, average delivery time
8. Security Considerations
Environment Variables:
Never commit .env files or credentials to version control. Use platform-provided secret management in production (AWS Secrets Manager, Azure Key Vault, Heroku Config Vars).
Input Validation: The current implementation validates:
- Recipient array format and size
- Message content and length
- Phone number E.164 format
Additional validation to consider:
- Rate limiting per user/IP (use
express-rate-limit) - Content filtering for prohibited content
- Duplicate request detection (idempotency keys)
Authentication: The current API has no authentication. Add authentication before production deployment:
npm install express-rate-limit// backend/server.js
const rateLimit = require('express-rate-limit');
const broadcastLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Limit each IP to 10 broadcast requests per window
message: 'Too many broadcast requests, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/broadcast', broadcastLimiter);For production, implement:
- API key authentication (check
Authorizationheader) - JWT tokens with user sessions
- OAuth 2.0 for third-party integrations
Webhook Security: Verify Twilio webhook signatures to prevent spoofing:
const twilio = require('twilio');
app.post('/webhooks/status', (req, res) => {
const twilioSignature = req.headers['x-twilio-signature'];
const url = `https://your-domain.com${req.originalUrl}`;
const isValid = twilio.validateRequest(
authToken,
twilioSignature,
url,
req.body
);
if (!isValid) {
console.error('Invalid webhook signature');
return res.status(403).send('Forbidden');
}
// Process webhook...
});Documentation: Twilio Webhook Security
HTTPS Requirements: Twilio requires HTTPS for webhook URLs in production. Use:
- Let's Encrypt certificates (free)
- Cloud platform SSL termination (AWS ALB, Cloudflare)
- Reverse proxy with SSL (nginx, Caddy)
GDPR/Privacy Compliance: When storing phone numbers and message content:
- Obtain explicit user consent
- Provide opt-out mechanisms
- Implement data retention policies
- Encrypt sensitive data at rest
- Provide data export/deletion capabilities
For US SMS, comply with TCPA regulations and A2P 10DLC requirements. See Twilio's compliance guide.
9. Performance and Scaling
Concurrency Tuning:
The TWILIO_CONCURRENCY_LIMIT controls parallel API calls. Twilio's account-level limit is 100 concurrent requests. Start with 10-20 and increase gradually while monitoring for 429 errors.
Throughput Considerations: Message delivery speed (MPS) depends on phone number type:
- Long code: 1 MPS - suitable for person-to-person messaging
- Toll-free (verified): 3-25+ MPS - good for transactional messages
- A2P 10DLC (registered): 4-200+ MPS - best for application-to-person messaging
- Short code: 100+ MPS - highest throughput, requires lengthy approval
Source: Twilio throughput limits
Queue-Based Architecture: For production-scale broadcasts (>10,000 recipients), implement a job queue:
npm install bull
npm install redis// backend/queue.js
const Queue = require('bull');
const broadcastQueue = new Queue('sms-broadcast', {
redis: { host: 'localhost', port: 6379 }
});
broadcastQueue.process(async (job) => {
const { recipients, message } = job.data;
return await sendBulkSms(recipients, message);
});
// In your API endpoint:
app.post('/api/broadcast', async (req, res) => {
const job = await broadcastQueue.add({
recipients: req.body.recipients,
message: req.body.message
});
res.status(202).json({
success: true,
jobId: job.id,
message: 'Broadcast queued'
});
});Benefits:
- Survives server restarts
- Provides job progress tracking
- Enables distributed processing across multiple workers
- Supports retry logic and failure handling
Horizontal Scaling: To scale across multiple servers:
- Deploy backend to multiple instances behind a load balancer
- Use shared Redis for state management
- Use database for tracking message status
- Configure webhook URL to point to load balancer
- Implement sticky sessions if needed for WebSocket connections
Database Integration: Store broadcast campaigns and message status:
CREATE TABLE campaigns (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
message TEXT,
recipient_count INTEGER,
created_at TIMESTAMP DEFAULT NOW(),
status VARCHAR(50)
);
CREATE TABLE messages (
id SERIAL PRIMARY KEY,
campaign_id INTEGER REFERENCES campaigns(id),
recipient VARCHAR(20),
message_sid VARCHAR(34),
status VARCHAR(50),
error_code INTEGER,
sent_at TIMESTAMP,
delivered_at TIMESTAMP
);10. Deployment
Preparing for Production
-
Environment Configuration: Create production
.envwith real credentials and public URLs. -
Build Frontend:
bashcd frontend npm run buildThis creates optimized static files in
frontend/dist. -
Serve Frontend from Backend (Optional): Modify
backend/server.jsto serve built frontend:javascriptconst path = require('path'); // Serve static frontend files app.use(express.static(path.join(__dirname, '../frontend/dist'))); // Serve index.html for all non-API routes app.get('*', (req, res) => { if (!req.path.startsWith('/api') && !req.path.startsWith('/webhooks')) { res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); } });
Platform-Specific Deployment
Heroku:
# In project root
echo "node_modules/" > .gitignore
echo ".env" >> .gitignore
# Create Procfile
echo "web: cd backend && node server.js" > Procfile
# Deploy
heroku create your-app-name
heroku config:set TWILIO_ACCOUNT_SID=ACxxxxx
heroku config:set TWILIO_AUTH_TOKEN=your_token
heroku config:set TWILIO_FROM_NUMBER=+15551234567
git push heroku mainAWS Elastic Beanstalk:
- Create
package.jsonin project root with start script - Deploy using EB CLI:
eb inittheneb create - Configure environment variables in EB console
DigitalOcean App Platform:
- Connect GitHub repository
- Configure build/run commands in dashboard
- Set environment variables in Settings
Docker Deployment:
Create Dockerfile:
FROM node:18-alpine
WORKDIR /app
# Install backend dependencies
COPY backend/package*.json ./backend/
RUN cd backend && npm ci --production
# Install and build frontend
COPY frontend/package*.json ./frontend/
RUN cd frontend && npm ci && npm run build
# Copy application files
COPY backend/ ./backend/
COPY frontend/dist/ ./backend/public/
WORKDIR /app/backend
EXPOSE 3001
CMD ["node", "server.js"]Build and run:
docker build -t twilio-bulk-sms .
docker run -p 3001:3001 --env-file .env twilio-bulk-smsPost-Deployment Tasks
-
Update Webhook URLs: Replace ngrok URL with production domain in Twilio Console or
statusCallbackparameter. -
Configure SSL: Ensure HTTPS is enabled (required for Twilio webhooks).
-
Set Up Monitoring:
- Configure error tracking (Sentry, Rollbar)
- Set up uptime monitoring (UptimeRobot, Pingdom)
- Create dashboards for message metrics
-
Test Webhooks: Send test messages and verify webhook delivery in logs.
11. Troubleshooting Common Issues
Backend won't start:
- Verify all environment variables are set correctly
- Check Twilio credentials are valid
- Ensure port 3001 is not already in use
Frontend can't connect to backend:
- Check CORS configuration includes correct frontend URL
- Verify
API_BASE_URLin frontend matches backend address - Check browser console for CORS errors
Messages not sending:
- Verify phone numbers are in E.164 format (
+and country code) - Check Twilio account has sufficient balance
- Verify sender number is SMS-capable
- Check for A2P 10DLC registration requirements (US numbers)
- Review Twilio debugger in Console for error details
Receiving 429 errors:
- Reduce
TWILIO_CONCURRENCY_LIMITvalue - Check if sending too many messages too quickly for number type
- Verify account isn't hitting monthly message cap
Webhooks not being received:
- Confirm webhook URL is publicly accessible (HTTPS required)
- Check ngrok is running during local testing
- Verify
statusCallbackparameter is correctly set - Check firewall rules allow inbound HTTPS traffic
- Use Twilio Console debugger to see webhook delivery attempts
Messages fail with error 30007:
- Carrier detected prohibited content (spam keywords, shortened URLs)
- Use registered sender IDs and comply with carrier guidelines
- Register for A2P 10DLC to improve filtering scores
12. Next Steps and Enhancements
Real-Time Status Updates: Implement WebSocket or Server-Sent Events to push delivery status updates to the frontend in real time.
Message Templates:
Create reusable message templates with variable placeholders (e.g., Hello {{name}}, your order {{orderId}} is ready).
Scheduling: Add ability to schedule broadcasts for future delivery using job scheduling (node-cron, agenda).
Contact Management: Build a contact list feature to save and organize recipient groups.
Analytics Dashboard: Track delivery rates, failure reasons, costs, and campaign performance over time.
Message History: Store and display past broadcasts with detailed status for each recipient.
Opt-Out Management: Implement automatic opt-out handling to comply with regulations and respect user preferences.
Multi-Channel Support: Extend to support WhatsApp, MMS, or other channels using Twilio's unified API.
Summary
You've built a full-stack bulk SMS broadcast system with:
- ✓ Modern React/Vue frontend with Vite
- ✓ Express backend with Twilio integration
- ✓ Concurrency-controlled bulk sending
- ✓ Webhook-based delivery tracking
- ✓ Error handling and validation
- ✓ Production-ready architecture
This foundation can scale from hundreds to millions of messages with appropriate infrastructure (job queues, horizontal scaling, database integration).
Related Resources:
- How to Send SMS Messages with Twilio
- Twilio Node.js SDK Documentation
- A2P 10DLC Registration Guide
- Building SMS Applications with Node.js
- Express.js Best Practices
For further learning:
Frequently Asked Questions
How to send bulk SMS messages with Node.js?
Use the Vonage Messages API with Node.js and Express to create an API endpoint. This endpoint receives an array of recipient numbers and a message body, then sends the messages concurrently while respecting rate limits using a concurrency limiter like 'p-limit'.
What is the Vonage Messages API?
The Vonage Messages API is a service provided by Vonage that allows you to send SMS messages programmatically. It offers various features for sending messages, managing delivery statuses, and handling different message types.
Why use ngrok for Vonage webhooks?
ngrok creates a public tunnel to your local development server, making it accessible from the internet. This allows Vonage to send webhooks to your local server during development, which is essential for testing status updates.
How to handle Vonage API rate limits in Node.js?
Use the 'p-limit' library to control the number of concurrent requests to the Vonage API. Set the limit in the VONAGE_CONCURRENCY_LIMIT environment variable, starting low (e.g., 5-10) and adjusting based on testing.
What are Vonage SMS API prerequisites?
You need a Vonage API account, Node.js, npm or yarn, a Vonage phone number capable of sending SMS, ngrok for local webhook testing, and optionally the Vonage CLI.
What is a Vonage Application?
A Vonage Application is a container for your authentication credentials and webhook configurations. It acts as an identifier for your project when interacting with the Vonage APIs.
When should I use the Vonage Messages API?
Use the Vonage Messages API when you need to send SMS messages programmatically, especially for bulk notifications, marketing campaigns, alerts, or any situation requiring automated SMS communication.
How to create a Vonage Application?
Log in to the Vonage API Dashboard, navigate to 'Applications', and create a new application. Enable the 'Messages' capability, generate public/private keys, and configure inbound/status webhook URLs.
How to set up Vonage SMS webhooks with Node.js?
Create an Express route (e.g., '/webhooks/status') to handle incoming webhook requests. Log the payload, process the status, and importantly, respond with a 200 OK status quickly to acknowledge receipt.
Can I send bulk SMS with a free Vonage account?
While Vonage may offer trial credits, bulk SMS typically incurs costs. Check Vonage's pricing for details on message costs and account limits. Be aware of compliance requirements (10DLC in the US) for application-to-person (A2P) messaging, especially for marketing/notifications, which may involve additional fees.
What is the private.key file in Vonage?
The private.key file contains your Vonage application's private key, used for authentication with the Vonage APIs. Never share this key publicly or commit it to version control. Store it securely and load it from a secure location or secrets manager in production.
Why am I getting authentication failed error with Vonage?
Check if your VONAGE_APPLICATION_ID, VONAGE_APPLICATION_PRIVATE_KEY_PATH and VONAGE_FROM_NUMBER are correctly set in the '.env' file. Also, make sure your 'private.key' file is in the correct location and isn't corrupted.
When to register for 10DLC with Vonage?
If sending application-to-person (A2P) messages to US numbers, especially for marketing or notifications, you must register for 10DLC (10-digit long code) through the Vonage dashboard to comply with US carrier regulations. This improves deliverability.
How to test Vonage SMS integration locally?
Use ngrok to create a public URL for your local server, configure this URL as your webhook endpoint in your Vonage application settings, then send test SMS messages using the API or Vonage dashboard. Monitor your server logs and the ngrok interface for requests and webhook responses.