code examples
code examples
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:
# 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:
yarn workspace api add @vonage/server-sdkVerify installation:
yarn workspace api list --pattern @vonage/server-sdkYou 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
# 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_HEREObtain 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.keyto 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:
# Secrets
.env
private.key
*.key1.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:
// api/db/schema.prisma
datasource db {
provider = "sqlite" // Or "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = "native"
}
// Add models belowAdd the database URL to your .env file:
# .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:
yarn rw g service smsThis 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:
// 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:
| 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:
yarn rw g sdl sms --no-crud3.2 Define GraphQL Schema:
Open api/src/graphql/sms.sdl.ts and 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 @requireAuth temporarily for testing if auth isn't configured, but reinstate for production.
3.3 Test with GraphQL Playground:
- Start dev server:
yarn rw dev - Navigate to
http://localhost:8911/graphql - Provide auth header if needed:
Authorization: Bearer <token> - Execute the mutation:
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:
- API Keys: Dashboard home page
- 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
- Number: Numbers > Your numbers – ensure SMS capability in target country
- Secure Storage: Keep
.envandprivate.keyout of Git (use.gitignore) - 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:
| Level | Usage | Example |
|---|---|---|
debug | Verbose details during development | logger.debug('Attempting to send SMS...') |
info | Normal operations and summaries | logger.info('Bulk SMS job completed') |
warn | Recoverable issues | logger.warn('Retrying after rate limit...') |
error | Failures requiring attention | logger.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:
yarn workspace api add bullmq ioredis// 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.sendin 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:
// 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 numbersstatus: Filter by success/failuresentAt: Time-based queries and reportingvonageMessageId: Unique constraint for Vonage message UUIDs
6.2 Apply Migrations:
yarn rw prisma migrate dev --name add_sms_log6.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 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:
// 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:
yarn workspace api add graphql-rate-limit-directive// 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:
# .gitignore
.env
.env.*
private.key
*.key
*.pemUse 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 |
// 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):
// 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:
| Encoding | Characters per segment | Use Case |
|---|---|---|
| GSM-7 | 160 chars | Basic Latin characters |
| UCS-2 | 70 chars | Emojis, special characters |
| Concatenated | Multiple segments | Longer messages (charged per segment) |
// 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:
// 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:
yarn workspace api add p-limitimport 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:
| Metric | Target | Notes |
|---|---|---|
| API Response Time | < 200ms | For queueing approach |
| Message Throughput | 1-5 msg/sec | Depends on number type |
| Queue Processing | < 2s per message | Including retries |
| Database Write | < 50ms | Batch operations |
| Memory Usage | < 512MB | Node.js process |
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:
yarn workspace api add pino-pretty @logtail/pino// 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:
yarn workspace api add @sentry/node// 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:
yarn workspace api add prom-client// 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:
- 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.
| Issue | Symptoms | Solution |
|---|---|---|
| Rate Limits | 429 Too Many Requests, timeouts | Implement throttling or queue system. Check number type limits (1 msg/sec for long codes). |
| Invalid Credentials | 401 Unauthorized | Verify 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 Funds | Balance-related errors | Add credit to Vonage account. Set up automatic top-up. |
| Blocked Messages | Sent OK but not delivered | Check carrier filtering, A2P 10DLC registration (US), sender ID restrictions, message content. |
| Private Key Issues | Authentication failures | Ensure path is relative to running process. Check file permissions (readable). Verify key file isn't corrupted. |
| SDK Version Issues | Unexpected errors, type issues | Check 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:
- Register your brand in Vonage Dashboard
- Create and submit campaign for approval
- Wait for approval (1-2 weeks typically)
- Unregistered traffic faces severe filtering or blocking
Debug Mode:
Enable detailed logging:
// 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:
- Vonage API Reference: https://developer.vonage.com/
- Vonage Support: https://support.vonage.com/
- Status Page: https://status.nexmo.com/
12. Production Deployment and CI/CD for RedwoodJS
Deploy your bulk SMS system securely and reliably.
12.1 Hosting Options:
| Platform | Pros | Cons |
|---|---|---|
| Vercel | Easy setup, automatic previews | Serverless cold starts |
| Render | Persistent connections, Redis support | More complex setup |
| AWS | Full control, scalable | Steeper learning curve |
| Netlify | Simple deployment | Limited backend features |
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:
- Deploy code with backward-compatible schema changes
- Run migration
- Deploy code that uses new schema
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:
- 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:
- 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
- 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.