This guide provides a step-by-step walkthrough for building a feature within a RedwoodJS application to send bulk SMS messages using the Vonage Messages API. We'll cover everything from project setup and core implementation to error handling, security, and deployment considerations, enabling you to build a robust and scalable SMS broadcasting solution.
Project Overview and Goals
What We're Building:
We will add functionality to a RedwoodJS application that enables authorized users to send the same SMS message to a list of phone numbers concurrently via an API endpoint. This involves:
- Setting up a RedwoodJS project with Vonage integration.
- Creating a secure API endpoint (GraphQL mutation) to trigger bulk SMS sends.
- Implementing a RedwoodJS service to interact with the Vonage Messages API.
- Handling potential rate limits and errors gracefully.
- Logging message statuses to a database.
- Discussing best practices for security, performance, and compliance.
Problem Solved:
This guide addresses the need to efficiently notify multiple recipients via SMS simultaneously for purposes like alerts, marketing campaigns (with consent), or group notifications, directly from a web application backend.
Technologies Used:
- RedwoodJS: A full-stack JavaScript/TypeScript framework for the web. It provides structure, conventions (API services, GraphQL, Prisma), and tooling that accelerate development.
- Node.js: The underlying JavaScript runtime environment for RedwoodJS's API side.
- Vonage Messages API: A powerful API for sending and receiving messages across various channels, including SMS. We use it for its flexibility and robust features.
@vonage/server-sdk
: The official Vonage Node.js SDK for interacting with the API.- Prisma: The default ORM in RedwoodJS, used for database schema definition, migrations, and data access.
- GraphQL: RedwoodJS's default API query language, used to expose the bulk sending functionality.
System Architecture:
graph LR
A[Web Client (e.g., React)] --> B{RedwoodJS API Side (GraphQL Mutation)};
B --> C[Vonage Messages API];
C --> D[User's Phone (SMS)];
B --> E[Database (Postgres)];
subgraph B [RedwoodJS API Side]
direction TB
B1[SMS Service]
B2[Prisma (DB Logging)]
B3[Vonage SDK]
end
Prerequisites:
- Node.js (v18 or later recommended) and Yarn installed.
- A Vonage API account (Sign up here).
- Basic understanding of RedwoodJS concepts (Workspaces, Services, SDL, Prisma).
- Access to a terminal or command prompt.
- A Vonage virtual phone number capable of sending SMS.
Final Outcome:
By the end of this guide, you will have a RedwoodJS API endpoint capable of accepting a list of phone numbers and a message body, sending an SMS to each number via Vonage, logging the attempt, and handling basic rate limiting considerations.
1. Setting up the Project
Let's start by creating a new RedwoodJS project and installing the necessary dependencies.
1.1 Create RedwoodJS Project:
Open your terminal and run:
# Replace 'redwood-bulk-sms' with your desired project name
yarn create redwood-app redwood-bulk-sms --typescript
cd redwood-bulk-sms
This command scaffolds a new RedwoodJS project with TypeScript enabled.
1.2 Install Vonage SDK:
Navigate to the API workspace and install the Vonage Node.js SDK:
yarn workspace api add @vonage/server-sdk
1.3 Configure Environment Variables:
Vonage requires several credentials. We'll store these securely in environment variables.
Create a .env
file in the root of your project (if it doesn't exist) and add the following variables. You'll obtain these values from your Vonage Dashboard.
# .env
# Obtain from Vonage Dashboard -> API Settings
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
# Required for Messages API -> Create an Application in the Dashboard
# Vonage Dashboard -> Applications -> Create a new application
# Enable ""Messages"" capability. You can leave webhook URLs blank for now if only sending.
# Generate public/private keys. Download the private key.
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
# Path to the private key file you downloaded when creating the Vonage Application.
# Place the private.key file somewhere accessible, e.g., in the project root or api/src/config
# IMPORTANT: Ensure this file path is correct relative to where the server runs.
# IMPORTANT: Add 'private.key' (or your key file name) to your .gitignore file!
VONAGE_PRIVATE_KEY_PATH=./private.key # Or specify the full path
# Your Vonage virtual number capable of sending SMS (in E.164 format)
VONAGE_FROM_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER
VONAGE_API_KEY
/VONAGE_API_SECRET
: Found directly on the main page of your Vonage Dashboard.VONAGE_APPLICATION_ID
: Go toApplications
>+ Create a new application
. Give it a name (e.g., ""Redwood Bulk Sender""). Enable theMessages
capability. ClickGenerate public and private key
and download theprivate.key
file. Save it (e.g., in the project root). The Application ID will be displayed on this page. You don't need to fill in webhook URLs if you are only sending messages initially.VONAGE_PRIVATE_KEY_PATH
: The relative or absolute path to theprivate.key
file you just downloaded. Crucially, addprivate.key
to your.gitignore
file to prevent committing secrets.VONAGE_FROM_NUMBER
: Go toNumbers
>Your numbers
. Copy one of your Vonage virtual numbers capable of sending SMS (usually listed as SMS/Voice). Use the E.164 format (e.g.,14155550100
).
1.4 Setup Prisma Database:
RedwoodJS uses Prisma. Let's configure a basic SQLite setup for simplicity (you can switch to PostgreSQL or others later).
Ensure your api/db/schema.prisma
file has a provider set up:
// api/db/schema.prisma
datasource db {
provider = ""sqlite"" // Or ""postgresql""
url = env(""DATABASE_URL"")
}
generator client {
provider = ""prisma-client-js""
binaryTargets = ""native""
}
// Add models below
Update your .env
file with the DATABASE_URL
:
# .env (add this line)
DATABASE_URL=""file:./dev.db"" # For SQLite
# Or for PostgreSQL: DATABASE_URL=""postgresql://user:password@host:port/database?schema=public""
2. Implementing Core Functionality (Redwood Service)
We'll create a RedwoodJS service to encapsulate the logic for interacting with Vonage.
2.1 Generate SMS Service:
Use the Redwood CLI to generate the service files:
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 add the following code:
// 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.' }
}
// **IMPORTANT**: Add phone number format validation here!
// Use a library like libphonenumber-js to ensure 'to' is in E.164 format
// before sending to Vonage. Example:
// import { PhoneNumberUtil, PhoneNumberFormat } from 'google-libphonenumber';
// const phoneUtil = PhoneNumberUtil.getInstance();
// try {
// const number = phoneUtil.parseAndKeepRawInput(to, ''); // Specify default region if needed
// if (!phoneUtil.isValidNumber(number)) {
// throw new Error('Invalid phone number format');
// }
// to = phoneUtil.format(number, PhoneNumberFormat.E164);
// } catch (e) {
// logger.error(`Invalid phone number format for ${to}: ${e.message}`);
// return { success: false, error: `Invalid phone number format: ${to}` };
// }
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
)
// CONSIDER: Depending on requirements, DB logging failure might be critical.
// Options: Retry logging, push to a dead-letter queue, trigger monitoring alerts.
}
}
// --- 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 Consideration: Rate Limiting**
// Vonage imposes rate limits (e.g., 1 SMS/sec for long codes, higher for toll-free/short codes in US/CA).
// Sending requests in a tight loop or rapid parallel execution WILL hit these limits quickly for large lists.
//
// **Production Approaches:**
// 1. **Throttling/Delay:** Introduce delays between API calls (e.g., using setTimeout or a library like `p-throttle`). Simpler but less robust for scaling and error handling.
// 2. **Queueing System:** Use a background job queue (e.g., BullMQ, Faktory, AWS SQS) to process messages individually, respecting rate limits within queue workers. This is the **recommended approach** for reliable bulk sending.
//
// **For this guide, we'll use Promise.allSettled for concurrency but without explicit throttling.**
// This demonstrates parallel requests but needs refinement (throttling or queues) for production scale.
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)
Explanation:
- Credentials: Securely loaded from
process.env
. - Vonage Client: Initialized using required credentials. A comment clarifies the roles of different credentials. A dedicated
Messages
instance is created. sendSingleSms
Helper: Encapsulates sending one message, now includes a comment placeholder for crucial E.164 validation, callsvonageMessages.send
, logs the outcome using Redwood's logger, callslogSmsAttempt
, and returns a structured result. Includes improved error message extraction.logSmsAttempt
Helper: Handles writing log entries to the database via Prisma, with error handling and a comment about potential critical failure handling. Includes improved error message extraction.sendBulkSms
:- Takes
recipients
andmessage
. - Rate Limiting Discussion: Critically highlights the issue and strongly recommends queues for production.
- Implementation: Uses
map
andPromise.allSettled
for concurrent execution.allSettled
ensures all attempts complete. Logging is now primarily handled withinsendSingleSms
. Includes improved error message extraction for rejected promises. - Collects results and returns a summary.
- Takes
3. Building the API Layer (GraphQL)
Now, let's expose the sendBulkSms
service function through Redwood's GraphQL API.
3.1 Generate SDL:
Use the Redwood CLI to generate the GraphQL Schema Definition Language file:
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:
# 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
}
`
Explanation:
SendBulkSmsInput
: Defines the input: recipient array and message string.SmsSendResult
: Defines the output structure for a single recipient's attempt.BulkSmsSummary
: Defines the overall response structure.Mutation.sendBulkSms
: Declares the mutation, taking input and returning the summary.@requireAuth
: Redwood directive for authentication. Requires Redwood Auth setup (see Redwood Auth Docs). Remove temporarily for testing if auth isn't configured, but reinstate for production.
3.3 Test with GraphQL Playground:
- Start dev server:
yarn rw dev
- Go 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
}
}
}
Check terminal logs and test phones. View the response in Playground.
4. Integrating with Vonage (Recap & Details)
Recap of the core integration setup:
- Credentials:
VONAGE_API_KEY
,VONAGE_API_SECRET
,VONAGE_APPLICATION_ID
,VONAGE_PRIVATE_KEY_PATH
,VONAGE_FROM_NUMBER
configured via.env
. - Vonage Dashboard:
- API Keys: Dashboard home page.
- Application:
Applications
>Create a new application
. Name it, enableMessages
, generate keys, downloadprivate.key
. Note theApplication ID
. Webhook URLs can be dummy HTTPS URLs (e.g.,https://example.com/status
) if only sending. - Number:
Numbers
>Your numbers
. Ensure the number forVONAGE_FROM_NUMBER
is SMS capable in the target country.
- Secure Storage: Keep
.env
andprivate.key
out of Git (.gitignore
). Use hosting provider's environment variable management for deployment. - Vonage Outages: For complete Vonage outages (rare), your application should ideally detect persistent failures and inform the user or initiating system. Integrating a secondary SMS provider as a backup adds significant complexity.
5. Error Handling, Logging, and Retry Mechanisms
Robustness requires solid error handling and logging.
5.1 Consistent Error Handling:
sendSingleSms
uses try...catch
for Vonage SDK errors, logging and returning { success: false, error: message }
. sendBulkSms
uses Promise.allSettled
to handle individual failures within the batch.
5.2 Logging:
Redwood's Pino logger (api/src/lib/logger.ts
) is used:
- Debug: Logs attempts (
logger.debug
). - Info: Logs successes and job summaries (
logger.info
). - Error: Logs failures and unexpected errors (
logger.error
).
Production Logging:
- Set appropriate log levels (e.g.,
info
). - Consider a centralized logging service (Datadog, Logtail, etc.).
5.3 Retry Mechanisms:
Handle temporary Vonage errors (network issues, rate limits).
-
Simple Retry (Illustrative Example): Could be added within
sendSingleSms
for specific errors.// Inside sendSingleSms, illustrative example (needs refinement) let attempts = 0; const maxAttempts = 3; const delayMs = 1000; // 1 second initial delay while (attempts < maxAttempts) { try { // ** IMPORTANT: Define isRetryable(error) ** // This function should check error properties (e.g._ status code from Vonage response) // to determine if the error is temporary (e.g._ 429_ 5xx). // function isRetryable(error: any): boolean { /* ... check error properties ... */ return false; } const resp = await vonageMessages.send(/* ... */); // Assuming logSmsAttempt is called outside the loop on final success/failure return { success: true_ messageId: resp.message_uuid }; // Success_ exit loop } catch (error) { attempts++; const isErrorRetryable = false; // Replace with actual call to isRetryable(error) // ** CRUCIAL: Only retry retryable errors! ** if (attempts >= maxAttempts || !isErrorRetryable) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' logger.error(`Final attempt ${attempts} failed for ${to}: ${errorMessage}`, error); // Log to DB should be handled outside the loop by the main try/catch throw error; // Re-throw the error to be caught by the outer catch block } // Calculate delay using exponential backoff const retryDelay = delayMs * Math.pow(2, attempts - 1); const errorMessage = error instanceof Error ? error.message : 'Unknown error' logger.warn(`Attempt ${attempts} failed for ${to}. Retrying in ${retryDelay}ms... Error: ${errorMessage}`); await new Promise(resolve => setTimeout(resolve, retryDelay)); } } // If loop finishes without success, the last error is thrown and caught outside
Note: This simple retry adds complexity directly within the request path. The
isRetryable(error)
logic is essential to avoid retrying permanent errors like invalid credentials. -
Queue-Based Retries (Recommended): Use a job queue (BullMQ, etc.). Leverage its built-in retry strategies (exponential backoff, max attempts). This is the standard, robust approach for background tasks like bulk sending.
Testing Errors:
- Use invalid numbers.
- Temporarily use bad credentials.
- Send large lists to trigger rate limits.
- Mock
vonageMessages.send
in unit tests to throw errors.
6. Database Schema and Data Layer (Logging)
Log attempts for tracking.
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])
}
(Added indexes for common query patterns and unique constraint on message ID)
6.2 Apply Migrations:
yarn rw prisma migrate dev --name add_sms_log
6.3 Implement Database Logging:
The logging logic is already integrated into sendSingleSms
and the logSmsAttempt
helper function shown in Section 2.2. Every attempt initiated by sendSingleSms
will now be recorded in the SmsLog
table.
7. Adding Security Features
Protect your API and data.
- Authentication:
@requireAuth
used on the mutation. Ensure Redwood Auth is properly configured. - Authorization: Implement role checks if needed (e.g., only 'admin' can send). Use
requireAuth({ roles: ['admin'] })
within the service or secure the service method. - Input Validation:
- Phone Numbers: Crucially, validate recipient numbers are E.164 before calling Vonage (see comment in
sendSingleSms
). Uselibphonenumber-js
. - Message Content: Sanitize if user-generated. Limit length.
- List Size: Consider limiting
recipients.length
per request.
- Phone Numbers: Crucially, validate recipient numbers are E.164 before calling Vonage (see comment in
- Rate Limiting (API Endpoint): Rate limit your own GraphQL endpoint per user/IP (e.g.,
graphql-rate-limit-directive
) to prevent abuse. - Secret Management: Never commit
.env
,private.key
. Use secure environment variables in deployment. - Consent (Compliance): Critical. Obtain explicit opt-in consent before sending non-transactional SMS. Provide easy opt-out (Vonage often handles STOP). Comply with TCPA, GDPR, etc.
8. Handling Special Cases
SMS nuances:
- Phone Number Formatting: Enforce E.164 (
+14155550100
). Validate rigorously. - Character Limits & Encoding: GSM-7 (160 chars) vs. UCS-2 (70 chars, for emojis/special chars). Longer messages are concatenated (multiple segments = multiple charges).
- International Sending: Ensure Vonage account/number enabled for destinations. Be aware of regulations/filtering.
- Opt-Outs (STOP): Vonage usually handles standard keywords (US/CA). Verify settings. Maintain suppression lists if needed.
- Time Zones: Send times are server-based. Consider recipient time zones for global sends (schedule appropriately).
9. Implementing Performance Optimizations
For high volume:
- Asynchronous Processing (Queues): Essential. Move
sendSingleSms
calls to a background queue (BullMQ, SQS, etc.).- API endpoint enqueues jobs.
- Workers process jobs, respecting rate limits (throttle within workers).
- Improves API response time, scalability, and error handling.
- Database Writes: Use
db.smsLog.createMany
if processing batches from a queue for better DB performance. - Vonage API Concurrency: Manage concurrency effectively via queue workers, respecting Vonage limits.
- Resource Usage: Monitor Node.js memory/CPU.
10. Monitoring, Observability, and Analytics
Understand system health:
- Logging: Centralized logging (Section 5).
- Error Tracking: Use Sentry, Bugsnag, etc.
- Metrics: Track messages sent (success/fail), job duration/queue time, Vonage API latency, queue depth, worker health (Prometheus/Grafana, Datadog).
- Health Checks: Basic
/health
endpoint (check DB, maybe Vonage connectivity). - Vonage Dashboard: Monitor delivery rates, errors, costs.
- Alerting: Alert on high error rates, high latency, large queue depth, critical application errors.
11. Troubleshooting and Caveats
Common problems:
- Vonage Rate Limits:
- Error:
429 Too Many Requests
, timeouts. - Solution: Implement throttling or (better) a queue system. Check Vonage docs for specific limits per number type/region.
- Caveat: US A2P 10DLC has specific registered throughput limits.
- Error:
- Invalid Credentials:
- Error:
401 Unauthorized
. - Solution: Double-check all
VONAGE_*
vars andprivate.key
path/readability.
- Error:
- Incorrect Number Formatting:
- Error: 'Non-Network Number', invalid 'to'.
- Solution: Validate E.164 format strictly before sending.
- Insufficient Funds:
- Error: Balance-related errors.
- Solution: Add Vonage credit.
- Blocked/Filtered Messages:
- Symptom: Sent OK by API, but not delivered.
- Solution: Check carrier filtering (spam), sender ID, country rules, content. Crucially, check A2P 10DLC registration for US traffic. Review Vonage delivery receipts/logs for specific error codes.
- A2P 10DLC Compliance (US): Sending Application-to-Person SMS via standard US long codes (10DLC) mandates registration of your Brand and Campaign via Vonage (or The Campaign Registry). Unregistered traffic faces severe filtering and potential blocking. This is a non-negotiable requirement for reliable US A2P SMS.
- Private Key Path: Ensure path is correct relative to the running process in deployment.
- SDK Version Issues: Check for breaking changes when updating
@vonage/server-sdk
.
12. Deployment and CI/CD
Deploying your application:
- Hosting: Choose provider (Vercel, Render, Netlify, AWS...). Follow Redwood deploy guides (see Redwood Deploy Docs).
- Environment Variables: Configure all secrets (
VONAGE_*
,DATABASE_URL
) securely in the hosting environment. Do not commit sensitive files/vars. Handleprivate.key
upload/storage securely per provider features. - Database: Provision production DB (Postgres recommended). Update
DATABASE_URL
. - CI/CD Pipeline (e.g., GitHub Actions):
- Checkout Code.
- Setup Node/Yarn.
- Install Deps:
yarn install --frozen-lockfile
. - Lint/Type Check:
yarn rw lint && yarn rw type-check
. - Test:
yarn rw test api
(mock external services like Vonage). - Build:
yarn rw build
. - Migrate DB:
yarn rw prisma migrate deploy
(requires secure DB access). - Deploy: Use provider CLI/integration.
- Rollback: Plan for rollbacks using provider features and Git tags.
13. Verification and Testing
Validate your implementation:
-
Unit Tests (
api/src/services/sms/sms.test.ts
):- Mock
@vonage/server-sdk
(usingjest.mock
orvi.mock
). - Test
sendSingleSms
: Verify calls tovonageMessages.send
anddb.smsLog.create
with correct args. Mock success/error from Vonage. Test validation logic. - Test
sendBulkSms
: Verify callssendSingleSms
per recipient. Test mixed results. Test empty list. Check summary object.
// Example Mocking Structure (Jest) import { sendBulkSms /* ... other imports */ } from './sms' import { db } from 'src/lib/db' // Import db to mock its methods // Mock dependencies // Note: This complex mocking structure might need adjustments based on exact SDK usage. const mockSend = jest.fn(); jest.mock('@vonage/server-sdk', () => { const MockMessages = jest.fn().mockImplementation(() => ({ send: mockSend })); const MockVonage = jest.fn().mockImplementation(() => ({ options: {}, // Provide necessary options if Messages constructor uses them })); return { Vonage: MockVonage, Messages: MockMessages, __esModule: true, // For ES modules compatibility }; }); jest.mock('src/lib/db', () => ({ db: { smsLog: { create: jest.fn().mockResolvedValue({}), }, }, })); jest.mock('src/lib/logger'); // Mock logger // Type assertion for the mocked function if needed, especially in TypeScript const mockSmsLogCreate = db.smsLog.create as jest.Mock; describe('sms service', () => { beforeEach(() => { // Reset mocks before each test jest.clearAllMocks(); mockSend.mockClear(); // Clear the Vonage send mock mockSmsLogCreate.mockClear(); // Clear the Prisma create mock }); it('sendBulkSms handles success and failure, calling mocks correctly', async () => { // Arrange: Mock successful and failed responses for send attempts mockSend .mockResolvedValueOnce({ message_uuid: 'uuid-1' }) // First call succeeds .mockRejectedValueOnce(new Error('Vonage API Error')); // Second call fails 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(mockSend).toHaveBeenNthCalledWith(1, expect.objectContaining({ to: '+11112223333', text: 'Test' })); expect(mockSend).toHaveBeenNthCalledWith(2, expect.objectContaining({ to: '+14445556666', text: 'Test' })); expect(mockSmsLogCreate).toHaveBeenCalledTimes(2); expect(mockSmsLogCreate).toHaveBeenNthCalledWith(1, { data: expect.objectContaining({ recipient: '+11112223333', status: 'SUCCESS', vonageMessageId: 'uuid-1' }) }); expect(mockSmsLogCreate).toHaveBeenNthCalledWith(2, { data: expect.objectContaining({ recipient: '+14445556666', status: 'FAILED', errorMessage: expect.stringContaining('Vonage API Error') }) }); }); // Add more tests: empty recipients, validation errors, DB logging errors, etc. });
- Mock
-
Integration Tests: Test the GraphQL mutation endpoint directly (using Redwood's test setup or tools like
supertest
). Requires careful handling of external API calls (mocking or using test credentials). -
End-to-End (E2E) Tests: Use tools like Cypress or Playwright to simulate user interaction if there's a UI. Requires Vonage test numbers and potentially a dedicated test environment.
-
Manual Testing: Send messages to real test phones (including your own) using valid credentials in a development or staging environment. Verify delivery and check logs. Test edge cases like invalid numbers.