code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

RedwoodJS Bulk SMS Messaging with Vonage API: Complete Tutorial for Broadcasting Messages

Build a scalable bulk SMS broadcasting system with RedwoodJS and Vonage Messages API. Learn to send messages to multiple recipients, handle rate limits, and implement secure GraphQL endpoints.

RedwoodJS Bulk SMS Messaging with Vonage: Build Scalable Broadcasting System

Build a scalable bulk SMS broadcasting system with RedwoodJS and the Vonage Messages API. Send messages to multiple recipients simultaneously for alerts, notifications, and marketing campaigns.

This tutorial covers GraphQL API endpoints, Vonage SDK integration, concurrent message sending, error handling, rate limit management, Prisma database logging, security best practices, and production deployment.

Prerequisites:

  • Node.js 18+ installed
  • Active Vonage account with API credentials
  • Basic RedwoodJS knowledge (services, GraphQL, Prisma)
  • Command-line experience
  • Understanding of async/await patterns

1. RedwoodJS Project Setup and Vonage SDK Installation

Create a new RedwoodJS project and install the Vonage SDK. Setup takes approximately 10 minutes.

1.1 Create RedwoodJS Project:

bash
# Replace 'redwood-bulk-sms' with your desired project name
yarn create redwood-app redwood-bulk-sms --typescript
cd redwood-bulk-sms

This scaffolds a new RedwoodJS project with TypeScript enabled.

1.2 Install Vonage SDK:

Install the Vonage Node.js SDK in the API workspace:

bash
yarn workspace api add @vonage/server-sdk

Verify installation:

bash
yarn workspace api list --pattern @vonage/server-sdk

You should see @vonage/server-sdk in the output.

1.3 Configure Environment Variables:

Store your Vonage credentials securely in environment variables.

Create a .env file in your project root and add these variables:

dotenv
# .env
# From Vonage Dashboard -> API Settings
VONAGE_API_KEY=YOUR_API_KEY_HERE
VONAGE_API_SECRET=YOUR_API_SECRET_HERE

# From Vonage Dashboard -> Applications
VONAGE_APPLICATION_ID=YOUR_APPLICATION_ID_HERE

# Path to downloaded private key file
VONAGE_PRIVATE_KEY_PATH=./private.key

# Your Vonage number in E.164 format (e.g., +14155550100)
VONAGE_FROM_NUMBER=YOUR_VONAGE_NUMBER_HERE

Obtain credentials from Vonage Dashboard:

  1. API Key & Secret: Dashboard home page under "API Settings"
  2. Application ID: Navigate to Applications > Create a new application
    • Name it (e.g., "Redwood Bulk Sender")
    • Enable "Messages" capability
    • Click "Generate public and private key" and download private.key
    • Copy the Application ID shown
    • Leave webhook URLs blank (not needed for sending only)
  3. Private Key Path: Save private.key to your project root
  4. From Number: Navigate to Numbers > Your numbers and copy an SMS-capable number in E.164 format

Add to your .gitignore file:

gitignore
# Secrets
.env
private.key
*.key

1.4 Setup Prisma Database:

Configure a basic SQLite database (switch to PostgreSQL for production).

Ensure your api/db/schema.prisma file has a provider configured:

prisma
// api/db/schema.prisma

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

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

// Add models below

Add the database URL to your .env file:

dotenv
# .env
DATABASE_URL="file:./dev.db"  # SQLite for development
# For PostgreSQL: DATABASE_URL="postgresql://user:password@host:port/database?schema=public"

2. Implementing Core Functionality (RedwoodJS Service)

Create a RedwoodJS service to encapsulate Vonage interaction logic. Services contain business logic, GraphQL exposes endpoints, and Prisma handles database operations.

2.1 Generate SMS Service:

bash
yarn rw g service sms

This creates api/src/services/sms/sms.ts and related test/scenario files.

2.2 Implement Sending Logic:

Open api/src/services/sms/sms.ts and implement the Vonage integration:

typescript
// api/src/services/sms/sms.ts
import { Vonage } from '@vonage/server-sdk'
import { Messages } from '@vonage/messages' // Import the Messages class type if needed
import type { SendBulkSmsInput } from 'types/graphql' // We'll create this type later

import { logger } from 'src/lib/logger'
import { db } from 'src/lib/db' // Import the db client

// --- Vonage Client Initialization ---
// Retrieve credentials securely from environment variables
const vonageApiKey = process.env.VONAGE_API_KEY
const vonageApiSecret = process.env.VONAGE_API_SECRET
const vonageAppId = process.env.VONAGE_APPLICATION_ID
const vonagePrivateKeyPath = process.env.VONAGE_PRIVATE_KEY_PATH
const vonageFromNumber = process.env.VONAGE_FROM_NUMBER

// Basic validation to ensure environment variables are set
if (
  !vonageApiKey ||
  !vonageApiSecret ||
  !vonageAppId ||
  !vonagePrivateKeyPath ||
  !vonageFromNumber
) {
  throw new Error(
    'Missing Vonage credentials in environment variables. Check .env file.'
  )
}

// Initialize Vonage client.
// While Messages API primarily uses Application ID + Private Key for authentication,
// the SDK might use API Key/Secret for other functionalities or fallbacks.
const vonage = new Vonage({
  apiKey: vonageApiKey,
  apiSecret: vonageApiSecret,
  applicationId: vonageAppId,
  privateKey: vonagePrivateKeyPath,
})

// Create a specific client instance for the Messages API
const vonageMessages = new Messages(vonage.options) // Use the same options

