code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / Article

Twilio Node.js RedwoodJS Marketing Campaigns - Complete SMS Guide

Complete guide to building SMS marketing campaigns with Twilio and RedwoodJS. Includes A2P 10DLC compliance, bulk messaging, and subscriber management.

.env

This comprehensive guide walks you through integrating Twilio's Programmable Messaging API into a RedwoodJS application to send SMS marketing campaigns programmatically. You'll learn how to build a complete SMS marketing system with proper A2P 10DLC compliance, subscriber management, and bulk messaging capabilities.

By the end of this tutorial, you will have a production-ready RedwoodJS application capable of sending SMS messages via Twilio, complete with essential configurations, error handling, security best practices, and full compliance with US A2P 10DLC regulations for marketing campaigns. This serves as a solid foundation for building sophisticated SMS marketing features.

Project Overview and Goals

What We're Building:

  1. A backend API (GraphQL) endpoint to accept SMS details (recipient number(s), message text).
  2. A service function that uses the Twilio Node.js SDK to send the SMS.
  3. Basic frontend elements (using Redwood Forms) to input message details and trigger the API call.
  4. Secure handling of Twilio API credentials using environment variables.
  5. Basic error handling and logging.
  6. Compliance considerations for marketing campaigns, including A2P 10DLC registration requirements.

Problem Solved:

This project enables developers to programmatically send targeted SMS messages directly from their web application, facilitating communication for:

  • Marketing promotions and campaigns
  • Appointment reminders
  • Order confirmations
  • Security notifications (e.g., OTPs, alerts)
  • User engagement campaigns

