Build Bulk SMS Broadcasting with RedwoodJS and Twilio
Learn how to build a scalable SMS broadcasting system using RedwoodJS, Twilio Messaging Services, and A2P 10DLC compliance for reliable message delivery.
Build a robust bulk SMS broadcasting system within your RedwoodJS application using Twilio's Messaging Services. This guide walks you through creating a scalable solution that handles deliverability, manages costs, and ensures compliance with A2P 10DLC regulations.
You'll build a RedwoodJS application with an interface to compose and send messages to multiple recipients stored in your database. This involves setting up the RedwoodJS project, configuring Twilio, creating the database schema, implementing backend logic in a RedwoodJS service, exposing it via GraphQL, and building a frontend to trigger broadcasts.
By the end of this guide, you'll have a functional application capable of sending SMS messages efficiently to many users, leveraging Twilio's infrastructure for scalability and reliability.
What Technologies Do You Need for Bulk SMS Broadcasting?
Goal: Create a RedwoodJS application that can send the same SMS message to a list of subscribers stored in a database via Twilio.
Problem Solved: Automates the process of sending bulk SMS notifications, announcements, or alerts, while addressing scalability and deliverability challenges.
Technologies:
RedwoodJS: A full-stack JavaScript/TypeScript framework for building modern web applications. Provides structure with conventions for API (GraphQL), services, database (Prisma), and frontend (React).
Node.js: The runtime environment for RedwoodJS's backend.
Twilio: A communication platform as a service (CPaaS) providing APIs for SMS, voice, and more. You'll use its Programmable SMS API and Messaging Services.
Prisma: A next-generation ORM used by RedwoodJS for database access.
GraphQL: The query language used by RedwoodJS for API communication.
PostgreSQL (or similar): The database to store subscriber information.
Note: For a published article, consider replacing this ASCII diagram with a clearer image format like SVG or PNG for better visual appeal and readability.
Prerequisites:
Node.js (v20 or later required for RedwoodJS) and yarn installed.
A Twilio account with Account SID, Auth Token, and a purchased phone number. Sign up for Twilio.
Access to a PostgreSQL database (or modify schema.prisma for SQLite/MySQL if preferred).
Basic understanding of RedwoodJS, React, GraphQL, and asynchronous JavaScript.
How Do You Set Up Your RedwoodJS Project for SMS Broadcasting?
Let's start by creating a new RedwoodJS project and installing the necessary dependencies.
Create RedwoodJS App:
Open your terminal and run:
Bash
yarn create redwood-app ./redwood-twilio-bulk-sms
cd redwood-twilio-bulk-sms
Choose TypeScript or JavaScript when prompted (this guide will use JavaScript examples, but the concepts are identical for TypeScript).
Install Twilio SDK:
Navigate to the api directory and install the Twilio Node.js helper library:
Bash
cd api
yarn add twilio
cd ..
Configure Environment Variables:
RedwoodJS uses a .env file for environment variables. Create one in the project root (redwood-twilio-bulk-sms/.env):
.env
# .env
DATABASE_URL=""postgresql://user:password@host:port/database?schema=public"" # Replace with your DB connection string
# Twilio Credentials - Get from twilio.com/console
TWILIO_ACCOUNT_SID=""ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx""
TWILIO_AUTH_TOKEN=""your_auth_token""
# Twilio Messaging Service SID - Create in Twilio Console (See Step 5)
TWILIO_MESSAGING_SERVICE_SID=""MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxx""
DATABASE_URL: Your database connection string. Redwood defaults to SQLite if this is omitted, but PostgreSQL is recommended for production.
TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN: Found on your main Twilio Console dashboard. Keep these secret.
TWILIO_MESSAGING_SERVICE_SID: We will create this in Step 5. Using a Messaging Service is crucial for bulk sending.
Security Note: Never commit your .env file to version control. Redwood's default .gitignore file already includes it.
scripts/: For utility scripts (like seeding the database).
schema.prisma: Defines your database models.
.env: Stores environment variables.
How Do You Create the Database Schema for SMS Subscribers?
We need a way to store the phone numbers of our subscribers.
Define the Subscriber Model:
Open api/db/schema.prisma and define a model to store subscribers:
Prisma
// api/db/schema.prisma
datasource db {
provider = ""postgresql"" // Or ""sqlite"", ""mysql""
url = env(""DATABASE_URL"")
}
generator client {
provider = ""prisma-client-js""
}
// Define your subscriber model
model Subscriber {
id Int @id @default(autoincrement())
phoneNumber String @unique // Store in E.164 format (e.g., +15551234567)
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
We use phoneNumber as unique to avoid duplicate entries.
Storing numbers in E.164 format is essential for international compatibility and Twilio.
Apply Database Migrations:
Run the following command to create and apply the database migration:
Bash
yarn rw prisma migrate dev
When prompted, give your migration a name (e.g., create_subscriber_model). This command updates your database schema and generates the Prisma Client based on your model.
Seed Sample Data (Optional):
To test our application, let's add some sample subscribers. Create a seed script:
Bash
yarn rw setup setup-server-file # If you haven't alreadytouch scripts/seed.js
Edit scripts/seed.js:
JavaScript
// scripts/seed.jsconst { db } = require('api/src/lib/db')
// IMPORTANT: Replace with valid E.164 numbers you can test with.// If using a Twilio trial account, these must be verified numbers.// CAUTION: Do NOT commit real personal or customer phone numbers// into version control, especially if this code might become public.// Use placeholder or test-specific numbers.constSUBSCRIBERS = [
{ phoneNumber: '+15551112222', name: 'Alice Test' },
{ phoneNumber: '+15553334444', name: 'Bob Test' },
// Add more test numbers here
]
constmain = async () => {
console.log('Seeding database...')
// Using Promise.allSettled to handle potential failures gracefully during seedingconst results = awaitPromise.allSettled(
SUBSCRIBERS.map((sub) => {
return db.subscriber.upsert({
where: { phoneNumber: sub.phoneNumber },
update: { name: sub.name }, // Update name if number existscreate: sub,
})
})
)
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`- Upserted subscriber: ${SUBSCRIBERS[index].phoneNumber}`)
} else {
console.error(
`- Failed to upsert subscriber ${SUBSCRIBERS[index].phoneNumber}:`,
result.reason
)
}
})
console.log('Database seeded.')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await db.$disconnect()
})
Run the seed script:
Bash
yarn rw exec seed
How Do You Implement the Core SMS Broadcasting Logic?
The core logic for sending bulk messages will reside in a RedwoodJS service function. This keeps our business logic separate from the API layer.
Generate the Service:
Use Redwood's generator to create a service file for broadcasting:
Bash
yarn rw g service broadcast
This creates api/src/services/broadcast/broadcast.js and api/src/services/broadcast/broadcast.test.js.
Implement the sendBulkSms Function:
Open api/src/services/broadcast/broadcast.js and add the following function:
JavaScript
// api/src/services/broadcast/broadcast.jsimport { db } from'src/lib/db'import { logger } from'src/lib/logger'import twilio from'twilio'// Initialize Twilio Client// Ensure TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are in your .env fileconst twilioClient = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
)
/**
* Sends a message to all subscribers using Twilio Messaging Service.
* @param {string} messageBody - The text of the message to send.
* @returns {Promise<{successCount: number, errorCount: number, errors: Array<{phoneNumber: string, error: any}>}>} - Summary of the broadcast operation.
*/exportconstsendBulkSms = async ({ messageBody }) => {
logger.info('Starting bulk SMS broadcast...')
if (!messageBody || messageBody.trim() === '') {
thrownewError('Message body cannot be empty.')
}
if (!process.env.TWILIO_MESSAGING_SERVICE_SID) {
logger.error('TWILIO_MESSAGING_SERVICE_SID is not configured.')
thrownewError('Twilio Messaging Service SID is not configured.')
}
let subscribers = []
try {
subscribers = await db.subscriber.findMany({
select: { phoneNumber: true }, // Only select necessary field
})
} catch (dbError) {
logger.error({ dbError }, 'Failed to fetch subscribers from database.')
thrownewError('Failed to fetch subscribers.')
}
if (subscribers.length === 0) {
logger.warn('No subscribers found to send messages to.')
return { successCount: 0, errorCount: 0, errors: [] }
}
logger.info(`Attempting to send SMS to ${subscribers.length} subscribers.`)
// Use Promise.allSettled to handle individual message failures without stopping the entire batchconst messagePromises = subscribers.map((subscriber) => {
// We return a new promise here that wraps the Twilio call.// This inner promise uses .then() and .catch() to ensure that// regardless of whether the Twilio API call succeeds or fails,// we resolve with an object containing the outcome status ('fulfilled' or 'rejected'),// the result/error, AND crucially, the original `subscriber.phoneNumber`.// This prevents losing the phone number context when processing results later,// especially for rejected promises.return twilioClient.messages
.create({
body: messageBody,
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID, // Use Messaging Serviceto: subscriber.phoneNumber, // Already in E.164 format from DB
})
.then((message) => ({
status: 'fulfilled',
value: message,
phoneNumber: subscriber.phoneNumber, // Preserve phoneNumber on success
}))
.catch((error) => ({
status: 'rejected',
reason: error,
phoneNumber: subscriber.phoneNumber, // Preserve phoneNumber on failure
}))
})
// Promise.allSettled waits for all wrapped promises created above to settle.const results = awaitPromise.allSettled(messagePromises)
let successCount = 0let errorCount = 0const errors = []
// Now process the results from Promise.allSettled
results.forEach((result) => {
// Note: `result.status` here refers to the settlement of the *outer* promise// returned by the `.map()` call (the one wrapping the Twilio call).// We need to check the `status` property *inside* `result.value` (for fulfilled outer promises)// or `result.reason` (for rejected outer promises, though our wrapper always fulfills)// which we explicitly set based on the Twilio call outcome.if (result.status === 'fulfilled' && result.value.status === 'fulfilled') {
// Inner promise was fulfilled (Twilio call succeeded)
successCount++
logger.debug(
`Message sent successfully to ${result.value.phoneNumber}. SID: ${result.value.value.sid}`
)
} else {
// Inner promise was rejected (Twilio call failed) or outer promise failed (unexpected)
errorCount++
// Safely extract phone number and error reason from the settled result structureconst phoneNumber = result.value?.phoneNumber || result.reason?.phoneNumber || 'unknown'const errorReason = result.value?.reason || result.reason
logger.error(
{ error: errorReason, phoneNumber },
`Failed to send message to ${phoneNumber}`
)
errors.push({
phoneNumber: phoneNumber,
error: errorReason?.message || 'Unknown error',
})
}
})
logger.info(
`Bulk SMS broadcast finished. Success: ${successCount}, Failed: ${errorCount}`
)
return {
successCount,
errorCount,
errors, // Return detailed errors for potential reporting
}
}
Initialization: The Twilio client is initialized using credentials from .env.
Input Validation: Basic check for an empty message body and presence of the Messaging Service SID.
Fetch Subscribers: Retrieves all subscriber phone numbers from the database.
Messaging Service: Crucially, it uses messagingServiceSid instead of a from number. This allows Twilio to handle number pooling, scaling, opt-out management, and helps avoid carrier filtering.
Asynchronous Sending:Promise.allSettled sends messages concurrently. It waits for all promises to either resolve or reject, making it suitable for bulk operations where some individual sends might fail. The inner promise structure ensures phone number context is maintained.
Error Handling: Captures individual message failures and logs them. Returns a summary count and detailed errors.
Logging: Uses Redwood's built-in logger for informative output.
How Do You Build the GraphQL API for Broadcasting?
We need to expose the sendBulkSms service function through Redwood's GraphQL API so the frontend can call it.
Define the GraphQL Schema:
Create a GraphQL schema definition file for broadcasting:
Bash
touch api/src/graphql/broadcast.sdl.js
Edit api/src/graphql/broadcast.sdl.js:
GraphQL
# api/src/graphql/broadcast.sdl.js
export const schema= gql`
type BroadcastResult {successCount: Int!errorCount: Int!errors:[BroadcastError!]}type BroadcastError {phoneNumber: String!error: String!}typeMutation{"""""" Sends an SMS message to all subscribers. """"""
broadcastSms(messageBody: String!): BroadcastResult!@requireAuth}
`
We define a BroadcastResult type to match the return value of our service function.
The broadcastSms mutation takes the messageBody as input.
@requireAuth: This directive enforces that only authenticated users can call this mutation. We'll set up basic auth shortly. If you don't need auth initially, you can remove it, but it's highly recommended for production.
Link Schema to Service:
Redwood automatically maps the broadcastSms mutation in the SDL to the sendBulkSms function in api/src/services/broadcast/broadcast.js because the names correspond (after removing ""send"" and converting to camelCase). No explicit mapping code is needed here due to Redwood's conventions.
Implement Basic Authentication:
For @requireAuth to work, we need basic auth setup.
Bash
yarn rw setup auth dbAuth
Follow the CLI instructions:
Generate required files.
Run yarn rw prisma migrate dev to add auth tables to the database.
This sets up username/password authentication. Important: This setup provides the backend mechanisms for authentication, but you will still need to build the actual Login and Signup pages/components in your frontend (web side) for users to authenticate. Consult the RedwoodJS Authentication documentation for detailed steps on implementing these frontend components. The @requireAuth directive protects the endpoint, assuming users can log in via those pages.
Testing the API (Conceptual):
You could use Redwood's GraphQL playground (usually available at http://localhost:8911/graphql when the dev server is running) to test the mutation after setting up login/signup pages and logging in.
Example GraphQL Mutation (requires authentication token in headers):
How Do You Configure Twilio Messaging Services for Bulk SMS?
Using a Twilio Messaging Service is critical for sending messages at scale. It provides features like number pooling, sticky sender, geo-matching, and built-in compliance features (like opt-out handling).
A2P 10DLC Registration Requirements: If you're sending SMS to US phone numbers, A2P 10DLC (Application-to-Person 10-Digit Long Code) registration is mandatory. This registration process verifies your business identity and messaging use case with carriers, ensuring better deliverability and compliance. Allocate up to 4 weeks for the complete registration process, which includes:
Brand Registration: Provide information about your business
Campaign Creation: Describe your messaging use case and opt-in/opt-out procedures
Phone Number Association: Link your numbers to the registered campaign
Without A2P 10DLC registration, US carriers may block or filter your messages.
Navigate to Twilio Console: Log in to your Twilio Console.
Create Messaging Service:
Go to Develop → Messaging → Services.
Click "Create Messaging Service".
Enter a friendly name, e.g., "RedwoodJS Broadcast Service".
Select the use case – "Notify my users" is appropriate. Click "Create".
Add Sender Number:
Inside your newly created Messaging Service, click on "Sender Pool" in the left sidebar.
Click "Add Senders".
Select "Phone Number" as the Sender Type. Click "Continue".
Choose the Twilio phone number(s) you purchased earlier and want to use for broadcasting. Click "Add Phone Numbers". Add multiple numbers to the pool for better throughput and redundancy.
Configure Compliance (Required for US Numbers):
Explore the "Opt-Out Management" section. Twilio can automatically handle standard opt-out keywords (STOP, UNSUBSCRIBE, etc.) at the Messaging Service level. Configure this appropriately for your region and use case.
Complete A2P 10DLC registration for US-bound messages through the Twilio Console under Messaging → Regulatory Compliance. This is mandatory, not optional.
Get the Messaging Service SID:
Go back to the Properties page of your Messaging Service.
Find the "Service SID" (it starts with MG). Copy this value.
Update .env File:
Paste the copied Service SID into your .env file as the value for TWILIO_MESSAGING_SERVICE_SID.
Ensure your TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are also correctly set.
.env
# .env (ensure this value is updated)
# ... other vars
TWILIO_MESSAGING_SERVICE_SID="MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # <-- Paste SID here
How Do You Build the Frontend Interface for SMS Broadcasting?
Let's create a simple page in the RedwoodJS web side to trigger the broadcast.
Generate a Page:
Bash
yarn rw g page Broadcast
This creates web/src/pages/BroadcastPage/BroadcastPage.js and updates routes.
Implement the Broadcast Form:
Open web/src/pages/BroadcastPage/BroadcastPage.js and replace its content with:
JavaScript
// web/src/pages/BroadcastPage/BroadcastPage.jsimport { useState } from'react'import { MetaTags, useMutation } from'@redwoodjs/web'import { toast, Toaster } from'@redwoodjs/web/toast'import { Form, TextAreaField, Submit } from'@redwoodjs/forms'import { useAuth } from'src/auth'// Import useAuth// GraphQL Mutation defined in api/src/graphql/broadcast.sdl.jsconstBROADCAST_SMS_MUTATION = gql`mutation BroadcastSmsMutation($messageBody: String!){
broadcastSms(messageBody:$messageBody) {
successCount
errorCount
errors {
phoneNumber
error
}}}
`constBroadcastPage = () => {
const { isAuthenticated, logIn, logOut } = useAuth() // Check authentication statusconst [broadcastResult, setBroadcastResult] = useState(null)
const [broadcastSms, { loading, error }] = useMutation(
BROADCAST_SMS_MUTATION,
{
onCompleted: (data) => {
const result = data.broadcastSmssetBroadcastResult(result) // Store result for display
toast.success(
`Broadcast finished! Sent: ${result.successCount}, Failed: ${result.errorCount}`
)
if (result.errorCount > 0) {
console.error('Broadcast errors:', result.errors)
// Optionally display errors more prominently
}
},
onError: (error) => {
toast.error(`Broadcast failed: ${error.message}`)
setBroadcastResult(null) // Clear previous results on error
},
}
)
constonSubmit = (data) => {
if (!isAuthenticated) {
toast.error('You must be logged in to send broadcasts.')
// In a real app, you might redirect to login here instead of just showing an error.return
}
console.log('Submitting broadcast:', data)
setBroadcastResult(null) // Clear previous results before sendingbroadcastSms({ variables: { messageBody: data.messageBody } })
}
// IMPORTANT: This check prevents submission if not logged in, but this page// will likely be inaccessible or visually incomplete without implementing// the actual Login/Signup pages required by the `dbAuth` setup.// You MUST build those pages for users to authenticate successfully.if (!isAuthenticated) {
return (
<div><MetaTagstitle=""BroadcastSMS"" description=""Loginrequired"" /><h1>Broadcast SMS</h1><p>Please log in to send broadcast messages.</p>
{/* In a real application, you would add a <Linkto={routes.login()}>Login</Link>
component here, assuming your login route is named 'login'. */}
</div>
)
}
// Render the form only if authenticatedreturn (
<><MetaTagstitle=""BroadcastSMS"" description=""SendbulkSMSmessages"" /><ToastertoastOptions={{className: 'rw-toast', duration:6000 }} /><h1>Broadcast SMS</h1><FormonSubmit={onSubmit}className=""rw-form-wrapper""><TextAreaFieldname=""messageBody""
placeholder=""Enteryourmessagehere...""
validation={{required:true }}
className=""rw-input""
errorClassName=""rw-inputrw-input-error""
/><Submitdisabled={loading}className=""rw-buttonrw-button-blue"">
{loading ? 'Sending...' : 'Send Broadcast'}
</Submit></Form>
{error && (
<divstyle={{color: 'red', marginTop: '1rem' }}><p>Error sending broadcast:</p><pre>{error.message}</pre></div>
)}
{broadcastResult && (
<divstyle={{marginTop: '1rem', border: '1pxsolid #ccc', padding: '1rem' }}><h2>Broadcast Results:</h2><p>Successfully Sent: {broadcastResult.successCount}</p><p>Failed to Send: {broadcastResult.errorCount}</p>
{broadcastResult.errors && broadcastResult.errors.length > 0 && (
<><h3>Errors:</h3><ul>
{broadcastResult.errors.map((err, index) => (
<likey={index}>
{err.phoneNumber}: {err.error}
</li>
))}
</ul></>
)}
</div>
)}
</>
)
}
exportdefaultBroadcastPage
GraphQL Mutation: Imports the gql tag and defines the mutation string matching the SDL.
useMutation Hook: Redwood's hook for executing mutations. Handles loading state, errors, and completion callbacks.
useAuth Hook: Checks if the user is logged in before allowing submission. Crucially, relies on login/signup pages being implemented elsewhere.
Form: Uses Redwood's form helpers (Form, TextAreaField, Submit) for easy form handling and validation.
State: Uses useState to store and display the results of the last broadcast attempt.
Feedback: Uses toast notifications for immediate user feedback. Displays detailed results and errors below the form.
Add Routes (If Necessary):
Redwood's page generator usually adds the route automatically. Verify in web/src/Routes.js that you have a route like:
JavaScript
// web/src/Routes.js// ... other importsimport { Router, Route, Set } from'@redwoodjs/router'// Ensure Set is importedimportMainLayoutfrom'src/layouts/MainLayout/MainLayout'// Example layout importconstRoutes = () => {
return (
<Router>
{/* Add auth routes (e.g., /login, /signup) generated by `yarn rw setup auth dbAuth` */}
<Setwrap={MainLayout}> {/* Example Layout wrapper */}
<Routepath=""/broadcast"" page={BroadcastPage}name=""broadcast"" />
{/* ... other routes */}
</Set>
{/* Make sure your auth routes are defined according to Redwood conventions */}
</Router>
)
}
exportdefaultRoutes
Start the Development Server:
Bash
yarn rw dev
Navigate to http://localhost:8910/broadcast. If you implemented authentication, you'll likely be redirected or see the ""Please log in"" message. You need to navigate to your login/signup pages (which you must create as part of the dbAuth setup) first. After logging in, you should be able to access /broadcast and see the broadcast form.
What Error Handling and Logging Should You Implement?
Our current implementation includes basic error handling and logging. Let's refine it.
Service Layer Errors:
The sendBulkSms service catches database errors and Twilio API errors (Promise.allSettled with inner catch).
It logs errors using logger.error with context (like phoneNumber).
It returns structured error information ({ phoneNumber, error }) to the API layer.
API Layer Errors:
GraphQL automatically catches errors from the service and formats them.
The @requireAuth directive handles authorization errors.
Frontend Errors:
The useMutation hook catches GraphQL errors in its onError callback.
toast notifications inform the user.
Logging:
Redwood's default logger (api/src/lib/logger.js) logs to the console in development.
For production, configure the logger to output structured JSON and integrate with logging services (e.g., Datadog, Logtail, Papertrail). You can customize the logger's behavior, format, and destination by modifying api/src/lib/logger.js. Consult the official RedwoodJS documentation on Logging for specific configuration examples (e.g., setting up Pino options for JSON output).
Adjust log levels (e.g., info, warn, error) as needed in production.
Example: Log Twilio SIDs on success (logger.debug(...)) for easier debugging in Twilio's console.
Retry Mechanisms:
Simple Retries: For transient network errors, you could implement a simple retry loop within the inner .catch block of the twilioClient.messages.create call before resolving the wrapper promise as rejected. Use exponential backoff (e.g., wait 1s, then 2s, then 4s). This adds complexity to the service function.
Advanced Retries (Queues): For robust handling of failures (e.g., temporary carrier issues, rate limits), the best approach is to use a background job queue.
The API endpoint would quickly add a ""broadcast job"" to a queue (e.g., using libraries like BullMQ with Redis, or cloud services like AWS SQS).
A separate RedwoodJS worker process (often set up using custom scripts or specific RedwoodJS queue integrations) would pick up the job.
The worker fetches subscribers and attempts to send messages (perhaps in smaller batches).
If a message fails, the worker can retry it based on the queue's configuration (e.g., 3 retries with exponential backoff).
This decouples the sending process from the API request, improving API response time and reliability. Implementing queues is beyond this initial guide but is the recommended path for high-volume or critical messaging. Search for RedwoodJS documentation or community examples on integrating queue systems like BullMQ.
What Security Features Should You Implement for Bulk SMS?
Security is paramount when dealing with user data and external APIs.
Authentication/Authorization: We added @requireAuth to the mutation. Ensure your auth implementation is solid (secure password handling, session management). Implement the necessary login/signup pages. Consider role-based access control if different users have different permissions.
Input Validation:
GraphQL types provide basic validation (e.g., messageBody: String!).
The service function checks for an empty messageBody.
Sanitize any user input that might be reflected elsewhere, although in this specific broadcast case, the input is just the message body sent to Twilio.
API Key Security:
Use environment variables (.env) for Twilio credentials.
Ensure .env is in .gitignore.
Use secrets management solutions (like Doppler, Vault, or platform-specific secrets like Vercel Environment Variables, Netlify Build Environment Variables, Render Secret Files) in production environments instead of committing .env files or hardcoding.
Rate Limiting:
Protect your broadcastSms endpoint from abuse. Implement rate limiting based on user ID or IP address. This prevents a single user (or bot) from initiating too many broadcasts too quickly. Options include:
RedwoodJS API Middleware: Create custom middleware to track requests per user/IP within a time window.
External Services: Use features provided by API gateways or CDN/WAF services like Cloudflare Rate Limiting or AWS WAF.
Node.js Libraries: Integrate libraries like rate-limiter-flexible within your service or custom middleware.
Example (Conceptual Middleware):
JavaScript
// api/src/lib/rateLimiter.js (Example concept)// This is illustrative and needs proper integration into Redwood's middleware chain.import { RateLimiterMemory } from'rate-limiter-flexible';
const opts = {
points: 5, // 5 requestsduration: 60, // per 60 seconds by IP
};
const rateLimiter = newRateLimiterMemory(opts);
exportconstrateLimitMiddleware = async (req, res, next) => { // Adjust signature for Redwood event/contexttry {
// Assuming IP is accessible via event.requestContext.identity.sourceIp or similarconst ip = req.ip; // Replace with actual IP source in Redwood contextawait rateLimiter.consume(ip);
returnnext(); // Or proceed with the Redwood handler
} catch (rejRes) {
// Handle rate limiting rejection (e.g., return 429 status)
res.status(429).send('Too Many Requests'); // Adjust for Redwood response
}
};
Data Protection:
Store phone numbers securely in your database.
Implement proper access controls to ensure only authorized users can view subscriber lists.
Consider encrypting sensitive data at rest and in transit.
Comply with privacy regulations (GDPR, CCPA, etc.) when handling user contact information.
Message Content Filtering:
Implement content validation to prevent sending of spam, phishing attempts, or malicious links.
Consider adding admin approval workflows for sensitive broadcast campaigns.
Frequently Asked Questions About Bulk SMS with RedwoodJS and Twilio
What is A2P 10DLC and why is it required?
A2P 10DLC (Application-to-Person 10-Digit Long Code) is a registration system required by US carriers for businesses sending SMS messages to US phone numbers. It verifies your business identity and messaging use case, improving deliverability and reducing spam. Without A2P 10DLC registration, US carriers will block or heavily filter your messages. The registration process takes up to 4 weeks and requires brand registration, campaign creation, and phone number association.
How much does it cost to send bulk SMS with Twilio?
Twilio charges per message sent, with rates varying by destination country. US SMS typically costs $0.0079 per message segment (160 characters). Additional costs include phone number rental ($1-$15/month depending on type) and A2P 10DLC registration fees (brand registration: $4 one-time, campaign registration: $10-$15/month per campaign). Using Messaging Services provides better throughput without additional per-message costs.
What is the difference between a Twilio phone number and a Messaging Service?
A Twilio phone number is a single phone number that can send and receive SMS messages. A Messaging Service is a container that manages multiple phone numbers (sender pool) and provides advanced features like automatic number selection, geographic routing, sticky sender, and built-in opt-out handling. For bulk SMS broadcasting, Messaging Services are required to achieve scale, comply with carrier requirements, and avoid rate limits.
How many SMS messages can I send per second with RedwoodJS and Twilio?
Message throughput depends on your A2P 10DLC campaign registration tier and the number of phone numbers in your Messaging Service sender pool. Standard registered campaigns typically support 60-360 messages per second per phone number. Using Promise.allSettled as shown in this guide allows concurrent sending, leveraging Twilio's infrastructure to handle the parallelization efficiently. For higher volumes, consider implementing a queue system with BullMQ.
Do I need to implement opt-out handling manually?
No. When you use Twilio Messaging Services with opt-out management enabled, Twilio automatically handles standard opt-out keywords (STOP, UNSUBSCRIBE, CANCEL, END, QUIT) and opt-in keywords (START, UNSTOP). Twilio maintains the opt-out list and prevents messages from being sent to opted-out numbers. You can access the opt-out list via Twilio's API if you need to sync it with your database.
Can I use RedwoodJS bulk SMS for marketing campaigns?
Yes, but you must obtain explicit written consent from recipients before sending marketing messages. Verbal consent is not sufficient for marketing use cases under A2P 10DLC requirements. Your opt-in process must clearly state that users agree to receive marketing messages, include "Message and data rates may apply" disclaimer, and provide a link to your privacy policy. Marketing campaigns have stricter approval requirements during A2P 10DLC registration.
What happens if a message fails to send to a subscriber?
The implementation in this guide uses Promise.allSettled, which continues sending to all subscribers even if individual sends fail. Failed messages are logged with the phone number and error reason, and returned in the API response. Common failure reasons include invalid phone numbers, carrier filtering, insufficient account balance, or opt-out status. You can implement retry logic for transient failures or use a queue system for more robust error handling.
How do I test bulk SMS without sending to real users?
During development, use Twilio's verified phone numbers feature (for trial accounts) or purchase a small number of test phone numbers. Seed your database with these test numbers only. Twilio also provides message logs in the console where you can verify delivery status without actually checking physical devices. For production testing, create a separate test campaign and send to a small group of internal test users first.
What Node.js and RedwoodJS versions do I need?
As of January 2025, RedwoodJS requires Node.js v20 or higher. Node.js v20 LTS is recommended for stability. If you use Node.js v21 or higher, be aware of potential compatibility issues with certain deployment targets like AWS Lambda. The Twilio Node.js SDK (v5.10.1 as of January 2025) is compatible with all Node.js versions supported by RedwoodJS. Use node --version to check your current version.
How do I handle international SMS with this setup?
International SMS works with the same code structure, but requires consideration of several factors: phone numbers must be in E.164 format with correct country codes, Twilio pricing varies significantly by destination country, A2P registration requirements differ by country (10DLC is US-specific), character encoding may differ (GSM-7 for most countries, UCS-2 for Unicode), and time zones should be considered for appropriate sending times. Check Twilio's international SMS documentation for country-specific requirements and restrictions.
Next Steps for Your SMS Broadcasting System
Now that you have a functional bulk SMS broadcasting system, consider these enhancements:
Implement message scheduling to send broadcasts at optimal times for your audience
Add subscriber segmentation to target specific groups with relevant messages
Integrate analytics tracking to measure message delivery rates and engagement
Set up webhook handlers to process delivery receipts and update your database
Deploy to production using Vercel, Netlify, or AWS with proper secrets management
Add message templates for common broadcast types to ensure consistent messaging
Implement message queuing with BullMQ for high-volume reliable delivery
You can send bulk SMS messages with RedwoodJS by integrating Twilio's Messaging Service. Build a RedwoodJS service function to handle the logic, expose it via a GraphQL API endpoint, and create a frontend form to trigger the broadcast. Use a Messaging Service SID for sending, not a regular Twilio number, to leverage number pooling and carrier compliance.
A Twilio Messaging Service is a feature that simplifies sending bulk SMS. It provides features like number pooling, opt-out management, and sticky sender, enhancing deliverability and scalability. It's essential for sending large volumes of SMS messages and managing compliance requirements like A2P 10DLC.
Using a Twilio Messaging Service is crucial for bulk SMS sending as it offers advantages like number pooling, managing opt-outs, and simplified compliance. These features help ensure reliable delivery and avoid carrier filtering.
For high-volume or mission-critical bulk SMS sending, use a background job queue. This separates the sending process from the user request, improving API responsiveness and providing robust retry mechanisms. Queues handle temporary failures like carrier issues, improving overall reliability.
To set up your Twilio Messaging Service SID, create a Messaging Service in the Twilio Console, add your purchased Twilio phone number to its sender pool, and then locate and copy the Service SID. Paste this SID into your RedwoodJS project's .env file as TWILIO_MESSAGING_SERVICE_SID.
You need a database model to store subscriber phone numbers. The model should at least have a phoneNumber field (ideally unique and stored in E.164 format), and optionally a name field. The example uses Prisma in the schema.prisma file.
E.164 is an international standard for phone number formatting. It ensures numbers are stored correctly for global compatibility with Twilio. An E.164 number includes a plus sign (+) followed by the country code and national subscriber number (e.g., +15551234567).
Use Promise.allSettled to handle failures gracefully. This sends messages concurrently and waits for all promises to settle (succeed or fail). Log errors with context (like the phone number) and return a summary of success/failure counts with detailed error information.
RedwoodJS services contain the core backend logic of your application. They keep your business logic separate from other layers like the API, making your code cleaner, more testable, and easier to maintain. The sendBulkSms function is a good example.
Define a mutation (e.g., broadcastSms) in your GraphQL schema (.sdl.js) that takes the message body as input. Redwood automatically maps this to your service function based on naming conventions.
Use yarn rw setup auth dbAuth to set up authentication in your RedwoodJS application. This generates necessary authentication logic. Remember to also create the frontend login/signup pages and use @requireAuth on your GraphQL mutation to enforce authentication.
Use Redwood's built-in logger (logger.error, logger.warn, etc.) for structured logging. For production, customize api/src/lib/logger.js to output JSON and integrate with a logging service. Include context like the phone number when logging SMS failures.
Yes, using a background job queue like BullMQ with Redis or a cloud-based queue is the recommended way to handle SMS retries. It decouples the retry logic from the user request, providing more robust error handling and improved reliability.
Use environment variables for Twilio credentials (never hardcode them), secure your .env file, use secrets management in production, implement input validation/sanitization, and enforce rate limiting on your API endpoints to prevent abuse.
Build a robust bulk SMS broadcasting system within your RedwoodJS application using Twilio's Messaging Services. This guide walks you through creating a scalable solution that handles deliverability, manages costs, and ensures compliance with A2P 10DLC regulations.
You'll build a RedwoodJS application with an interface to compose and send messages to multiple recipients stored in your database. This involves setting up the RedwoodJS project, configuring Twilio, creating the database schema, implementing backend logic in a RedwoodJS service, exposing it via GraphQL, and building a frontend to trigger broadcasts.
Technology versions verified as of January 2025:
By the end of this guide, you'll have a functional application capable of sending SMS messages efficiently to many users, leveraging Twilio's infrastructure for scalability and reliability.
What Technologies Do You Need for Bulk SMS Broadcasting?
Goal: Create a RedwoodJS application that can send the same SMS message to a list of subscribers stored in a database via Twilio.
Problem Solved: Automates the process of sending bulk SMS notifications, announcements, or alerts, while addressing scalability and deliverability challenges.
Technologies:
Architecture:
Note: For a published article, consider replacing this ASCII diagram with a clearer image format like SVG or PNG for better visual appeal and readability.
Prerequisites:
schema.prismafor SQLite/MySQL if preferred).How Do You Set Up Your RedwoodJS Project for SMS Broadcasting?
Let's start by creating a new RedwoodJS project and installing the necessary dependencies.
Create RedwoodJS App: Open your terminal and run:
yarn create redwood-app ./redwood-twilio-bulk-sms cd redwood-twilio-bulk-smsChoose TypeScript or JavaScript when prompted (this guide will use JavaScript examples, but the concepts are identical for TypeScript).
Install Twilio SDK: Navigate to the
apidirectory and install the Twilio Node.js helper library:cd api yarn add twilio cd ..Configure Environment Variables: RedwoodJS uses a
.envfile for environment variables. Create one in the project root (redwood-twilio-bulk-sms/.env):DATABASE_URL: Your database connection string. Redwood defaults to SQLite if this is omitted, but PostgreSQL is recommended for production.TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN: Found on your main Twilio Console dashboard. Keep these secret.TWILIO_MESSAGING_SERVICE_SID: We will create this in Step 5. Using a Messaging Service is crucial for bulk sending.Security Note: Never commit your
.envfile to version control. Redwood's default.gitignorefile already includes it.Project Structure Overview:
api/: Contains backend code (GraphQL API, services, database schema).web/: Contains frontend code (React components, pages, layouts).scripts/: For utility scripts (like seeding the database).schema.prisma: Defines your database models..env: Stores environment variables.How Do You Create the Database Schema for SMS Subscribers?
We need a way to store the phone numbers of our subscribers.
Define the Subscriber Model: Open
api/db/schema.prismaand define a model to store subscribers:phoneNumberas unique to avoid duplicate entries.Apply Database Migrations: Run the following command to create and apply the database migration:
When prompted, give your migration a name (e.g.,
create_subscriber_model). This command updates your database schema and generates the Prisma Client based on your model.Seed Sample Data (Optional): To test our application, let's add some sample subscribers. Create a seed script:
yarn rw setup setup-server-file # If you haven't already touch scripts/seed.jsEdit
scripts/seed.js:// scripts/seed.js const { db } = require('api/src/lib/db') // IMPORTANT: Replace with valid E.164 numbers you can test with. // If using a Twilio trial account, these must be verified numbers. // CAUTION: Do NOT commit real personal or customer phone numbers // into version control, especially if this code might become public. // Use placeholder or test-specific numbers. const SUBSCRIBERS = [ { phoneNumber: '+15551112222', name: 'Alice Test' }, { phoneNumber: '+15553334444', name: 'Bob Test' }, // Add more test numbers here ] const main = async () => { console.log('Seeding database...') // Using Promise.allSettled to handle potential failures gracefully during seeding const results = await Promise.allSettled( SUBSCRIBERS.map((sub) => { return db.subscriber.upsert({ where: { phoneNumber: sub.phoneNumber }, update: { name: sub.name }, // Update name if number exists create: sub, }) }) ) results.forEach((result, index) => { if (result.status === 'fulfilled') { console.log(`- Upserted subscriber: ${SUBSCRIBERS[index].phoneNumber}`) } else { console.error( `- Failed to upsert subscriber ${SUBSCRIBERS[index].phoneNumber}:`, result.reason ) } }) console.log('Database seeded.') } main() .catch((e) => { console.error(e) process.exit(1) }) .finally(async () => { await db.$disconnect() })Run the seed script:
yarn rw exec seedHow Do You Implement the Core SMS Broadcasting Logic?
The core logic for sending bulk messages will reside in a RedwoodJS service function. This keeps our business logic separate from the API layer.
Generate the Service: Use Redwood's generator to create a service file for broadcasting:
This creates
api/src/services/broadcast/broadcast.jsandapi/src/services/broadcast/broadcast.test.js.Implement the
sendBulkSmsFunction: Openapi/src/services/broadcast/broadcast.jsand add the following function:// api/src/services/broadcast/broadcast.js import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' import twilio from 'twilio' // Initialize Twilio Client // Ensure TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are in your .env file const twilioClient = twilio( process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN ) /** * Sends a message to all subscribers using Twilio Messaging Service. * @param {string} messageBody - The text of the message to send. * @returns {Promise<{successCount: number, errorCount: number, errors: Array<{phoneNumber: string, error: any}>}>} - Summary of the broadcast operation. */ export const sendBulkSms = async ({ messageBody }) => { logger.info('Starting bulk SMS broadcast...') if (!messageBody || messageBody.trim() === '') { throw new Error('Message body cannot be empty.') } if (!process.env.TWILIO_MESSAGING_SERVICE_SID) { logger.error('TWILIO_MESSAGING_SERVICE_SID is not configured.') throw new Error('Twilio Messaging Service SID is not configured.') } let subscribers = [] try { subscribers = await db.subscriber.findMany({ select: { phoneNumber: true }, // Only select necessary field }) } catch (dbError) { logger.error({ dbError }, 'Failed to fetch subscribers from database.') throw new Error('Failed to fetch subscribers.') } if (subscribers.length === 0) { logger.warn('No subscribers found to send messages to.') return { successCount: 0, errorCount: 0, errors: [] } } logger.info(`Attempting to send SMS to ${subscribers.length} subscribers.`) // Use Promise.allSettled to handle individual message failures without stopping the entire batch const messagePromises = subscribers.map((subscriber) => { // We return a new promise here that wraps the Twilio call. // This inner promise uses .then() and .catch() to ensure that // regardless of whether the Twilio API call succeeds or fails, // we resolve with an object containing the outcome status ('fulfilled' or 'rejected'), // the result/error, AND crucially, the original `subscriber.phoneNumber`. // This prevents losing the phone number context when processing results later, // especially for rejected promises. return twilioClient.messages .create({ body: messageBody, messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID, // Use Messaging Service to: subscriber.phoneNumber, // Already in E.164 format from DB }) .then((message) => ({ status: 'fulfilled', value: message, phoneNumber: subscriber.phoneNumber, // Preserve phoneNumber on success })) .catch((error) => ({ status: 'rejected', reason: error, phoneNumber: subscriber.phoneNumber, // Preserve phoneNumber on failure })) }) // Promise.allSettled waits for all wrapped promises created above to settle. const results = await Promise.allSettled(messagePromises) let successCount = 0 let errorCount = 0 const errors = [] // Now process the results from Promise.allSettled results.forEach((result) => { // Note: `result.status` here refers to the settlement of the *outer* promise // returned by the `.map()` call (the one wrapping the Twilio call). // We need to check the `status` property *inside* `result.value` (for fulfilled outer promises) // or `result.reason` (for rejected outer promises, though our wrapper always fulfills) // which we explicitly set based on the Twilio call outcome. if (result.status === 'fulfilled' && result.value.status === 'fulfilled') { // Inner promise was fulfilled (Twilio call succeeded) successCount++ logger.debug( `Message sent successfully to ${result.value.phoneNumber}. SID: ${result.value.value.sid}` ) } else { // Inner promise was rejected (Twilio call failed) or outer promise failed (unexpected) errorCount++ // Safely extract phone number and error reason from the settled result structure const phoneNumber = result.value?.phoneNumber || result.reason?.phoneNumber || 'unknown' const errorReason = result.value?.reason || result.reason logger.error( { error: errorReason, phoneNumber }, `Failed to send message to ${phoneNumber}` ) errors.push({ phoneNumber: phoneNumber, error: errorReason?.message || 'Unknown error', }) } }) logger.info( `Bulk SMS broadcast finished. Success: ${successCount}, Failed: ${errorCount}` ) return { successCount, errorCount, errors, // Return detailed errors for potential reporting } }.env.messagingServiceSidinstead of afromnumber. This allows Twilio to handle number pooling, scaling, opt-out management, and helps avoid carrier filtering.Promise.allSettledsends messages concurrently. It waits for all promises to either resolve or reject, making it suitable for bulk operations where some individual sends might fail. The inner promise structure ensures phone number context is maintained.loggerfor informative output.How Do You Build the GraphQL API for Broadcasting?
We need to expose the
sendBulkSmsservice function through Redwood's GraphQL API so the frontend can call it.Define the GraphQL Schema: Create a GraphQL schema definition file for broadcasting:
touch api/src/graphql/broadcast.sdl.jsEdit
api/src/graphql/broadcast.sdl.js:# api/src/graphql/broadcast.sdl.js export const schema = gql` type BroadcastResult { successCount: Int! errorCount: Int! errors: [BroadcastError!] } type BroadcastError { phoneNumber: String! error: String! } type Mutation { """""" Sends an SMS message to all subscribers. """""" broadcastSms(messageBody: String!): BroadcastResult! @requireAuth } `BroadcastResulttype to match the return value of our service function.broadcastSmsmutation takes themessageBodyas input.@requireAuth: This directive enforces that only authenticated users can call this mutation. We'll set up basic auth shortly. If you don't need auth initially, you can remove it, but it's highly recommended for production.Link Schema to Service: Redwood automatically maps the
broadcastSmsmutation in the SDL to thesendBulkSmsfunction inapi/src/services/broadcast/broadcast.jsbecause the names correspond (after removing ""send"" and converting to camelCase). No explicit mapping code is needed here due to Redwood's conventions.Implement Basic Authentication: For
@requireAuthto work, we need basic auth setup.Follow the CLI instructions:
yarn rw prisma migrate devto add auth tables to the database.webside) for users to authenticate. Consult the RedwoodJS Authentication documentation for detailed steps on implementing these frontend components. The@requireAuthdirective protects the endpoint, assuming users can log in via those pages.Testing the API (Conceptual): You could use Redwood's GraphQL playground (usually available at
http://localhost:8911/graphqlwhen the dev server is running) to test the mutation after setting up login/signup pages and logging in.Example GraphQL Mutation (requires authentication token in headers):
mutation SendBroadcast { broadcastSms(messageBody: ""Hello Subscribers! Special offer today!"") { successCount errorCount errors { phoneNumber error } } }How Do You Configure Twilio Messaging Services for Bulk SMS?
Using a Twilio Messaging Service is critical for sending messages at scale. It provides features like number pooling, sticky sender, geo-matching, and built-in compliance features (like opt-out handling).
A2P 10DLC Registration Requirements: If you're sending SMS to US phone numbers, A2P 10DLC (Application-to-Person 10-Digit Long Code) registration is mandatory. This registration process verifies your business identity and messaging use case with carriers, ensuring better deliverability and compliance. Allocate up to 4 weeks for the complete registration process, which includes:
Without A2P 10DLC registration, US carriers may block or filter your messages.
Navigate to Twilio Console: Log in to your Twilio Console.
Create Messaging Service:
Add Sender Number:
Configure Compliance (Required for US Numbers):
Get the Messaging Service SID:
MG). Copy this value.Update
.envFile:.envfile as the value forTWILIO_MESSAGING_SERVICE_SID.TWILIO_ACCOUNT_SIDandTWILIO_AUTH_TOKENare also correctly set.How Do You Build the Frontend Interface for SMS Broadcasting?
Let's create a simple page in the RedwoodJS web side to trigger the broadcast.
Generate a Page:
This creates
web/src/pages/BroadcastPage/BroadcastPage.jsand updates routes.Implement the Broadcast Form: Open
web/src/pages/BroadcastPage/BroadcastPage.jsand replace its content with:// web/src/pages/BroadcastPage/BroadcastPage.js import { useState } from 'react' import { MetaTags, useMutation } from '@redwoodjs/web' import { toast, Toaster } from '@redwoodjs/web/toast' import { Form, TextAreaField, Submit } from '@redwoodjs/forms' import { useAuth } from 'src/auth' // Import useAuth // GraphQL Mutation defined in api/src/graphql/broadcast.sdl.js const BROADCAST_SMS_MUTATION = gql` mutation BroadcastSmsMutation($messageBody: String!) { broadcastSms(messageBody: $messageBody) { successCount errorCount errors { phoneNumber error } } } ` const BroadcastPage = () => { const { isAuthenticated, logIn, logOut } = useAuth() // Check authentication status const [broadcastResult, setBroadcastResult] = useState(null) const [broadcastSms, { loading, error }] = useMutation( BROADCAST_SMS_MUTATION, { onCompleted: (data) => { const result = data.broadcastSms setBroadcastResult(result) // Store result for display toast.success( `Broadcast finished! Sent: ${result.successCount}, Failed: ${result.errorCount}` ) if (result.errorCount > 0) { console.error('Broadcast errors:', result.errors) // Optionally display errors more prominently } }, onError: (error) => { toast.error(`Broadcast failed: ${error.message}`) setBroadcastResult(null) // Clear previous results on error }, } ) const onSubmit = (data) => { if (!isAuthenticated) { toast.error('You must be logged in to send broadcasts.') // In a real app, you might redirect to login here instead of just showing an error. return } console.log('Submitting broadcast:', data) setBroadcastResult(null) // Clear previous results before sending broadcastSms({ variables: { messageBody: data.messageBody } }) } // IMPORTANT: This check prevents submission if not logged in, but this page // will likely be inaccessible or visually incomplete without implementing // the actual Login/Signup pages required by the `dbAuth` setup. // You MUST build those pages for users to authenticate successfully. if (!isAuthenticated) { return ( <div> <MetaTags title=""Broadcast SMS"" description=""Log in required"" /> <h1>Broadcast SMS</h1> <p>Please log in to send broadcast messages.</p> {/* In a real application, you would add a <Link to={routes.login()}>Login</Link> component here, assuming your login route is named 'login'. */} </div> ) } // Render the form only if authenticated return ( <> <MetaTags title=""Broadcast SMS"" description=""Send bulk SMS messages"" /> <Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} /> <h1>Broadcast SMS</h1> <Form onSubmit={onSubmit} className=""rw-form-wrapper""> <TextAreaField name=""messageBody"" placeholder=""Enter your message here..."" validation={{ required: true }} className=""rw-input"" errorClassName=""rw-input rw-input-error"" /> <Submit disabled={loading} className=""rw-button rw-button-blue""> {loading ? 'Sending...' : 'Send Broadcast'} </Submit> </Form> {error && ( <div style={{ color: 'red', marginTop: '1rem' }}> <p>Error sending broadcast:</p> <pre>{error.message}</pre> </div> )} {broadcastResult && ( <div style={{ marginTop: '1rem', border: '1px solid #ccc', padding: '1rem' }}> <h2>Broadcast Results:</h2> <p>Successfully Sent: {broadcastResult.successCount}</p> <p>Failed to Send: {broadcastResult.errorCount}</p> {broadcastResult.errors && broadcastResult.errors.length > 0 && ( <> <h3>Errors:</h3> <ul> {broadcastResult.errors.map((err, index) => ( <li key={index}> {err.phoneNumber}: {err.error} </li> ))} </ul> </> )} </div> )} </> ) } export default BroadcastPagegqltag and defines the mutation string matching the SDL.useMutationHook: Redwood's hook for executing mutations. Handles loading state, errors, and completion callbacks.useAuthHook: Checks if the user is logged in before allowing submission. Crucially, relies on login/signup pages being implemented elsewhere.Form,TextAreaField,Submit) for easy form handling and validation.useStateto store and display the results of the last broadcast attempt.toastnotifications for immediate user feedback. Displays detailed results and errors below the form.Add Routes (If Necessary): Redwood's page generator usually adds the route automatically. Verify in
web/src/Routes.jsthat you have a route like:// web/src/Routes.js // ... other imports import { Router, Route, Set } from '@redwoodjs/router' // Ensure Set is imported import MainLayout from 'src/layouts/MainLayout/MainLayout' // Example layout import const Routes = () => { return ( <Router> {/* Add auth routes (e.g., /login, /signup) generated by `yarn rw setup auth dbAuth` */} <Set wrap={MainLayout}> {/* Example Layout wrapper */} <Route path=""/broadcast"" page={BroadcastPage} name=""broadcast"" /> {/* ... other routes */} </Set> {/* Make sure your auth routes are defined according to Redwood conventions */} </Router> ) } export default RoutesStart the Development Server:
Navigate to
http://localhost:8910/broadcast. If you implemented authentication, you'll likely be redirected or see the ""Please log in"" message. You need to navigate to your login/signup pages (which you must create as part of thedbAuthsetup) first. After logging in, you should be able to access/broadcastand see the broadcast form.What Error Handling and Logging Should You Implement?
Our current implementation includes basic error handling and logging. Let's refine it.
sendBulkSmsservice catches database errors and Twilio API errors (Promise.allSettledwith inner catch).logger.errorwith context (likephoneNumber).{ phoneNumber, error }) to the API layer.@requireAuthdirective handles authorization errors.useMutationhook catches GraphQL errors in itsonErrorcallback.toastnotifications inform the user.api/src/lib/logger.js) logs to the console in development.api/src/lib/logger.js. Consult the official RedwoodJS documentation on Logging for specific configuration examples (e.g., setting up Pino options for JSON output).info,warn,error) as needed in production.logger.debug(...)) for easier debugging in Twilio's console..catchblock of thetwilioClient.messages.createcall before resolving the wrapper promise as rejected. Use exponential backoff (e.g., wait 1s, then 2s, then 4s). This adds complexity to the service function.What Security Features Should You Implement for Bulk SMS?
Security is paramount when dealing with user data and external APIs.
@requireAuthto the mutation. Ensure your auth implementation is solid (secure password handling, session management). Implement the necessary login/signup pages. Consider role-based access control if different users have different permissions.messageBody: String!).messageBody..env) for Twilio credentials..envis in.gitignore..envfiles or hardcoding.broadcastSmsendpoint from abuse. Implement rate limiting based on user ID or IP address. This prevents a single user (or bot) from initiating too many broadcasts too quickly. Options include:rate-limiter-flexiblewithin your service or custom middleware.// api/src/lib/rateLimiter.js (Example concept) // This is illustrative and needs proper integration into Redwood's middleware chain. import { RateLimiterMemory } from 'rate-limiter-flexible'; const opts = { points: 5, // 5 requests duration: 60, // per 60 seconds by IP }; const rateLimiter = new RateLimiterMemory(opts); export const rateLimitMiddleware = async (req, res, next) => { // Adjust signature for Redwood event/context try { // Assuming IP is accessible via event.requestContext.identity.sourceIp or similar const ip = req.ip; // Replace with actual IP source in Redwood context await rateLimiter.consume(ip); return next(); // Or proceed with the Redwood handler } catch (rejRes) { // Handle rate limiting rejection (e.g., return 429 status) res.status(429).send('Too Many Requests'); // Adjust for Redwood response } };Frequently Asked Questions About Bulk SMS with RedwoodJS and Twilio
What is A2P 10DLC and why is it required?
A2P 10DLC (Application-to-Person 10-Digit Long Code) is a registration system required by US carriers for businesses sending SMS messages to US phone numbers. It verifies your business identity and messaging use case, improving deliverability and reducing spam. Without A2P 10DLC registration, US carriers will block or heavily filter your messages. The registration process takes up to 4 weeks and requires brand registration, campaign creation, and phone number association.
How much does it cost to send bulk SMS with Twilio?
Twilio charges per message sent, with rates varying by destination country. US SMS typically costs $0.0079 per message segment (160 characters). Additional costs include phone number rental ($1-$15/month depending on type) and A2P 10DLC registration fees (brand registration: $4 one-time, campaign registration: $10-$15/month per campaign). Using Messaging Services provides better throughput without additional per-message costs.
What is the difference between a Twilio phone number and a Messaging Service?
A Twilio phone number is a single phone number that can send and receive SMS messages. A Messaging Service is a container that manages multiple phone numbers (sender pool) and provides advanced features like automatic number selection, geographic routing, sticky sender, and built-in opt-out handling. For bulk SMS broadcasting, Messaging Services are required to achieve scale, comply with carrier requirements, and avoid rate limits.
How many SMS messages can I send per second with RedwoodJS and Twilio?
Message throughput depends on your A2P 10DLC campaign registration tier and the number of phone numbers in your Messaging Service sender pool. Standard registered campaigns typically support 60-360 messages per second per phone number. Using
Promise.allSettledas shown in this guide allows concurrent sending, leveraging Twilio's infrastructure to handle the parallelization efficiently. For higher volumes, consider implementing a queue system with BullMQ.Do I need to implement opt-out handling manually?
No. When you use Twilio Messaging Services with opt-out management enabled, Twilio automatically handles standard opt-out keywords (STOP, UNSUBSCRIBE, CANCEL, END, QUIT) and opt-in keywords (START, UNSTOP). Twilio maintains the opt-out list and prevents messages from being sent to opted-out numbers. You can access the opt-out list via Twilio's API if you need to sync it with your database.
Can I use RedwoodJS bulk SMS for marketing campaigns?
Yes, but you must obtain explicit written consent from recipients before sending marketing messages. Verbal consent is not sufficient for marketing use cases under A2P 10DLC requirements. Your opt-in process must clearly state that users agree to receive marketing messages, include "Message and data rates may apply" disclaimer, and provide a link to your privacy policy. Marketing campaigns have stricter approval requirements during A2P 10DLC registration.
What happens if a message fails to send to a subscriber?
The implementation in this guide uses
Promise.allSettled, which continues sending to all subscribers even if individual sends fail. Failed messages are logged with the phone number and error reason, and returned in the API response. Common failure reasons include invalid phone numbers, carrier filtering, insufficient account balance, or opt-out status. You can implement retry logic for transient failures or use a queue system for more robust error handling.How do I test bulk SMS without sending to real users?
During development, use Twilio's verified phone numbers feature (for trial accounts) or purchase a small number of test phone numbers. Seed your database with these test numbers only. Twilio also provides message logs in the console where you can verify delivery status without actually checking physical devices. For production testing, create a separate test campaign and send to a small group of internal test users first.
What Node.js and RedwoodJS versions do I need?
As of January 2025, RedwoodJS requires Node.js v20 or higher. Node.js v20 LTS is recommended for stability. If you use Node.js v21 or higher, be aware of potential compatibility issues with certain deployment targets like AWS Lambda. The Twilio Node.js SDK (v5.10.1 as of January 2025) is compatible with all Node.js versions supported by RedwoodJS. Use
node --versionto check your current version.How do I handle international SMS with this setup?
International SMS works with the same code structure, but requires consideration of several factors: phone numbers must be in E.164 format with correct country codes, Twilio pricing varies significantly by destination country, A2P registration requirements differ by country (10DLC is US-specific), character encoding may differ (GSM-7 for most countries, UCS-2 for Unicode), and time zones should be considered for appropriate sending times. Check Twilio's international SMS documentation for country-specific requirements and restrictions.
Next Steps for Your SMS Broadcasting System
Now that you have a functional bulk SMS broadcasting system, consider these enhancements:
For more information, consult the Twilio Messaging Services documentation and RedwoodJS guides.