// --- Helper Function for Single SMS ---
// Encapsulates sending a single message and basic error handling
async function sendSingleSms(
  to: string,
  text: string
): Promise<{ success: boolean; messageId?: string; error?: string }> {
  const commonLogData = { recipient: to, messageText: text }
  try {
    // Input validation (basic example)
    if (!to || !text) {
      return { success: false, error: 'Recipient number and text are required.' }
    }

    // Validate E.164 format (basic check)
    const e164Regex = /^\+[1-9]\d{1,14}$/
    if (!e164Regex.test(to)) {
      logger.error(`Invalid phone number format: ${to}`)
      return { success: false, error: `Invalid phone number format: ${to}. Use E.164 (e.g., +14155550100)` }
    }

    // Validate message length (160 chars for GSM-7, 70 for Unicode)
    if (text.length > 1600) {
      return { success: false, error: 'Message exceeds maximum length of 1600 characters.' }
    }

    logger.debug(`Attempting to send SMS to: ${to}`)

    const resp = await vonageMessages.send({
      message_type: 'text',
      channel: 'sms',
      to: to,
      from: vonageFromNumber, // Use the configured Vonage number
      text: text,
    })

    logger.info(`SMS sent to ${to}, message_uuid: ${resp.message_uuid}`)
    await logSmsAttempt({
      ...commonLogData,
      status: 'SUCCESS',
      vonageMessageId: resp.message_uuid,
    })
    return { success: true, messageId: resp.message_uuid }
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : 'Unknown Vonage API error'
    logger.error(`Failed to send SMS to ${to}: ${errorMessage}`, error)
    // More specific error handling based on Vonage error codes could be added here
    await logSmsAttempt({
      ...commonLogData,
      status: 'FAILED',
      errorMessage: errorMessage,
    })
    return { success: false, error: errorMessage }
  }
}

// --- Database Logging Helper ---
async function logSmsAttempt(data: {
  recipient: string
  messageText: string
  status: 'SUCCESS' | 'FAILED'
  vonageMessageId?: string
  errorMessage?: string
}) {
  try {
    await db.smsLog.create({ data })
  } catch (dbError) {
    const errorMessage = dbError instanceof Error ? dbError.message : 'Unknown database error'
    logger.error(
      `Failed to log SMS attempt for ${data.recipient} to database: ${errorMessage}`,
      dbError
    )
    // DB logging failure is non-critical for message delivery
    // Monitor these errors via centralized logging
  }
}

// --- Bulk SMS Sending Logic ---
export const sendBulkSms = async ({ input }: { input: SendBulkSmsInput }) => {
  const { recipients, message } = input
  const results: Array<{
    to: string
    success: boolean
    messageId?: string
    error?: string
  }> = []

  logger.info(
    `Starting bulk SMS job for ${recipients.length} recipients.`
  )

  // IMPORTANT: Rate Limiting
  // Vonage rate limits vary by number type (1 SMS/sec for long codes, higher for toll-free)
  //
  // For production, use one of these approaches:
  // 1. Throttling: Use p-throttle library to limit concurrent requests
  // 2. Queue System: BullMQ, AWS SQS, or similar (recommended for reliability)
  //
  // This implementation uses Promise.allSettled without throttling
  // Add rate limiting before production deployment

  const sendPromises = recipients.map((recipient) =>
    sendSingleSms(recipient, message) // logSmsAttempt is called inside sendSingleSms
      .then((result) => ({ to: recipient, ...result })) // Add recipient number to result
      .catch((err) => { // Catch unexpected errors from sendSingleSms itself (e.g., validation error)
        const errorMessage = err instanceof Error ? err.message : 'Unknown error'
        return {
          to: recipient,
          success: false,
          error: `Unexpected error during sendSingleSms execution: ${errorMessage}`,
        }
      })
  )

  const settledResults = await Promise.allSettled(sendPromises)

  settledResults.forEach((result, index) => {
    const recipient = recipients[index] // Maintain order
    if (result.status === 'fulfilled') {
      // The result.value contains { to, success, messageId?, error? } from sendSingleSms
      results.push(result.value)
      // Logging is handled within sendSingleSms now
    } else {
      // This catches errors *before* or *outside* sendSingleSms's try/catch,
      // or if the promise wrapping sendSingleSms itself was rejected unexpectedly.
      const reasonMessage = result.reason instanceof Error ? result.reason.message : String(result.reason)
      const errorMsg = `Promise rejected for recipient ${recipient}: ${reasonMessage}`
      logger.error(errorMsg)
      results.push({
        to: recipient,
        success: false,
        error: errorMsg,
      })
      // Log this critical/unexpected failure if needed, although sendSingleSms handles most API/logic errors.
      logSmsAttempt({ recipient, messageText: message, status: 'FAILED', errorMessage: errorMsg });
    }
  })

  const successfulSends = results.filter((r) => r.success).length
  const failedSends = results.length - successfulSends

  logger.info(
    `Bulk SMS job completed. Success: ${successfulSends}, Failed: ${failedSends}`
  )

  // Return a summary and detailed results
  return {
    totalRecipients: recipients.length,
    successfulSends,
    failedSends,
    results, // Array of individual results
  }
}

// Add other SMS-related service functions if needed (e.g., getMessageStatus)

Key Implementation Details:

ComponentPurposeNotes
CredentialsLoaded from process.envValidated at startup
Vonage ClientInitialized with required credentialsDedicated Messages instance created
sendSingleSmsSends one message with error handlingValidates E.164 format and message length
logSmsAttemptRecords attempts to database via PrismaNon-blocking – failure doesn't stop sending
sendBulkSmsProcesses multiple recipients concurrentlyUses Promise.allSettled for reliability

3. Building the GraphQL API Layer

Expose the sendBulkSms service function through Redwood's GraphQL API.

3.1 Generate SDL:

bash
yarn rw g sdl sms --no-crud

3.2 Define GraphQL Schema:

Open api/src/graphql/sms.sdl.ts and define the input type and mutation:

graphql
# api/src/graphql/sms.sdl.ts
export const schema = gql`
  # Input type for the bulk SMS mutation
  input SendBulkSmsInput {
    recipients: [String!]! # Array of phone numbers, expected in E.164 format
    message: String!       # The text message content
  }

  # Represents the result of a single SMS send attempt within the bulk job
  type SmsSendResult {
    to: String!
    success: Boolean!
    messageId: String # Vonage message UUID if successful
    error: String     # Error message if failed
  }

  # Represents the overall summary of the bulk SMS job
  type BulkSmsSummary {
    totalRecipients: Int!
    successfulSends: Int!
    failedSends: Int!
    results: [SmsSendResult!]! # Detailed results for each recipient
  }

  type Mutation {
    # Mutation to send bulk SMS messages
    # Protected by @requireAuth to ensure only logged-in users can trigger it
    sendBulkSms(input: SendBulkSmsInput!): BulkSmsSummary! @requireAuth
  }
`

Schema Components:

  • SendBulkSmsInput: Defines input – recipient array and message string
  • SmsSendResult: Defines output structure for each recipient's attempt
  • BulkSmsSummary: Defines overall response structure with aggregated results
  • @requireAuth: Redwood directive for authentication (see Redwood Auth Docs)

Remove @requireAuth temporarily for testing if auth isn't configured, but reinstate for production.

3.3 Test with GraphQL Playground:

  1. Start dev server: yarn rw dev
  2. Navigate to http://localhost:8911/graphql
  3. Provide auth header if needed: Authorization: Bearer <token>
  4. Execute the mutation:
graphql
mutation SendBulk {
  sendBulkSms(input: {
    recipients: ["+14155550101", "+14155550102"] # Use valid E.164 test numbers
    message: "Hello from RedwoodJS Bulk Sender!"
  }) {
    totalRecipients
    successfulSends
    failedSends
    results {
      to
      success
      messageId
      error
    }
  }
}

Expected Response:

json
{
  "data": {
    "sendBulkSms": {
      "totalRecipients": 2,
      "successfulSends": 2,
      "failedSends": 0,
      "results": [
        {
          "to": "+14155550101",
          "success": true,
          "messageId": "uuid-123",
          "error": null
        },
        {
          "to": "+14155550102",
          "success": true,
          "messageId": "uuid-456",
          "error": null
        }
      ]
    }
  }
}

Check terminal logs and test phones to verify delivery.

4. Vonage Integration Setup and Configuration

Core integration requirements:

Credentials Required:

  • VONAGE_API_KEY, VONAGE_API_SECRET, VONAGE_APPLICATION_ID, VONAGE_PRIVATE_KEY_PATH, VONAGE_FROM_NUMBER (configured via .env)

Vonage Dashboard Configuration:

  1. API Keys: Dashboard home page
  2. Application: Applications > Create new application
    • Name it appropriately
    • Enable "Messages" capability
    • Generate keys and download private.key
    • Note the Application ID
    • Webhook URLs can be dummy HTTPS URLs (e.g., https://example.com/status) if only sending
  3. Number: Numbers > Your numbers – ensure SMS capability in target country
  4. Secure Storage: Keep .env and private.key out of Git (use .gitignore)
  5. Deployment: Use hosting provider's environment variable management for secrets

Webhook Configuration (Optional):

For delivery receipts, configure webhook URLs:

  • Status webhook: https://yourdomain.com/webhooks/vonage/status
  • Inbound webhook: https://yourdomain.com/webhooks/vonage/inbound

5. Production Error Handling, Logging, and Retry Mechanisms

Build robustness into your system with comprehensive error handling.

5.1 Consistent Error Handling:

sendSingleSms uses try...catch for Vonage SDK errors, logging and returning structured error objects. sendBulkSms uses Promise.allSettled to handle individual failures within batches.

5.2 Logging:

Redwood's Pino logger (api/src/lib/logger.ts) provides structured logging:

LevelUsageExample
debugVerbose details during developmentlogger.debug('Attempting to send SMS...')
infoNormal operations and summarieslogger.info('Bulk SMS job completed')
warnRecoverable issueslogger.warn('Retrying after rate limit...')
errorFailures requiring attentionlogger.error('Failed to send SMS', error)

Production Logging Setup:

Set appropriate log levels in production:

javascript
// api/src/lib/logger.ts
export const logger = createLogger({
  options: {
    level: process.env.LOG_LEVEL || 'info', // 'debug' for development
    redact: ['recipient', 'messageText'], // Redact PII in production
  },
})

Consider centralized logging services (Datadog, Logtail, LogDNA) for production monitoring.

5.3 Retry Mechanisms:

Handle temporary Vonage errors (network issues, rate limits) with intelligent retries:

typescript
// Helper function to determine if error is retryable
function isRetryable(error: any): boolean {
  // Check for temporary errors (rate limits, network issues, server errors)
  if (error.status) {
    return error.status === 429 || (error.status >= 500 && error.status < 600)
  }
  // Check for network errors
  if (error.code) {
    return ['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND'].includes(error.code)
  }
  return false
}

// Enhanced sendSingleSms with retry logic
async function sendSingleSmsWithRetry(
  to: string,
  text: string,
  maxAttempts: number = 3
): Promise<{ success: boolean; messageId?: string; error?: string }> {
  let lastError: any

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const result = await sendSingleSms(to, text)
      if (result.success) {
        return result
      }
      lastError = new Error(result.error)
    } catch (error) {
      lastError = error

      if (!isRetryable(error) || attempt === maxAttempts) {
        break
      }

      // Exponential backoff: 1s, 2s, 4s
      const delayMs = 1000 * Math.pow(2, attempt - 1)
      logger.warn(`Attempt ${attempt} failed for ${to}. Retrying in ${delayMs}ms...`)
      await new Promise(resolve => setTimeout(resolve, delayMs))
    }
  }

  const errorMessage = lastError instanceof Error ? lastError.message : 'Unknown error'
  logger.error(`All ${maxAttempts} attempts failed for ${to}: ${errorMessage}`)
  return { success: false, error: errorMessage }
}

Queue-Based Retries (Recommended):

For production reliability, use a job queue like BullMQ:

bash
yarn workspace api add bullmq ioredis
typescript
// api/src/lib/queue.ts
import { Queue, Worker } from 'bullmq'
import Redis from 'ioredis'

const connection = new Redis(process.env.REDIS_URL)

export const smsQueue = new Queue('sms', { connection })

// Worker to process SMS jobs
const worker = new Worker('sms', async (job) => {
  const { to, message } = job.data
  return await sendSingleSms(to, message)
}, {
  connection,
  concurrency: 5, // Process 5 messages concurrently
  limiter: {
    max: 1, // 1 message per second (adjust based on your Vonage limits)
    duration: 1000,
  },
})

Testing Errors:

  • Use invalid numbers to trigger validation errors
  • Temporarily use incorrect credentials to test authentication failures
  • Send large lists to trigger rate limits
  • Mock vonageMessages.send in unit tests to throw specific errors

6. Prisma Database Schema for SMS Message Logging

Track all SMS attempts for auditing, reporting, and troubleshooting.

6.1 Define Prisma Model:

Add to api/db/schema.prisma:

prisma
// api/db/schema.prisma

// ... (datasource and generator)

model SmsLog {
  id              String   @id @default(cuid())
  recipient       String   // The phone number the message was sent to (E.164)
  messageText     String   // The content of the message
  status          String   // e.g., 'SUCCESS', 'FAILED'
  vonageMessageId String?  @unique // The message_uuid from Vonage on success
  errorMessage    String?  // Error details on failure
  sentAt          DateTime @default(now()) // When the send attempt was initiated
  updatedAt       DateTime @updatedAt

  @@index([recipient])
  @@index([status])
  @@index([sentAt])
}

Index Strategy:

  • recipient: Query logs for specific phone numbers
  • status: Filter by success/failure
  • sentAt: Time-based queries and reporting
  • vonageMessageId: Unique constraint for Vonage message UUIDs

6.2 Apply Migrations:

bash
yarn rw prisma migrate dev --name add_sms_log

6.3 Query Examples:

typescript
// Get recent failures
const recentFailures = await db.smsLog.findMany({
  where: {
    status: 'FAILED',
    sentAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } // Last 24 hours
  },
  orderBy: { sentAt: 'desc' },
  take: 100
})

// Count messages by status
const stats = await db.smsLog.groupBy({
  by: ['status'],
  _count: true
})

The logging logic in sendSingleSms and logSmsAttempt automatically records every attempt to the SmsLog table.

7. Security Features and Authentication for Bulk SMS

Protect your API and ensure compliance with telecommunications regulations.

7.1 Authentication & Authorization:

Implement proper authentication using Redwood Auth:

typescript
// api/src/services/sms/sms.ts
import { requireAuth } from 'src/lib/auth'

export const sendBulkSms = async ({ input }: { input: SendBulkSmsInput }) => {
  // Restrict to admin users only
  requireAuth({ roles: ['admin'] })

  // ... rest of implementation
}

7.2 Input Validation:

Validate all inputs before processing:

typescript
// Add to sendBulkSms function
export const sendBulkSms = async ({ input }: { input: SendBulkSmsInput }) => {
  const { recipients, message } = input

  // Validate recipient count
  if (recipients.length === 0) {
    throw new Error('Recipient list cannot be empty')
  }
  if (recipients.length > 1000) {
    throw new Error('Maximum 1000 recipients per batch')
  }

  // Validate message content
  if (!message || message.trim().length === 0) {
    throw new Error('Message content is required')
  }
  if (message.length > 1600) {
    throw new Error('Message exceeds maximum length of 1600 characters')
  }

  // Validate phone numbers
  const e164Regex = /^\+[1-9]\d{1,14}$/
  const invalidNumbers = recipients.filter(num => !e164Regex.test(num))
  if (invalidNumbers.length > 0) {
    throw new Error(`Invalid phone numbers: ${invalidNumbers.join(', ')}`)
  }

  // ... rest of implementation
}

7.3 Rate Limiting:

Protect your GraphQL endpoint from abuse:

bash
yarn workspace api add graphql-rate-limit-directive
typescript
// api/src/directives/rateLimit/rateLimit.ts
import { createRateLimitDirective } from 'graphql-rate-limit-directive'

export const rateLimitDirective = createRateLimitDirective({
  identifyContext: (ctx) => ctx.currentUser?.id || ctx.event.headers['x-forwarded-for']
})
graphql
# api/src/graphql/sms.sdl.ts
type Mutation {
  sendBulkSms(input: SendBulkSmsInput!): BulkSmsSummary!
    @requireAuth
    @rateLimit(limit: 10, duration: 3600) # 10 requests per hour
}

7.4 Secret Management:

Never commit secrets to version control:

gitignore
# .gitignore
.env
.env.*
private.key
*.key
*.pem

Use environment variables in deployment platforms (Vercel, Render, AWS):

  • Store secrets in platform's environment variable manager
  • Use secret scanning tools (git-secrets, TruffleHog)
  • Rotate credentials regularly

7.5 Compliance & Consent:

Critical legal requirements:

RegulationRequirementImplementation
TCPA (US)Explicit opt-in for marketingMaintain consent database
GDPR (EU)Right to erasureImplement data deletion endpoints
CAN-SPAMEasy opt-out mechanismHonor STOP keywords
CASL (Canada)Express consent before sendingDocument consent timestamp
prisma
// Add consent tracking to schema
model Consent {
  id          String   @id @default(cuid())
  phoneNumber String   @unique
  opted_in     Boolean  @default(false)
  optInDate   DateTime?
  optOutDate  DateTime?
  source      String   // How consent was obtained
  ipAddress   String?  // IP address when consent was given
}

8. Handling Special Cases in SMS Broadcasting

Address SMS-specific challenges and edge cases.

8.1 Phone Number Formatting:

Always enforce E.164 format (+14155550100):

typescript
// api/src/lib/phoneUtils.ts
export function normalizePhoneNumber(phone: string, defaultRegion: string = 'US'): string {
  // Remove all non-digit characters except leading +
  let cleaned = phone.replace(/[^\d+]/g, '')

  // Add + if missing
  if (!cleaned.startsWith('+')) {
    cleaned = '+' + cleaned
  }

  return cleaned
}

export function validateE164(phone: string): boolean {
  const e164Regex = /^\+[1-9]\d{1,14}$/
  return e164Regex.test(phone)
}

8.2 Character Limits & Encoding:

Understand SMS encoding and segmentation:

EncodingCharacters per segmentUse Case
GSM-7160 charsBasic Latin characters
UCS-270 charsEmojis, special characters
ConcatenatedMultiple segmentsLonger messages (charged per segment)
typescript
// api/src/lib/smsUtils.ts
export function calculateSegments(message: string): {
  segments: number
  encoding: 'GSM-7' | 'UCS-2'
  charsPerSegment: number
} {
  const gsmRegex = /^[@£$¥èéùìòÇØøÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&'()*+,\-.\/0-9:;<=>?¡A-ZÄÖÑܧ¿a-zäöñüà\r\n]*$/

  const isGsm = gsmRegex.test(message)
  const encoding = isGsm ? 'GSM-7' : 'UCS-2'
  const charsPerSegment = isGsm ? (message.length > 160 ? 153 : 160) : (message.length > 70 ? 67 : 70)
  const segments = Math.ceil(message.length / charsPerSegment)

  return { segments, encoding, charsPerSegment }
}

8.3 International Sending:

Configure for international destinations:

  • Verify Vonage account has international sending enabled
  • Check destination country regulations and filtering rules
  • Test with numbers in target countries before bulk sending
  • Be aware of higher costs for international SMS

8.4 Opt-Out Handling:

Vonage automatically handles standard STOP keywords in US/CA. Maintain a suppression list:

typescript
// api/src/services/sms/sms.ts
async function shouldSendSms(phoneNumber: string): Promise<boolean> {
  const consent = await db.consent.findUnique({
    where: { phoneNumber }
  })

  return consent?.opted_in === true && !consent.optOutDate
}

// Update sendBulkSms to check consent
export const sendBulkSms = async ({ input }: { input: SendBulkSmsInput }) => {
  const { recipients, message } = input

  // Filter out opted-out recipients
  const filteredRecipients = []
  for (const recipient of recipients) {
    if (await shouldSendSms(recipient)) {
      filteredRecipients.push(recipient)
    } else {
      logger.info(`Skipping opted-out recipient: ${recipient}`)
    }
  }

  // ... continue with filteredRecipients
}

8.5 Time Zone Considerations:

Schedule sends based on recipient time zones:

typescript
// api/src/lib/timezoneUtils.ts
import { DateTime } from 'luxon'

export function isBusinessHours(timezone: string): boolean {
  const now = DateTime.now().setZone(timezone)
  const hour = now.hour

  // 9 AM to 8 PM local time
  return hour >= 9 && hour < 20
}

export function calculateDelay(timezone: string, targetHour: number = 9): number {
  const now = DateTime.now().setZone(timezone)
  const target = now.set({ hour: targetHour, minute: 0, second: 0 })

  if (now > target) {
    // Schedule for tomorrow
    return target.plus({ days: 1 }).diff(now).milliseconds
  }

  return target.diff(now).milliseconds
}

9. Performance Optimizations for High-Volume SMS

Scale your system to handle thousands of messages efficiently.

9.1 Asynchronous Processing with Queues:

Move SMS sending to background workers for improved reliability:

typescript
// api/src/services/sms/sms.ts
import { smsQueue } from 'src/lib/queue'

export const sendBulkSms = async ({ input }: { input: SendBulkSmsInput }) => {
  const { recipients, message } = input

  // Enqueue jobs instead of sending directly
  const jobs = await Promise.all(
    recipients.map(recipient =>
      smsQueue.add('send-sms', {
        to: recipient,
        message,
        timestamp: Date.now()
      })
    )
  )

  return {
    totalRecipients: recipients.length,
    jobsCreated: jobs.length,
    message: 'SMS jobs queued successfully'
  }
}

9.2 Database Write Optimization:

Batch database writes for better performance:

typescript
async function logBulkSmsAttempts(attempts: Array<{
  recipient: string
  messageText: string
  status: 'SUCCESS' | 'FAILED'
  vonageMessageId?: string
  errorMessage?: string
}>) {
  try {
    await db.smsLog.createMany({
      data: attempts,
      skipDuplicates: true
    })
  } catch (error) {
    logger.error('Failed to batch log SMS attempts', error)
  }
}

9.3 Concurrency Management:

Control concurrent API requests:

bash
yarn workspace api add p-limit
typescript
import pLimit from 'p-limit'

export const sendBulkSms = async ({ input }: { input: SendBulkSmsInput }) => {
  const { recipients, message } = input
  const limit = pLimit(5) // Max 5 concurrent requests

  const sendPromises = recipients.map(recipient =>
    limit(() => sendSingleSms(recipient, message))
  )

  const results = await Promise.allSettled(sendPromises)
  // ... process results
}

9.4 Performance Benchmarks:

Target performance metrics:

MetricTargetNotes
API Response Time< 200msFor queueing approach
Message Throughput1-5 msg/secDepends on number type
Queue Processing< 2s per messageIncluding retries
Database Write< 50msBatch operations
Memory Usage< 512MBNode.js process

Monitor with:

typescript
// api/src/lib/metrics.ts
import { performance } from 'perf_hooks'

export function trackDuration(label: string, fn: () => Promise<any>) {
  const start = performance.now()
  return fn().finally(() => {
    const duration = performance.now() - start
    logger.info(`${label} took ${duration.toFixed(2)}ms`)
  })
}

10. Monitoring and Observability for SMS Broadcasting

Gain visibility into system health and performance.

10.1 Centralized Logging:

Configure production logging with Pino transports:

bash
yarn workspace api add pino-pretty @logtail/pino
typescript
// api/src/lib/logger.ts
import { Logtail } from '@logtail/pino'
import { createLogger } from '@redwoodjs/api/logger'

const logtail = new Logtail(process.env.LOGTAIL_TOKEN)

export const logger = createLogger({
  options: {
    level: process.env.LOG_LEVEL || 'info',
    redact: ['recipient', 'messageText', 'vonageApiKey', 'vonageApiSecret']
  },
  destination: logtail
})

10.2 Error Tracking:

Integrate Sentry for error monitoring:

bash
yarn workspace api add @sentry/node
typescript
// api/src/lib/sentry.ts
import * as Sentry from '@sentry/node'

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.1
})

export { Sentry }

10.3 Metrics Collection:

Track key metrics with Prometheus:

bash
yarn workspace api add prom-client
typescript
// api/src/lib/metrics.ts
import { Counter, Histogram, Registry } from 'prom-client'

const register = new Registry()

export const smsCounter = new Counter({
  name: 'sms_sent_total',
  help: 'Total number of SMS messages sent',
  labelNames: ['status'],
  registers: [register]
})

export const smsDuration = new Histogram({
  name: 'sms_send_duration_seconds',
  help: 'SMS send duration in seconds',
  registers: [register]
})

// Expose metrics endpoint
export function getMetrics() {
  return register.metrics()
}

10.4 Health Checks:

Implement health check endpoint:

typescript
// api/src/functions/health/health.ts
import type { APIGatewayEvent, Context } from 'aws-lambda'
import { db } from 'src/lib/db'

export const handler = async (event: APIGatewayEvent, context: Context) => {
  const checks = {
    database: false,
    timestamp: new Date().toISOString()
  }

  try {
    await db.$queryRaw`SELECT 1`
    checks.database = true
  } catch (error) {
    logger.error('Database health check failed', error)
  }

  const isHealthy = checks.database

  return {
    statusCode: isHealthy ? 200 : 503,
    body: JSON.stringify(checks)
  }
}

10.5 Alerting:

Configure alerts for critical issues:

Alert Thresholds:

  • Error rate > 10% over 5 minutes
  • API latency > 1 second for 5 consecutive requests
  • Queue depth > 10,000 messages
  • Failed sends > 100 in 10 minutes
  • Database connection failures

10.6 Vonage Dashboard Monitoring:

Regularly check:

  • Delivery rates by destination country
  • Error codes and their frequency
  • Account balance and spending trends
  • Message throughput over time

11. Troubleshooting Common Vonage Integration Issues

Diagnose and resolve common problems quickly.

IssueSymptomsSolution
Rate Limits429 Too Many Requests, timeoutsImplement throttling or queue system. Check number type limits (1 msg/sec for long codes).
Invalid Credentials401 UnauthorizedVerify all VONAGE_* environment variables. Ensure private.key path is correct and file is readable.
Bad Number Format'Non-Network Number', invalid 'to'Validate E.164 format strictly (+14155550100). Remove spaces, dashes, parentheses.
Insufficient FundsBalance-related errorsAdd credit to Vonage account. Set up automatic top-up.
Blocked MessagesSent OK but not deliveredCheck carrier filtering, A2P 10DLC registration (US), sender ID restrictions, message content.
Private Key IssuesAuthentication failuresEnsure path is relative to running process. Check file permissions (readable). Verify key file isn't corrupted.
SDK Version IssuesUnexpected errors, type issuesCheck changelog for breaking changes. Pin SDK version in package.json.

10DLC Registration (US Critical):

Sending Application-to-Person SMS via US long codes requires Brand and Campaign registration:

  1. Register your brand in Vonage Dashboard
  2. Create and submit campaign for approval
  3. Wait for approval (1-2 weeks typically)
  4. Unregistered traffic faces severe filtering or blocking

Debug Mode:

Enable detailed logging:

typescript
// api/src/lib/logger.ts
export const logger = createLogger({
  options: {
    level: 'debug', // Verbose output
    prettyPrint: true
  }
})

Diagnostic Checklist:

  • All environment variables set correctly
  • Private key file exists and is readable
  • Phone numbers in E.164 format
  • Vonage account has sufficient balance
  • Messages API capability enabled on application
  • From number is SMS-capable
  • 10DLC registered (for US traffic)
  • Network connectivity to Vonage API
  • No firewall blocking outbound HTTPS

Support Resources:

12. Production Deployment and CI/CD for RedwoodJS

Deploy your bulk SMS system securely and reliably.

12.1 Hosting Options:

PlatformProsCons
VercelEasy setup, automatic previewsServerless cold starts
RenderPersistent connections, Redis supportMore complex setup
AWSFull control, scalableSteeper learning curve
NetlifySimple deploymentLimited backend features

12.2 Environment Configuration:

Configure all secrets in your hosting platform:

bash
# Required environment variables for production
VONAGE_API_KEY=your_api_key
VONAGE_API_SECRET=your_api_secret
VONAGE_APPLICATION_ID=your_app_id
VONAGE_PRIVATE_KEY_PATH=/var/secrets/private.key
VONAGE_FROM_NUMBER=+14155550100
DATABASE_URL=postgresql://user:pass@host:5432/db
REDIS_URL=redis://host:6379
LOG_LEVEL=info
SENTRY_DSN=your_sentry_dsn
NODE_ENV=production

12.3 CI/CD Pipeline:

Create .github/workflows/deploy.yml:

