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.
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.
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:
.env
# .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:
API Key & Secret: Dashboard home page under "API Settings"
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)
Private Key Path: Save private.key to your project root
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:
# .env
DATABASE_URL="file:./dev.db" # SQLite for development
# For PostgreSQL: DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
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.tsimport { Vonage } from'@vonage/server-sdk'import { Messages } from'@vonage/messages'// Import the Messages class type if neededimporttype { SendBulkSmsInput } from'types/graphql'// We'll create this type laterimport { logger } from'src/lib/logger'import { db } from'src/lib/db'// Import the db client// --- Vonage Client Initialization ---// Retrieve credentials securely from environment variablesconst vonageApiKey = process.env.VONAGE_API_KEYconst vonageApiSecret = process.env.VONAGE_API_SECRETconst vonageAppId = process.env.VONAGE_APPLICATION_IDconst vonagePrivateKeyPath = process.env.VONAGE_PRIVATE_KEY_PATHconst vonageFromNumber = process.env.VONAGE_FROM_NUMBER// Basic validation to ensure environment variables are setif (
!vonageApiKey ||
!vonageApiSecret ||
!vonageAppId ||
!vonagePrivateKeyPath ||
!vonageFromNumber
) {
thrownewError(
'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 = newVonage({
apiKey: vonageApiKey,
apiSecret: vonageApiSecret,
applicationId: vonageAppId,
privateKey: vonagePrivateKeyPath,
})
// Create a specific client instance for the Messages APIconst vonageMessages = newMessages(vonage.options) // Use the same options// --- Helper Function for Single SMS ---// Encapsulates sending a single message and basic error handlingasyncfunctionsendSingleSms(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 numbertext: text,
})
logger.info(`SMS sent to ${to}, message_uuid: ${resp.message_uuid}`)
awaitlogSmsAttempt({
...commonLogData,
status: 'SUCCESS',
vonageMessageId: resp.message_uuid,
})
return { success: true, messageId: resp.message_uuid }
} catch (error) {
const errorMessage = error instanceofError ? 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 hereawaitlogSmsAttempt({
...commonLogData,
status: 'FAILED',
errorMessage: errorMessage,
})
return { success: false, error: errorMessage }
}
}
// --- Database Logging Helper ---asyncfunctionlogSmsAttempt(data: {
recipient: string
messageText: string
status: 'SUCCESS' | 'FAILED'
vonageMessageId?: string
errorMessage?: string
}) {
try {
await db.smsLog.create({ data })
} catch (dbError) {
const errorMessage = dbError instanceofError ? 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 ---exportconstsendBulkSms = async ({ input }: { input: SendBulkSmsInput }) => {
const { recipients, message } = input
constresults: Array<{
to: stringsuccess: booleanmessageId?: stringerror?: 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 deploymentconst 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 instanceofError ? err.message : 'Unknown error'return {
to: recipient,
success: false,
error: `Unexpected error during sendSingleSms execution: ${errorMessage}`,
}
})
)
const settledResults = awaitPromise.allSettled(sendPromises)
settledResults.forEach((result, index) => {
const recipient = recipients[index] // Maintain orderif (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.reasoninstanceofError ? 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).lengthconst failedSends = results.length - successfulSends
logger.info(
`Bulk SMS job completed. Success: ${successfulSends}, Failed: ${failedSends}`
)
// Return a summary and detailed resultsreturn {
totalRecipients: recipients.length,
successfulSends,
failedSends,
results, // Array of individual results
}
}
// Add other SMS-related service functions if needed (e.g., getMessageStatus)
Key Implementation Details:
Component
Purpose
Notes
Credentials
Loaded from process.env
Validated at startup
Vonage Client
Initialized with required credentials
Dedicated Messages instance created
sendSingleSms
Sends one message with error handling
Validates E.164 format and message length
logSmsAttempt
Records attempts to database via Prisma
Non-blocking – failure doesn't stop sending
sendBulkSms
Processes multiple recipients concurrently
Uses 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 mutationinput SendBulkSmsInput {recipients:[String!]!# Array of phone numbers, expected in E.164 formatmessage: String!# The text message content}# Represents the result of a single SMS send attempt within the bulk jobtype SmsSendResult {to: String!success: Boolean!messageId: String # Vonage message UUID if successfulerror: String # Error message if failed}# Represents the overall summary of the bulk SMS jobtype BulkSmsSummary {totalRecipients: Int!successfulSends: Int!failedSends: Int!results:[SmsSendResult!]!# Detailed results for each recipient}typeMutation{# 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
For production reliability, use a job queue like BullMQ:
Bash
yarn workspace api add bullmq ioredis
TypeScript
// api/src/lib/queue.tsimport { Queue, Worker } from'bullmq'importRedisfrom'ioredis'const connection = newRedis(process.env.REDIS_URL)
exportconst smsQueue = newQueue('sms', { connection })
// Worker to process SMS jobsconst worker = newWorker('sms', async (job) => {
const { to, message } = job.datareturnawaitsendSingleSms(to, message)
}, {
connection,
concurrency: 5, // Process 5 messages concurrentlylimiter: {
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
# api/src/graphql/sms.sdl.tstypeMutation{
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:
Regulation
Requirement
Implementation
TCPA (US)
Explicit opt-in for marketing
Maintain consent database
GDPR (EU)
Right to erasure
Implement data deletion endpoints
CAN-SPAM
Easy opt-out mechanism
Honor STOP keywords
CASL (Canada)
Express consent before sending
Document 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
}
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:
Implement Queue System: Add BullMQ for production-scale reliability
Setup Monitoring: Integrate Sentry and centralized logging
10DLC Registration: Complete for US SMS traffic
Add Webhooks: Receive delivery receipts and inbound messages
Build UI: Create frontend for message composition and sending
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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:
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:
# Replace 'redwood-bulk-sms' with your desired project name yarn create redwood-app redwood-bulk-sms --typescript cd redwood-bulk-smsThis scaffolds a new RedwoodJS project with TypeScript enabled.
1.2 Install Vonage SDK:
Install the Vonage Node.js SDK in the API workspace:
Verify installation:
You should see
@vonage/server-sdkin the output.1.3 Configure Environment Variables:
Store your Vonage credentials securely in environment variables.
Create a
.envfile in your project root and add these variables:Obtain credentials from Vonage Dashboard:
private.keyprivate.keyto your project rootAdd to your
.gitignorefile:1.4 Setup Prisma Database:
Configure a basic SQLite database (switch to PostgreSQL for production).
Ensure your
api/db/schema.prismafile has a provider configured:Add the database URL to your
.envfile: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:
This creates
api/src/services/sms/sms.tsand related test/scenario files.2.2 Implement Sending Logic:
Open
api/src/services/sms/sms.tsand implement the Vonage integration:// 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:
process.envMessagesinstance createdsendSingleSmslogSmsAttemptsendBulkSmsPromise.allSettledfor reliability3. Building the GraphQL API Layer
Expose the
sendBulkSmsservice function through Redwood's GraphQL API.3.1 Generate SDL:
3.2 Define GraphQL Schema:
Open
api/src/graphql/sms.sdl.tsand define the input type and mutation:# 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 stringSmsSendResult: Defines output structure for each recipient's attemptBulkSmsSummary: Defines overall response structure with aggregated results@requireAuth: Redwood directive for authentication (see Redwood Auth Docs)Remove
@requireAuthtemporarily for testing if auth isn't configured, but reinstate for production.3.3 Test with GraphQL Playground:
yarn rw devhttp://localhost:8911/graphqlAuthorization: Bearer <token>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:
{ "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:
private.keyhttps://example.com/status) if only sending.envandprivate.keyout of Git (use.gitignore)Webhook Configuration (Optional):
For delivery receipts, configure webhook URLs:
https://yourdomain.com/webhooks/vonage/statushttps://yourdomain.com/webhooks/vonage/inbound5. Production Error Handling, Logging, and Retry Mechanisms
Build robustness into your system with comprehensive error handling.
5.1 Consistent Error Handling:
sendSingleSmsusestry...catchfor Vonage SDK errors, logging and returning structured error objects.sendBulkSmsusesPromise.allSettledto handle individual failures within batches.5.2 Logging:
Redwood's Pino logger (
api/src/lib/logger.ts) provides structured logging:debuglogger.debug('Attempting to send SMS...')infologger.info('Bulk SMS job completed')warnlogger.warn('Retrying after rate limit...')errorlogger.error('Failed to send SMS', error)Production Logging Setup:
Set appropriate log levels in production:
// 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:
// 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:
// 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:
vonageMessages.sendin unit tests to throw specific errors6. 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:Index Strategy:
recipient: Query logs for specific phone numbersstatus: Filter by success/failuresentAt: Time-based queries and reportingvonageMessageId: Unique constraint for Vonage message UUIDs6.2 Apply Migrations:
6.3 Query Examples:
// 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
sendSingleSmsandlogSmsAttemptautomatically records every attempt to theSmsLogtable.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:
// 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:
// 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:
// 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'] })# 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:
Use environment variables in deployment platforms (Vercel, Render, AWS):
7.5 Compliance & Consent:
Critical legal requirements:
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):// 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:
// 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:
8.4 Opt-Out Handling:
Vonage automatically handles standard STOP keywords in US/CA. Maintain a suppression list:
// 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:
// 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:
// 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:
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:
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:
Monitor with:
// 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:
// 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:
// 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:
// 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:
// 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:
10.6 Vonage Dashboard Monitoring:
Regularly check:
11. Troubleshooting Common Vonage Integration Issues
Diagnose and resolve common problems quickly.
429 Too Many Requests, timeouts401 UnauthorizedVONAGE_*environment variables. Ensureprivate.keypath is correct and file is readable.+14155550100). Remove spaces, dashes, parentheses.package.json.10DLC Registration (US Critical):
Sending Application-to-Person SMS via US long codes requires Brand and Campaign registration:
Debug Mode:
Enable detailed logging:
// api/src/lib/logger.ts export const logger = createLogger({ options: { level: 'debug', // Verbose output prettyPrint: true } })Diagnostic Checklist:
Support Resources:
12. Production Deployment and CI/CD for RedwoodJS
Deploy your bulk SMS system securely and reliably.
12.1 Hosting Options:
12.2 Environment Configuration:
Configure all secrets in your hosting platform:
# 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=production12.3 CI/CD Pipeline:
Create
.github/workflows/deploy.yml: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:
# In CI/CD pipeline yarn rw prisma migrate deployFor zero-downtime deployments:
12.5 Rollback Strategy:
Prepare for rollbacks:
# 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 --prod13. Testing and Verification Strategy
Ensure reliability with comprehensive testing.
13.1 Unit Tests:
Test service functions with mocks:
// 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:
// 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:
// 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:
13.5 Testing Best Practices:
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:
Production Checklist:
For questions or issues, consult: