Sent logo
Sent TeamMar 8, 2026 / tools / Article

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.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.

  1. 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).

  2. Install Twilio SDK: Navigate to the api directory and install the Twilio Node.js helper library:

    bash
    cd api
    yarn add twilio
    cd ..
  3. Configure Environment Variables: RedwoodJS uses a .env file 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 .env file to version control. Redwood's default .gitignore file already includes it.

  4. 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.

  1. 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.
  2. 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.

  3. 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 already
    touch scripts/seed.js

    Edit 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:

    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.

  1. 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.

  2. Implement the sendBulkSms Function: Open api/src/services/broadcast/broadcast.js and 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 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.

  1. 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!
      }
    
      type Mutation {
        """""" 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.
  2. 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.

  3. 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.
  4. 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):

    graphql
    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:

  • 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.

  1. Navigate to Twilio Console: Log in to your Twilio Console.

  2. Create Messaging Service:

    • Go to DevelopMessagingServices.
    • Click "Create Messaging Service".
    • Enter a friendly name, e.g., "RedwoodJS Broadcast Service".
    • Select the use case – "Notify my users" is appropriate. Click "Create".
  3. 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.
  4. 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.
  5. 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.
  6. 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.
    dotenv
    # .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.

  1. Generate a Page:

    bash
    yarn rw g page Broadcast

    This creates web/src/pages/BroadcastPage/BroadcastPage.js and updates routes.

  2. Implement the Broadcast Form: Open web/src/pages/BroadcastPage/BroadcastPage.js and 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 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.
  3. 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 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
  4. 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.
      1. 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).
      2. A separate RedwoodJS worker process (often set up using custom scripts or specific RedwoodJS queue integrations) would pick up the job.
      3. The worker fetches subscribers and attempts to send messages (perhaps in smaller batches).
      4. If a message fails, the worker can retry it based on the queue's configuration (e.g., 3 retries with exponential backoff).
      5. 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 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
          }
        };
  • 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.