yaml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'yarn'

      - name: Install Dependencies
        run: yarn install --frozen-lockfile

      - name: Lint Code
        run: yarn rw lint

      - name: Type Check
        run: yarn rw type-check

      - name: Run Tests
        run: yarn rw test api --no-watch
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/test

      - name: Build Application
        run: yarn rw build

      - name: Deploy to Production
        run: yarn rw deploy vercel --prod
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}

12.4 Database Migrations:

Apply migrations safely:

bash
# In CI/CD pipeline
yarn rw prisma migrate deploy

For zero-downtime deployments:

  1. Deploy code with backward-compatible schema changes
  2. Run migration
  3. Deploy code that uses new schema

12.5 Rollback Strategy:

Prepare for rollbacks:

bash
# Tag releases
git tag -a v1.2.3 -m "Release v1.2.3"
git push origin v1.2.3

# Rollback if needed
git checkout v1.2.2
yarn rw deploy vercel --prod

13. Testing and Verification Strategy

Ensure reliability with comprehensive testing.

13.1 Unit Tests:

Test service functions with mocks:

typescript
// api/src/services/sms/sms.test.ts
import { sendBulkSms } from './sms'
import { db } from 'src/lib/db'

// Mock dependencies
const mockSend = jest.fn()
jest.mock('@vonage/server-sdk', () => {
  const MockMessages = jest.fn().mockImplementation(() => ({
    send: mockSend
  }))
  const MockVonage = jest.fn().mockImplementation(() => ({
    options: {},
  }))
  return {
    Vonage: MockVonage,
    Messages: MockMessages,
    __esModule: true,
  }
})

jest.mock('src/lib/db', () => ({
  db: {
    smsLog: {
      create: jest.fn().mockResolvedValue({}),
    },
  },
}))

jest.mock('src/lib/logger')

const mockSmsLogCreate = db.smsLog.create as jest.Mock

describe('sms service', () => {
  beforeEach(() => {
    jest.clearAllMocks()
    mockSend.mockClear()
    mockSmsLogCreate.mockClear()
  })

  it('sendBulkSms handles success and failure correctly', async () => {
    // Arrange
    mockSend
      .mockResolvedValueOnce({ message_uuid: 'uuid-1' })
      .mockRejectedValueOnce(new Error('Vonage API Error'))

    const input = { recipients: ['+11112223333', '+14445556666'], message: 'Test' }

    // Act
    const result = await sendBulkSms({ input })

    // Assert
    expect(result.totalRecipients).toBe(2)
    expect(result.successfulSends).toBe(1)
    expect(result.failedSends).toBe(1)
    expect(result.results).toHaveLength(2)
    expect(result.results[0]).toMatchObject({
      to: '+11112223333',
      success: true,
      messageId: 'uuid-1'
    })
    expect(result.results[1]).toMatchObject({
      to: '+14445556666',
      success: false,
      error: expect.stringContaining('Vonage API Error')
    })

    // Verify mock calls
    expect(mockSend).toHaveBeenCalledTimes(2)
    expect(mockSmsLogCreate).toHaveBeenCalledTimes(2)
  })

  it('validates phone number format', async () => {
    const input = { recipients: ['1234567890'], message: 'Test' }
    const result = await sendBulkSms({ input })

    expect(result.failedSends).toBe(1)
    expect(result.results[0].error).toContain('Invalid phone number format')
  })

  it('handles empty recipient list', async () => {
    const input = { recipients: [], message: 'Test' }

    await expect(sendBulkSms({ input })).rejects.toThrow('Recipient list cannot be empty')
  })
})

13.2 Integration Tests:

Test GraphQL endpoint:

typescript
// api/src/services/sms/sms.scenarios.ts
export const standard = defineScenario({
  smsLog: {
    one: {
      data: {
        recipient: '+14155550101',
        messageText: 'Test message',
        status: 'SUCCESS',
        vonageMessageId: 'test-uuid-1'
      }
    }
  }
})

13.3 E2E Tests:

typescript
// web/src/pages/BulkSmsPage/BulkSmsPage.test.tsx
import { render, screen, waitFor } from '@redwoodjs/testing/web'
import BulkSmsPage from './BulkSmsPage'

describe('BulkSmsPage', () => {
  it('renders successfully', () => {
    render(<BulkSmsPage />)
    expect(screen.getByText('Send Bulk SMS')).toBeInTheDocument()
  })

  it('sends bulk SMS successfully', async () => {
    const { getByLabelText, getByText } = render(<BulkSmsPage />)

    // Fill form
    const recipientsInput = getByLabelText('Recipients')
    const messageInput = getByLabelText('Message')

    fireEvent.change(recipientsInput, {
      target: { value: '+14155550101\n+14155550102' }
    })
    fireEvent.change(messageInput, {
      target: { value: 'Test message' }
    })

    // Submit
    fireEvent.click(getByText('Send'))

    // Verify
    await waitFor(() => {
      expect(screen.getByText('Messages sent successfully')).toBeInTheDocument()
    })
  })
})

13.4 Test Coverage Targets:

  • Unit tests: > 80% coverage
  • Integration tests: All GraphQL endpoints
  • E2E tests: Critical user flows
  • Manual testing: Real phone numbers in staging

13.5 Testing Best Practices:

  • Mock external APIs (Vonage) in unit/integration tests
  • Use test phone numbers for manual verification
  • Test error scenarios thoroughly
  • Verify database logging
  • Test rate limiting behavior
  • Validate input edge cases

Conclusion

You've built a production-ready bulk SMS broadcasting system with RedwoodJS and Vonage. Your implementation includes:

✓ Secure Vonage SDK integration with environment-based configuration ✓ GraphQL API with authentication and input validation ✓ Concurrent message sending with error handling ✓ Database logging for auditing and troubleshooting ✓ Security features including consent tracking and rate limiting ✓ Production deployment strategy with CI/CD pipeline ✓ Comprehensive testing approach

