tools
tools
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.
Technology versions verified as of January 2025:
- Twilio Node.js SDK: v5.10.1 (latest stable release)
- RedwoodJS: Requires Node.js v20 or higher
- A2P 10DLC: Mandatory for US-bound SMS traffic
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.
-
Architecture:
+-------------+ +------------------------+ +---------------------+ +-------------+ +-----+ | React | ----> | RedwoodJS GraphQL API | ---> | RedwoodJS Service | ---> | Twilio API | ---> | SMS | | Frontend | | (api/src/graphql) | | (api/src/services) | | (Messaging | | | | (web side) | | - broadcastSms Mutation| | - Fetches recipients| | Service) | | | +-------------+ +------------------------+ | - Calls Twilio | +-------------+ +-----+ | +---------------------+ | | v v +-----------------+ +---------------+ | Prisma Client | <-------> | Database | | (api/db) | | (Subscribers) | +-----------------+ +---------------+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.prismafor 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:
bashyarn 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:bashcd 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):dotenv# .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
.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: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
phoneNumberas unique to avoid duplicate entries. - Storing numbers in E.164 format is essential for international compatibility and Twilio.
- We use
-
Apply Database Migrations: Run the following command to create and apply the database migration:
bashyarn rw prisma migrate devWhen 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:
bashyarn rw setup setup-server-file # If you haven't already touch scripts/seed.jsEdit
scripts/seed.js:javascript// 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:
bashyarn 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:
bashyarn rw g service broadcastThis 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:javascript// 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 } }- 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
messagingServiceSidinstead of afromnumber. This allows Twilio to handle number pooling, scaling, opt-out management, and helps avoid carrier filtering. - Asynchronous Sending:
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. - Error Handling: Captures individual message failures and logs them. Returns a summary count and detailed errors.
- Logging: Uses Redwood's built-in
loggerfor informative output.
- Initialization: The Twilio client is initialized using credentials from
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:
bashtouch api/src/graphql/broadcast.sdl.jsEdit
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! } type Mutation { """""" Sends an SMS message to all subscribers. """""" broadcastSms(messageBody: String!): BroadcastResult! @requireAuth } `- We define a
BroadcastResulttype to match the return value of our service function. - The
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.
- We define a
-
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.bashyarn rw setup auth dbAuthFollow the CLI instructions:
- Generate required files.
- Run
yarn rw prisma migrate devto 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 (
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):
graphqlmutation 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:
- 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
.envFile:- Paste the copied Service SID into your
.envfile as the value forTWILIO_MESSAGING_SERVICE_SID. - Ensure your
TWILIO_ACCOUNT_SIDandTWILIO_AUTH_TOKENare also correctly set.
dotenv# .env (ensure this value is updated) # ... other vars TWILIO_MESSAGING_SERVICE_SID="MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # <-- Paste SID here - Paste the copied Service SID into your
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:
bashyarn rw g page BroadcastThis creates
web/src/pages/BroadcastPage/BroadcastPage.jsand updates routes. -
Implement the Broadcast Form: Open
web/src/pages/BroadcastPage/BroadcastPage.jsand replace its content with:javascript// 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 BroadcastPage- GraphQL Mutation: Imports the
gqltag 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: Uses Redwood's form helpers (
Form,TextAreaField,Submit) for easy form handling and validation. - State: Uses
useStateto store and display the results of the last broadcast attempt. - Feedback: Uses
toastnotifications for immediate user feedback. Displays detailed results and errors below the form.
- GraphQL Mutation: Imports the
-
Add Routes (If Necessary): Redwood's page generator usually adds the route automatically. Verify in
web/src/Routes.jsthat you have a route like:javascript// 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 Routes -
Start the Development Server:
bashyarn rw devNavigate 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.
- Service Layer Errors:
- The
sendBulkSmsservice catches database errors and Twilio API errors (Promise.allSettledwith inner catch). - It logs errors using
logger.errorwith context (likephoneNumber). - It returns structured error information (
{ phoneNumber, error }) to the API layer.
- The
- API Layer Errors:
- GraphQL automatically catches errors from the service and formats them.
- The
@requireAuthdirective handles authorization errors.
- Frontend Errors:
- The
useMutationhook catches GraphQL errors in itsonErrorcallback. toastnotifications inform the user.
- The
- 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.
- Redwood's default logger (
- Retry Mechanisms:
- Simple Retries: For transient network errors, you could implement a simple retry loop within the inner
.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. - 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.
- Simple Retries: For transient network errors, you could implement a simple retry loop within the inner
What Security Features Should You Implement for Bulk SMS?
Security is paramount when dealing with user data and external APIs.
- Authentication/Authorization: We added
@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. - 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.
- GraphQL types provide basic validation (e.g.,
- API Key Security:
- Use environment variables (
.env) for Twilio credentials. - Ensure
.envis 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
.envfiles or hardcoding.
- Use environment variables (
- Rate Limiting:
- Protect your
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:- 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-flexiblewithin 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 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 } };
- Protect your
- 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
For more information, consult the Twilio Messaging Services documentation and RedwoodJS guides.