Technologies Used:

  • RedwoodJS: A full-stack, serverless-first web application framework built on React, GraphQL, and Prisma. Chosen for its integrated structure (API and web sides), opinionated defaults, and developer experience features (generators, cells, forms).
  • Twilio Programmable Messaging API: A robust Communications Platform as a Service (CPaaS) API for sending and receiving SMS messages globally. Chosen for its reliability, scalability, comprehensive documentation, and developer-friendly SDKs.
  • Node.js: The runtime environment for the RedwoodJS API side.
  • GraphQL: Query language for the API layer, tightly integrated into RedwoodJS.
  • Prisma: Node.js & TypeScript ORM used by RedwoodJS for database interactions (though we won't implement complex DB logic in this initial guide, Prisma is part of the stack).
  • Yarn: Package manager used by RedwoodJS.

System Architecture:

The flow is straightforward:

+-----------------+ +-----------------------+ +----------------+ +--------------+ | RedwoodJS Web | ---> | RedwoodJS API (GraphQL)| ---> | Twilio Service | ---> | Twilio SMS API| | (React Frontend)| | (Node.js Backend) | | (Node.js SDK) | | (External API)| +-----------------+ +-----------------------+ +----------------+ +--------------+ | User Inputs | Handles Request | Sends SMS | Delivers SMS | Message Details | & Business Logic | via API | to Recipient
  1. The user interacts with the RedwoodJS frontend (Web side) to provide recipient number(s) and message text.
  2. The frontend triggers a GraphQL mutation on the RedwoodJS backend (API side).
  3. The GraphQL resolver calls a RedwoodJS service function.
  4. The service function initializes the Twilio Node.js SDK client using API credentials from environment variables.
  5. The service function calls the Twilio SDK's messages.create() method to dispatch the SMS message via the Twilio platform.
  6. Twilio handles the delivery of the SMS to the recipient's phone through carrier networks.
  7. The service function returns a success or error status to the frontend.

Prerequisites:

  • Node.js: Version 18.x or 20.x recommended (download Node.js).
  • Yarn: Version 1.x (Classic). RedwoodJS uses Yarn 1 by default.
  • RedwoodJS CLI: Installed globally (npm install -g @redwoodjs/cli or yarn global add @redwoodjs/cli).
  • Twilio Account:
  • Basic understanding of JavaScript, React, and command-line interfaces.

Expected Outcome:

A running RedwoodJS application where you can enter a phone number and message text into a form, click "Send", and have an SMS delivered to that number via your Twilio account.

1. Setting up the Project

Let's create the RedwoodJS project and install necessary dependencies.

Step 1: Create RedwoodJS Application

Open your terminal and run the RedwoodJS create command. We'll name our project redwood-twilio-sms.

bash
yarn create redwood-app redwood-twilio-sms

Follow the prompts. Choose TypeScript if you prefer, but this guide will use JavaScript for broader accessibility.

Step 2: Navigate to Project Directory

bash
cd redwood-twilio-sms

Step 3: Install Twilio Node.js SDK

The Twilio SDK needs to be installed in the API workspace of your Redwood project.

bash
yarn workspace api add twilio

This command specifically adds the twilio package to the api/package.json file and installs it within the api/node_modules directory.

Step 4: Configure Environment Variables

RedwoodJS uses .env files for environment variables. The .env file is gitignored by default for security.

Create a .env file in the root of your project:

bash
touch .env

Add your Twilio API credentials and your Twilio sending number to this file.

dotenv
# .env
TWILIO_ACCOUNT_SID=YOUR_TWILIO_ACCOUNT_SID
TWILIO_AUTH_TOKEN=YOUR_TWILIO_AUTH_TOKEN
TWILIO_PHONE_NUMBER=YOUR_TWILIO_PHONE_NUMBER # Use E.164 format, e.g., +14155550100
  • TWILIO_ACCOUNT_SID: Found on your Twilio Console Dashboard. Purpose: Authenticates your application with Twilio.
  • TWILIO_AUTH_TOKEN: Found on your Twilio Console Dashboard. Purpose: Authenticates your application with Twilio. Keep this secret!
  • TWILIO_PHONE_NUMBER: One of your purchased Twilio phone numbers in E.164 format (e.g., +14155550100). Purpose: The 'From' number displayed to the recipient.

Security Best Practices:

  • Never commit the .env file to version control
  • For production, use Twilio API Keys instead of your Account SID and Auth Token for better security and access control
  • Store credentials securely in your hosting provider's environment variables configuration

Explanation: Using environment variables keeps sensitive credentials out of your source code, which is crucial for security. RedwoodJS automatically loads variables from .env during development. For deployment, you'll need to configure these variables in your hosting provider's environment settings.

2. Defining the API Layer (GraphQL)

Now, let's define the backend API endpoint using GraphQL.

Step 1: Generate SDL and Service Files

Use the Redwood generator to create the Schema Definition Language (SDL) and service files for SMS functionality:

bash
yarn rw g sdl sms --no-crud

This command creates:

  • api/src/graphql/sms.sdl.ts (or .js)
  • api/src/services/sms/sms.ts (or .js)
  • api/src/services/sms/sms.test.ts (or .js)

Step 2: Define the GraphQL Schema

Edit api/src/graphql/sms.sdl.ts (or .js) to define the sendSms mutation:

graphql
# api/src/graphql/sms.sdl.ts (or .js)

export const schema = gql`
  type Mutation {
    """Sends an SMS message via Twilio."""
    sendSms(input: SendSmsInput!): SendSmsResponse! @skipAuth # Allow unauthenticated access for now
  }

  input SendSmsInput {
    """Recipient phone number in E.164 format (e.g., +14155550100)."""
    to: String!
    """The text content of the SMS message."""
    text: String!
  }

  type SendSmsResponse {
    """Indicates if the message was queued successfully by Twilio."""
    success: Boolean!
    """Optional message SID provided by Twilio on success."""
    messageSid: String
    """Optional error message if sending failed."""
    error: String
  }
`

Explanation:

  • We define a Mutation type with one field: sendSms.
  • sendSms takes a required input argument of type SendSmsInput.
  • SendSmsInput defines the required fields: to (recipient) and text (message body). Using an input type keeps the mutation arguments organized.
  • sendSms returns a SendSmsResponse type.
  • SendSmsResponse indicates success (boolean) and provides optional messageSid (Twilio's unique message identifier) or error details.
  • @skipAuth: For simplicity in this guide, we disable authentication for this mutation. In a production application, you must implement authentication (@requireAuth) to ensure only authorized users can send SMS.

3. Implementing the Service Logic

Now, implement the logic within the service file generated earlier. This function will initialize the Twilio client and call its API.

Edit api/src/services/sms/sms.ts (or .js):

typescript
// api/src/services/sms/sms.ts (or .js)
import twilio from 'twilio'
import { logger } from 'src/lib/logger' // Redwood's built-in logger

export const sendSms = async ({ input }: { input: { to: string; text: string } }) => {
  const { to, text } = input

  // Validate input (basic example)
  if (!to || !text) {
    throw new Error('Recipient number (to) and message text are required.')
  }

  const accountSid = process.env.TWILIO_ACCOUNT_SID
  const authToken = process.env.TWILIO_AUTH_TOKEN
  const phoneNumber = process.env.TWILIO_PHONE_NUMBER

  if (!accountSid || !authToken || !phoneNumber) {
    logger.error('Twilio credentials or phone number missing in environment variables.')
    throw new Error('Server configuration error: Twilio credentials not set.')
  }

  try {
    // Initialize Twilio Client
    const client = twilio(accountSid, authToken)

    logger.info(`Attempting to send SMS to ${to} from ${phoneNumber}`)

    // Send the SMS using Twilio SDK
    const message = await client.messages.create({
      body: text,
      from: phoneNumber, // Your Twilio phone number
      to: to,
    })

    // Check Twilio response status
    if (message.sid) {
      logger.info(`SMS sent successfully to ${to}, Message SID: ${message.sid}, Status: ${message.status}`)
      return {
        success: true,
        messageSid: message.sid,
        error: null,
      }
    } else {
      // Unlikely scenario, but handle unexpected response
      logger.error(`Twilio returned unexpected response for ${to}`)
      return {
        success: false,
        messageSid: null,
        error: 'Unexpected response from Twilio',
      }
    }
  } catch (error) {
    logger.error({ error }, `Failed to send SMS via Twilio to ${to}`)

    // Twilio errors contain useful information
    const errorMessage = error.message || 'An unexpected error occurred while sending SMS.'
    const errorCode = error.code || 'UNKNOWN'

    return {
      success: false,
      messageSid: null,
      error: `Twilio error (${errorCode}): ${errorMessage}`,
    }
  }
}

Explanation:

  1. Import: We import the twilio SDK and Redwood's logger.
  2. Function Signature: The sendSms function receives the input object matching our SendSmsInput GraphQL type.
  3. Input Validation: Basic check for missing to or text. Production apps should have more robust validation (e.g., using zod or checking phone number format with a library like libphonenumber-js).
  4. Credentials Check: Retrieve Twilio credentials and phone number from process.env. Crucially, it checks if they exist and throws an error if not.
  5. Twilio Client Initialization: Create a Twilio client instance using twilio(accountSid, authToken).
  6. Logging: Use logger.info and logger.error to record actions and potential issues.
  7. client.messages.create(): Call the Twilio SDK method to send SMS:
    • body: Message content
    • from: Your Twilio phone number
    • to: Recipient number
  8. Response Handling:
    • Twilio returns a message object with a sid (unique message identifier) and status property.
    • If message.sid exists, we consider it successful and return { success: true, messageSid }.
    • The status field indicates the message state (e.g., queued, sent, delivered, failed). Initially, it's typically queued.
  9. Error Handling (try...catch): Catches errors thrown by the SDK (e.g., invalid credentials, network problems, invalid phone numbers). Twilio errors include a code and descriptive message that should be logged and returned.
  10. Return Value: Returns an object matching the SendSmsResponse GraphQL type.

Important Notes:

4. Building the Frontend

Now, let's create a simple page on the web side to interact with our API.

Step 1: Generate the Page

Use the Redwood generator to create a page:

bash
yarn rw g page SmsSender

This creates web/src/pages/SmsSenderPage/SmsSenderPage.js (or .tsx) and adds a route in web/src/Routes.js (or .tsx).

Step 2: Implement the Page Component

Edit web/src/pages/SmsSenderPage/SmsSenderPage.js (or .tsx):

jsx
// web/src/pages/SmsSenderPage/SmsSenderPage.js (or .tsx)
import { useState } from 'react'
import { MetaTags, useMutation } from '@redwoodjs/web'
import { Form, TextField, TextAreaField, Submit, FieldError, Label, useForm } from '@redwoodjs/forms'
import { toast, Toaster } from '@redwoodjs/web/toast'

// Define the GraphQL Mutation
const SEND_SMS_MUTATION = gql`
  mutation SendSmsMutation($input: SendSmsInput!) {
    sendSms(input: $input) {
      success
      messageSid
      error
    }
  }
`

const SmsSenderPage = () => {
  const formMethods = useForm()
  const [loading, setLoading] = useState(false)

  // useMutation hook to interact with the GraphQL API
  const [sendSms, { error: mutationError }] = useMutation(SEND_SMS_MUTATION, {
    onCompleted: (data) => {
      setLoading(false)
      if (data.sendSms.success) {
        toast.success(`SMS sent successfully! Message SID: ${data.sendSms.messageSid}`)
        formMethods.reset()
      } else {
        toast.error(`Failed to send SMS: ${data.sendSms.error || 'Unknown error'}`)
      }
    },
    onError: (error) => {
      setLoading(false)
      toast.error(`Mutation Error: ${error.message}`)
    }
  })

  // Form submission handler
  const onSubmit = async (data) => {
    setLoading(true)
    try {
      await sendSms({ variables: { input: data } })
    } catch (e) {
      setLoading(false)
      toast.error(`Submission Error: ${e.message}`)
    }
  }

  return (
    <>
      <MetaTags title="SMS Sender" description="Send SMS messages via Twilio" />
      <Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />

      <h1>Send SMS via Twilio</h1>

      <Form onSubmit={onSubmit} formMethods={formMethods} className="rw-form-wrapper">
        <Label name="to" className="rw-label" errorClassName="rw-label rw-label-error">
          Recipient Number (E.164 format, e.g., +14155550100)
        </Label>
        <TextField
          name="to"
          className="rw-input"
          errorClassName="rw-input rw-input-error"
          validation={{
            required: { value: true, message: 'Recipient number is required' },
            pattern: {
              value: /^\+[1-9]\d{1,14}$/, // E.164 format validation
              message: 'Please enter a valid E.164 phone number (e.g., +14155550100)'
            }
           }}
        />
        <FieldError name="to" className="rw-field-error" />

        <Label name="text" className="rw-label" errorClassName="rw-label rw-label-error">
          Message Text
        </Label>
        <TextAreaField
          name="text"
          className="rw-input"
          errorClassName="rw-input rw-input-error"
          validation={{
             required: { value: true, message: 'Message text is required' },
             maxLength: { value: 1600, message: 'Message is too long (max 1600 characters)' }
          }}
        />
        <FieldError name="text" className="rw-field-error" />

        {mutationError && (
           <div className="rw-form-error">
             <p>API Error: {mutationError.message}</p>
           </div>
         )}

        <div className="rw-button-group">
          <Submit disabled={loading} className="rw-button rw-button-blue">
            {loading ? 'Sending...' : 'Send SMS'}
          </Submit>
        </div>
      </Form>
    </>
  )
}

export default SmsSenderPage

Explanation:

  1. Imports: Import necessary components from React, RedwoodJS (MetaTags, useMutation, @redwoodjs/forms, @redwoodjs/web/toast).
  2. SEND_SMS_MUTATION: Define the GraphQL mutation string that matches the one defined in the SDL.
  3. useForm: Initialize Redwood Forms for handling form state and validation.
  4. useState: Track a loading state for user feedback during submission.
  5. useMutation Hook:
    • Takes the GraphQL mutation string and options with callbacks.
    • onCompleted: Called when the mutation successfully executes. Checks data.sendSms.success to show appropriate success/error toasts.
    • onError: Called if there's a GraphQL or network-level error.
  6. onSubmit Handler: Called when form is submitted, triggers the mutation.
  7. <Toaster>: Required by toast to display notifications.
  8. <Form> Component: Redwood's form wrapper with validation.
  9. Form Fields: Uses Redwood's form components with validation rules including E.164 format pattern check.
  10. <Submit> Button: Disabled during loading to prevent duplicate submissions.

Note: SMS messages are typically segmented at 160 characters (GSM-7) or 70 characters (UCS-2 for Unicode). The 1600 character limit allows for approximately 10 segments. For production, consider warning users about message segmentation and costs.

5. Understanding A2P 10DLC Compliance for SMS Marketing Campaigns

When sending marketing SMS messages to US recipients using 10-digit long codes (10DLC), you must comply with A2P 10DLC regulations. This section covers critical compliance requirements for SMS marketing.

What is A2P 10DLC?

A2P 10DLC (Application-to-Person 10-Digit Long Code) is a system in the United States that requires businesses to register their brand and campaign use cases before sending SMS messages via 10-digit phone numbers. This system improves deliverability, reduces spam, and ensures compliance with carrier requirements.

Registration Requirements:

  1. Brand Registration: Register your business information with The Campaign Registry (TCR)
  2. Campaign Registration: Register your specific use case (e.g., "Marketing", "Account Notifications")
  3. Phone Number Association: Link your Twilio 10DLC numbers to your registered campaign

How to Register for A2P 10DLC via Twilio Console

Follow these steps to register for A2P 10DLC:

Step 1: Register Your Brand

Navigate to Twilio Console → Messaging → Regulatory Compliance and complete brand registration with:

  • Legal business name
  • Business registration number (EIN for US)
  • Business type and industry
  • Contact information
  • Business address

Step 2: Register Your Campaign

After brand approval, register your campaign with:

  • Campaign use case (e.g., "Marketing")
  • Sample messages
  • Opt-in/opt-out process description
  • Help message content
  • Expected message volume

Step 3: Associate Phone Numbers

Link your Twilio 10DLC phone numbers to your approved campaign through a Messaging Service.

Important Notes:

  • Registration Time: Brand review typically takes 1-5 business days; campaign review takes 1-2 weeks
  • Costs: Brand registration costs $4/month; campaign registration is a one-time $15 fee (as of 2025)
  • Throughput: Approved campaigns receive higher throughput (up to 4,800 messages/minute vs. 60-75 for unregistered)
  • Deliverability: Unregistered numbers may experience filtered messages or higher failure rates
  • Required for Marketing: Marketing campaigns to US numbers require A2P 10DLC registration

For detailed guidance, see Twilio's A2P 10DLC Registration Guide.

Legal Requirements for Marketing SMS:

  1. Express Written Consent: Obtain explicit consent before sending marketing messages (TCPA compliance)
  2. Clear Opt-In: Users must actively agree (checkboxes, SMS keywords like "JOIN")
  3. Opt-Out Mechanism: Provide clear opt-out instructions (e.g., "Reply STOP to unsubscribe")
  4. Documentation: Maintain records of consent for compliance audits

Implementation Example:

javascript
// Example opt-in tracking in Prisma schema
model SmsSubscriber {
  id          String   @id @default(cuid())
  phoneNumber String   @unique
  optedIn     Boolean  @default(true)
  optInDate   DateTime @default(now())
  optInMethod String   // e.g., "web_form", "sms_keyword"
  optOutDate  DateTime?
}

Required Message Content:

Every marketing message should include:

  • Your business name or brand
  • How to opt-out (e.g., "Reply STOP to unsubscribe")
  • Help information (e.g., "Reply HELP for help")

Example: "Hi from Acme Corp! Get 20% off today only. Reply STOP to opt-out or HELP for help."

SMS Marketing Rate Limits and Throughput

Twilio Rate Limits:

  • Unregistered Numbers: ~60-75 messages per minute (throttled by carriers)
  • Registered 10DLC: Up to 4,800 messages per minute depending on campaign score
  • Toll-Free (Verified): ~200 messages per minute

Best Practices:

Alternative: Toll-Free Numbers for SMS Marketing

For marketing campaigns, consider Toll-Free verification as an alternative to 10DLC:

Pros:

  • Simpler registration process
  • No monthly fees (just per-message costs)
  • Good for moderate volume (up to 200 msg/min)

Cons:

  • Lower throughput than registered 10DLC
  • Still requires verification for messaging
  • May have lower deliverability for marketing

6. Implementing Proper Error Handling, Logging, and Retry Mechanisms

Error Handling (Covered Above):

  • Frontend: Redwood Forms validation catches input errors. useMutation's onError catches GraphQL/network errors. onCompleted checks the success flag from the service response for Twilio-specific errors. Toasts provide user feedback.
  • Backend (Service):
    • Input validation prevents processing invalid data.
    • try...catch blocks handle SDK exceptions (auth, network).
    • Checking the Twilio API response (presence of message.sid) handles errors reported by Twilio.
    • Clear separation of success/error states in the return object ({ success, messageSid, error }).

Common Twilio Error Codes:

  • 21211: Invalid 'To' phone number
  • 21408: Permission to send to unverified number (trial account)
  • 21610: Unsubscribed recipient (has opted out)
  • 30007: Message blocked as spam
  • 30008: Unknown destination carrier

For a complete list, see Twilio Error Codes.

Logging (Covered Above):

  • Redwood's built-in Pino logger (logger from src/lib/logger) is used in the service.
  • logger.info logs successful attempts and outcomes.
  • logger.error logs configuration issues, SDK errors, and Twilio API errors, including error objects for detailed stack traces.
  • Logs automatically output to console during development (yarn rw dev). In production, configure your hosting provider or logging service (e.g., Logflare, Datadog) to capture these logs.

Retry Mechanisms:

Implementing retries adds complexity. For SMS, retries should be handled carefully to avoid duplicate messages.

  • Client-Side Retries: Generally not recommended for actions like sending SMS, as network issues could lead to multiple successful submissions if the user retries manually after the first request actually succeeded but the response was lost. The loading state and disabled button help prevent this.

  • Server-Side Retries: If the client.messages.create() call fails due to transient issues (e.g., temporary network error, Twilio rate limit), you could implement a retry strategy within the service function.

    • Identify Retryable Errors: Only retry on specific error codes or types (e.g., network timeouts, 500 series HTTP errors from Twilio, rate limit errors 429). Do not retry on errors like invalid credentials, invalid phone numbers, or exceeded account limits.
    • Use a Library: Libraries like async-retry or p-retry can simplify implementing exponential backoff.
    • Idempotency: Twilio doesn't provide built-in idempotency keys for the standard Messages API. Be cautious with retries to avoid sending duplicate messages. Consider implementing your own deduplication logic using message hashes stored temporarily.
    • Example Sketch (Conceptual - requires async-retry):
    typescript
    // api/src/services/sms/sms.ts (Conceptual Retry Snippet)
    import retry from 'async-retry';
    import twilio from 'twilio';
    import { logger } from 'src/lib/logger';
    
    export const sendSms = async ({ input }: { input: { to: string; text: string } }) => {
      const { to, text } = input;
      // ... input validation and credential checks ...
    
      const accountSid = process.env.TWILIO_ACCOUNT_SID;
      const authToken = process.env.TWILIO_AUTH_TOKEN;
      const phoneNumber = process.env.TWILIO_PHONE_NUMBER;
    
      const client = twilio(accountSid, authToken);
    
      try {
        const message = await retry(
          async (bail, attempt) => {
            logger.info(`Attempt ${attempt} to send SMS to ${to}`);
    
            try {
              const result = await client.messages.create({
                body: text,
                from: phoneNumber,
                to: to,
              });
    
              logger.info(`SMS sent successfully to ${to}, Message SID: ${result.sid}`);
              return result;
    
            } catch (error) {
              // Check if error is retryable
              const errorCode = error.code;
    
              // Retry on rate limits or temporary issues
              if (errorCode === 20429 || errorCode === 'ECONNRESET') {
                logger.warn(`Attempt ${attempt} failed with retryable error: ${error.message}`);
                throw error; // Trigger retry
              } else {
                // Non-retryable error (invalid number, auth failure, etc.)
                logger.error(`Non-retryable error: ${error.message}`);
                bail(error); // Stop retrying
                return null;
              }
            }
          },
          {
            retries: 3,
            factor: 2,
            minTimeout: 1000,
            onRetry: (error, attempt) => {
              logger.warn(`Retrying SMS send to ${to} (attempt ${attempt}): ${error.message}`);
            },
          }
        );
    
        if (message && message.sid) {
          return { success: true, messageSid: message.sid, error: null };
        } else {
          return { success: false, messageSid: null, error: 'Failed after retries' };
        }
    
      } catch (error) {
        logger.error({ error }, `Failed to send SMS to ${to} after all retries`);
        return {
          success: false,
          messageSid: null,
          error: `${error.code || 'ERROR'}: ${error.message}`
        };
      }
    };

    Recommendation: Start without automatic retries. Add them only if you observe frequent transient failures in production and have carefully considered the idempotency implications. Logging and monitoring are key to identifying the need for retries.

7. Creating a Database Schema and Data Layer (Optional Extension)

While not strictly required for sending a single SMS, storing campaign details or message logs is often necessary for marketing applications. RedwoodJS uses Prisma for database interactions.

Step 1: Define Schema

Edit api/db/schema.prisma:

prisma
// api/db/schema.prisma

datasource db {
  provider = "postgresql" // Or "sqlite", "mysql"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
}

model SmsLog {
  id              String    @id @default(cuid())
  createdAt       DateTime  @default(now())
  recipient       String
  sender          String
  messageText     String
  status          String    // e.g., "QUEUED", "SENT", "DELIVERED", "FAILED"
  twilioMessageSid String?  @unique
  twilioStatus    String?   // Status from Twilio (queued, sent, delivered, failed, etc.)
  errorMessage    String?
  errorCode       String?
  // Add campaign relations if needed
  // campaignId   String?
  // campaign     Campaign? @relation(fields: [campaignId], references: [id])
}

// Example: Campaign model for managing marketing campaigns
// model Campaign {
//   id          String    @id @default(cuid())
//   name        String
//   description String?
//   createdAt   DateTime  @default(now())
//   messages    SmsLog[]
//   status      String    @default("DRAFT") // DRAFT, ACTIVE, COMPLETED
// }

// Example: Opt-in/Subscriber management
// model SmsSubscriber {
//   id          String   @id @default(cuid())
//   phoneNumber String   @unique
//   optedIn     Boolean  @default(true)
//   optInDate   DateTime @default(now())
//   optInMethod String
//   optOutDate  DateTime?
// }

Step 2: Create Migration

Generate the SQL migration file and apply it to your database:

bash
# Make sure your DATABASE_URL is set in .env (e.g., for SQLite: DATABASE_URL="file:./dev.db")
yarn rw prisma migrate dev --name add_sms_log

This creates a migration file in api/db/migrations and updates your database schema.

Step 3: Update Service to Log

Modify api/src/services/sms/sms.ts to use the Prisma client (db) to save a log entry:

typescript
// api/src/services/sms/sms.ts (Additions for DB logging)
import twilio from 'twilio'
import { logger } from 'src/lib/logger'
import { db } from 'src/lib/db' // Import Redwood's Prisma client instance

export const sendSms = async ({ input }: { input: { to: string; text: string } }) => {
  const { to, text } = input

  // ... Input validation and credential checks ...
  const accountSid = process.env.TWILIO_ACCOUNT_SID
  const authToken = process.env.TWILIO_AUTH_TOKEN
  const phoneNumber = process.env.TWILIO_PHONE_NUMBER

  if (!accountSid || !authToken || !phoneNumber) {
    logger.error('Twilio credentials missing')
    throw new Error('Server configuration error')
  }

  let response: { success: boolean; messageSid?: string; error?: string } | null = null

  try {
    const client = twilio(accountSid, authToken)
    logger.info(`Attempting to send SMS to ${to} from ${phoneNumber}`)

    const message = await client.messages.create({
      body: text,
      from: phoneNumber,
      to: to,
    })

    if (message.sid) {
      logger.info(`SMS sent successfully to ${to}, SID: ${message.sid}, Status: ${message.status}`)
      response = { success: true, messageSid: message.sid, error: null }

      // Log to DB on success
      await db.smsLog.create({
        data: {
          recipient: to,
          sender: phoneNumber,
          messageText: text,
          status: 'SENT',
          twilioMessageSid: message.sid,
          twilioStatus: message.status,
        },
      })
    } else {
      const errorMessage = 'Unexpected response from Twilio'
      logger.error(errorMessage)
      response = { success: false, messageSid: null, error: errorMessage }

      // Log to DB on unexpected response
      await db.smsLog.create({
        data: {
          recipient: to,
          sender: phoneNumber,
          messageText: text,
          status: 'FAILED',
          errorMessage: errorMessage,
        },
      })
    }
  } catch (error) {
    const errorMessage = error.message || 'An unexpected error occurred'
    const errorCode = error.code || 'UNKNOWN'

    logger.error({ error }, `Failed to send SMS to ${to}`)
    response = { success: false, messageSid: null, error: `${errorCode}: ${errorMessage}` }

    // Log to DB on failure
    await db.smsLog.create({
      data: {
        recipient: to,
        sender: phoneNumber,
        messageText: text,
        status: 'FAILED',
        errorMessage: errorMessage,
        errorCode: errorCode,
      },
    })
  }

  if (!response) {
    logger.error('SMS service reached end without setting response')
    return { success: false, error: 'Internal server error' }
  }

  return response
}

Benefits of Database Logging:

  • Track message delivery status over time
  • Audit trail for compliance
  • Analytics on campaign performance
  • Troubleshooting delivery issues
  • Cost tracking and budgeting

Next Steps for Production:

  1. Implement Status Callbacks: Configure Twilio webhooks to receive delivery status updates and update your SmsLog records
  2. Add Bulk Send Functionality: Implement queue systems (Bull, BullMQ) for sending messages to multiple recipients
  3. Subscriber Management: Build opt-in/opt-out workflows
  4. Campaign Management: Create admin interfaces for managing marketing campaigns
  5. Analytics Dashboard: Visualize delivery rates, engagement metrics

8. Testing Your Application

Step 1: Start Development Server

bash
yarn rw dev

This starts both the API server and web development server.

Step 2: Navigate to the SMS Sender Page

Open your browser and go to http://localhost:8910/sms-sender

Step 3: Send a Test Message

  1. Enter a verified phone number (for trial accounts) in E.164 format (e.g., +14155550100)
  2. Enter your message text
  3. Click "Send SMS"
  4. Check your phone for the message
  5. Check the console for log output

Step 4: Verify in Twilio Console

Navigate to Twilio Console → Messaging → Logs to see message details, status, and any errors.

9. Deployment Considerations

When deploying your RedwoodJS application to production:

Environment Variables:

Configure these in your hosting provider (Vercel, Netlify, AWS, etc.):

  • TWILIO_ACCOUNT_SID
  • TWILIO_AUTH_TOKEN
  • TWILIO_PHONE_NUMBER
  • DATABASE_URL (if using database logging)

Security Best Practices:

  1. Use API Keys: Instead of Account SID/Auth Token, use Twilio API Keys for better security
  2. Implement Authentication: Remove @skipAuth and add proper user authentication
  3. Rate Limiting: Implement rate limiting to prevent abuse
  4. Input Sanitization: Validate and sanitize all user inputs
  5. HTTPS Only: Ensure all traffic uses HTTPS

Scaling Considerations:

  • Use Messaging Services for automatic load balancing
  • Implement message queues (Bull, AWS SQS) for bulk sends
  • Monitor Twilio usage and set up billing alerts
  • Cache frequently accessed data (subscriber lists, templates)

10. Next Steps and Advanced Features

To build a production-ready marketing campaign system, consider implementing:

  1. Status Callbacks/Webhooks: Track delivery status, handle bounces, process opt-outs
  2. Scheduled Sending: Use job queues (Bull) or cron jobs for timed campaigns
  3. Message Templates: Store reusable message templates with variable substitution
  4. Segmentation: Group subscribers by attributes for targeted campaigns
  5. A/B Testing: Test different message variants for optimization
  6. Analytics Dashboard: Track open rates, click-through rates (with link shortening)
  7. Multi-Channel: Add email, push notifications alongside SMS
  8. Compliance Automation: Automatic opt-out handling, consent tracking
  9. Cost Management: Budget caps, cost alerts, usage reports

Additional Resources:

Frequently Asked Questions (FAQ)

How do I send bulk SMS messages with Twilio?

To send bulk SMS messages with Twilio and RedwoodJS, implement a message queue system using Bull or BullMQ. This allows you to process large recipient lists without hitting rate limits. Store subscriber lists in your database, then create a background job that iterates through recipients and calls the Twilio API for each message.

Yes, SMS marketing is legal in the USA when you comply with TCPA regulations. You must obtain express written consent before sending marketing messages, provide clear opt-out mechanisms (like "Reply STOP"), and register for A2P 10DLC if using 10-digit long codes.

How much does Twilio SMS cost?

Twilio SMS pricing varies by country. In the US, outbound SMS typically costs $0.0079 per message segment. Additionally, A2P 10DLC registration costs $4/month for brand registration and a one-time $15 campaign registration fee. Check current pricing at the Twilio pricing page.

What is the SMS open rate for marketing campaigns?

SMS marketing typically achieves 98% open rates, with most messages read within 3 minutes of delivery. This makes SMS one of the most effective marketing channels for time-sensitive promotions and urgent notifications.

Conclusion

You now have a functional RedwoodJS application integrated with Twilio's Programmable Messaging API for sending SMS marketing campaigns. This guide covered:

  • Complete setup of RedwoodJS with Twilio SDK
  • GraphQL API implementation for SMS sending
  • Frontend form with validation
  • Comprehensive error handling and logging
  • A2P 10DLC compliance requirements for marketing campaigns
  • Optional database logging for message tracking
  • Production deployment considerations

This foundation can be extended to build sophisticated marketing campaign systems with advanced features like subscriber management, campaign scheduling, analytics, and multi-channel messaging.

Remember to always comply with TCPA regulations, obtain proper consent, and respect opt-out requests when sending marketing messages. Happy coding!