Next Steps:

  1. Implement Queue System: Add BullMQ for production-scale reliability
  2. Setup Monitoring: Integrate Sentry and centralized logging
  3. 10DLC Registration: Complete for US SMS traffic
  4. Add Webhooks: Receive delivery receipts and inbound messages
  5. Build UI: Create frontend for message composition and sending
  6. Expand Features: Add scheduling, templates, personalization

Production Checklist:

  • All secrets configured in hosting environment
  • Database migrated and backed up
  • 10DLC registered (US traffic)
  • Consent tracking implemented
  • Rate limiting enabled
  • Monitoring and alerting configured
  • CI/CD pipeline tested
  • Load testing completed
  • Documentation updated
  • Team trained on operations

For questions or issues, consult:

Frequently Asked Questions

How to send bulk SMS messages with RedwoodJS?

Integrate the Vonage Messages API into your RedwoodJS application. This involves setting up a RedwoodJS project, installing the Vonage SDK, configuring environment variables, creating a secure API endpoint, and implementing a service to interact with the Vonage API. The provided guide offers a detailed walkthrough of the process, covering key aspects like error handling, security, and deployment considerations, to achieve a robust SMS broadcasting solution within your RedwoodJS app.

What is the Vonage Messages API used for in RedwoodJS?

The Vonage Messages API enables sending and receiving messages across various channels, including SMS, directly from your RedwoodJS application's backend. This guide focuses on using it for bulk SMS broadcasting, allowing you to efficiently notify multiple recipients simultaneously for alerts, marketing campaigns (with required consent), or group notifications.

Why use RedwoodJS for bulk SMS applications?

RedwoodJS offers a structured, full-stack JavaScript framework with built-in conventions for API services (GraphQL), database interaction (Prisma), and more. This accelerates development and provides a robust foundation for integrating features like bulk SMS sending via the Vonage API, as outlined in the guide.

When should I use a queue system for bulk SMS?

For production-level bulk SMS applications, especially with large recipient lists, a queue system (like BullMQ or AWS SQS) is strongly recommended. This handles individual message processing in the background, respecting Vonage's rate limits and ensuring reliable delivery without overwhelming the API or your application server. Simple throttling with delays is less robust for scaling and error handling.

Can I use emojis in my bulk SMS messages with Vonage?

Yes, but be aware of character limits. Emojis and special characters use UCS-2 encoding, limiting messages to 70 characters per segment. Standard GSM-7 encoding allows 160 characters. Longer messages, including those with emojis, are concatenated into multiple segments, each incurring a separate charge.

What is the Vonage application ID used for?

The Vonage Application ID, along with the associated private key, is essential for authenticating your application with the Vonage Messages API. You generate these when you create a new Vonage Application in your Vonage Dashboard. In addition to generating keys, enable 'Messages' as a capability in your Vonage application, and download the private key which needs to be added as an additional environment variable. Remember to store these credentials securely.

How to handle Vonage API rate limits in RedwoodJS?

Vonage imposes rate limits on SMS sending. The guide strongly recommends implementing a queue system (like BullMQ or AWS SQS) for production applications. This offloads message processing to background workers, enabling controlled, rate-limit-respecting sending. While simple throttling with delays is possible, it's less robust for scaling and error handling.

What is the role of Prisma in the bulk SMS RedwoodJS application?

Prisma, RedwoodJS's default ORM, is used for defining the database schema (including the SmsLog model to track message attempts), managing database migrations, and providing convenient data access within your services. It simplifies database interactions and ensures data integrity.

How to secure my Vonage API credentials in RedwoodJS?

Store your Vonage API key, secret, application ID, and the path to your downloaded private key as environment variables in a `.env` file. Crucially, add both `.env` and `private.key` to your `.gitignore` file to prevent committing these secrets to version control. For deployment, utilize your hosting provider's secure environment variable management features.

How to log bulk SMS attempts with RedwoodJS and Prisma?

The guide provides a `logSmsAttempt` helper function that uses Prisma to create records in a dedicated `SmsLog` table in your database. This function is called within the `sendSingleSms` function, logging each attempt with details like recipient, message, status, Vonage message ID (if successful), and error messages (if failed).

How to handle errors when sending bulk SMS with Vonage?

The `sendSingleSms` function uses `try...catch` blocks to handle errors from the Vonage SDK. `sendBulkSms` uses `Promise.allSettled` to manage individual message failures within a batch. Logging via Redwood's logger and database logging via Prisma provide detailed records for troubleshooting. Implement retry mechanisms for temporary errors.

What is A2P 10DLC, and why is it important for US SMS?

A2P 10DLC (Application-to-Person 10-Digit Long Code) is a mandatory registration framework in the US for sending application-to-person SMS messages using standard long codes. It requires registering your brand and campaign with Vonage (or The Campaign Registry) to ensure compliance and avoid message filtering or blocking. This is crucial for reliable SMS delivery in the US.

What to do if Vonage SMS messages are not delivered?

Several factors can cause non-delivery even if the Vonage API reports success. Check for carrier filtering (spam), verify your sender ID, review country-specific regulations, ensure message content compliance, and crucially, confirm A2P 10DLC registration for US traffic. Consult Vonage delivery receipts and logs for specific error codes.

How to test my RedwoodJS bulk SMS implementation?

The guide recommends a multi-layered approach: unit tests (mocking the Vonage SDK and database interactions), integration tests for the GraphQL mutation, and end-to-end tests with tools like Cypress for UI interaction (if applicable). Manual testing with real test phone numbers is also essential for validating real-world delivery and edge cases.

How to deploy a RedwoodJS bulk SMS application?

Follow Redwood's deployment guides for your chosen hosting provider (Vercel, Render, Netlify, AWS, etc.). Configure all environment variables securely, provision a production database (PostgreSQL recommended), and set up a CI/CD pipeline for automated build, testing, database migrations, and deployment. Handle private key uploads securely.