Build SMS marketing campaigns and two-way messaging in your RedwoodJS application using the Vonage Messages API. Send outbound SMS messages for notifications or marketing campaigns and receive inbound messages via webhooks, leveraging RedwoodJS's full-stack architecture.
Complete this guide to build a RedwoodJS application that:
Sends SMS messages programmatically – Trigger notifications, 2FA codes, appointment reminders, or e-commerce order updates using the Vonage Node.js SDK
Receives incoming SMS messages – Handle two-way conversations through secure webhooks connected to your Vonage virtual number
Stores message logs in a database – Track all inbound and outbound messages using Prisma ORM
Manages credentials securely – Protect API keys and secrets using environment variables
Use this setup for alerts, two-factor authentication, marketing campaigns, customer support conversations, and user engagement.
Prerequisites:
Node.js: Version 20 or higher (check with node -v). Use nvm to manage Node versions.
Yarn: Yarn Classic (v1.x) (check with yarn -v).
Vonage API Account: Sign up free at Vonage API Dashboard. You'll need your API Key and Secret.
Vonage Virtual Number: Purchase an SMS-capable virtual number through the Vonage Dashboard.
ngrok: Expose your local development server to the internet for webhook testing. Download from ngrok.com.
Project Overview and Goals
Build a RedwoodJS application with these SMS components:
Component
Purpose
API Service
Encapsulates Vonage interactions on the API side
GraphQL Mutation
Triggers sending an SMS message
Webhook Handler
Receives incoming SMS messages from Vonage
Database Logging
Logs sent and received messages using Prisma
Technologies:
RedwoodJS – Full-stack JavaScript/TypeScript framework providing structure, tooling (GraphQL, Prisma, Jest), and conventions for rapid development
Vonage Messages API – Unified API for sending and receiving messages across various channels (focusing on SMS)
@vonage/server-sdk – Official Vonage Node.js SDK for API interactions
Prisma – RedwoodJS's default ORM for database operations
GraphQL – API communication layer between web and API sides in RedwoodJS
ngrok – Local webhook development and testing tool
System Architecture:
Text
[User] <--> [Redwood Web Frontend (React)] <--> [Redwood API (GraphQL)]
|
v
+------------------------------------------<-- [Vonage Service (Node.js)] --> [Vonage API] --> [SMS Network] --> [Recipient Phone]
| ^
| (Webhook Trigger) | (API Call)
v |
[Vonage Webhook Function (Node.js)]---------------------+
|
v
[Database (Prisma)]
How Do You Set Up a RedwoodJS Project for Vonage SMS?
Create a new RedwoodJS project and configure the necessary Vonage components.
1.1 Create RedwoodJS App:
Open your terminal and run the Create Redwood App command. Use TypeScript (default) and initialize a git repository.
Bash
# Choose a name for your project (e.g., redwood-vonage-sms)
yarn create redwood-app redwood-vonage-sms
# Follow the prompts:# - Select your preferred language: TypeScript (press Enter for default)# - Do you want to initialize a git repo?: yes (press Enter for default)# - Enter a commit message: Initial commit (press Enter for default)# - Do you want to run yarn install?: yes (press Enter for default)# Navigate into your new project directorycd redwood-vonage-sms
1.2 Environment Variables Setup:
Store API keys and sensitive information securely using .env files.
Create a .env file in the root of your project:
Bash
touch .env
Add these environment variables to your .env file (you'll populate these values in the next steps):
Text
# .env
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Path to the private key file
# Alternatively, provide the key content directly (useful for deployment)
# VONAGE_PRIVATE_KEY_CONTENT="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
VONAGE_VIRTUAL_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # e.g., 14155551212
Add .env and private.key to your .gitignore file to prevent committing secrets. The default RedwoodJS .gitignore already includes .env, but verify and add private.key.
Text
# .gitignore
# ... other entries
.env
private.key
*.private.key
1.3 Vonage Account Setup:
API Key & Secret: Log in to your Vonage API Dashboard. Find your API Key and Secret on the main page. Copy these into your .env file.
Virtual Number: Navigate to "Numbers" > "Buy numbers" to purchase an SMS-capable number. Copy this number in E.164 format (e.g., 14155551212) into VONAGE_VIRTUAL_NUMBER in your .env file.
Set Default SMS API: Go to Account Settings in the Dashboard. Under "API settings" > "SMS settings", set "Default SMS Setting" to Messages API. This configuration is essential for the @vonage/server-sdk to function correctly. Save the changes.
1.4 Create Vonage Application:
Vonage Applications group your numbers and configurations together.
In the Vonage Dashboard, navigate to "Applications" > "Create a new application".
Name it (e.g., "RedwoodJS SMS App").
Click "Generate public and private key". Immediately save the private.key file that downloads. Save it in your RedwoodJS project root (or update VONAGE_PRIVATE_KEY_PATH in .env if you save it elsewhere).
Enable the "Messages" capability.
Enter placeholder URLs for now (you'll update these with your ngrok URL during development):
Inbound URL:https://example.com/webhooks/inbound
Status URL:https://example.com/webhooks/status
Click "Generate new application".
Copy the Application ID into VONAGE_APPLICATION_ID in your .env file.
Link Your Number: Return to the "Applications" list, find your new application, and click "Link" under "Linked numbers". Select your Vonage virtual number and link it.
1.5 Install Vonage SDK:
Install the Vonage Node.js SDK in the api workspace.
Bash
yarn workspace api add @vonage/server-sdk
How Do You Implement SMS Sending with Vonage in RedwoodJS?
Create a RedwoodJS service to handle sending SMS messages via Vonage.
2.1 Create SMS Service:
Generate the service files using the RedwoodJS CLI.
Bash
yarn rw g service sms
This creates api/src/services/sms/sms.ts and related test/scenario files.
2.2 Configure Vonage Client:
Initialize the Vonage client centrally using a utility file for better code organization and health monitoring.
Create api/src/lib/vonage.ts:
TypeScript
// api/src/lib/vonage.tsimport { Vonage } from'@vonage/server-sdk'import { logger } from'src/lib/logger'import fs from'fs'import path from'path'letvonageInstance: Vonage | null = nullexportconst getVonageClient = (): Vonage => {
if (!vonageInstance) {
const apiKey = process.env.VONAGE_API_KEYconst apiSecret = process.env.VONAGE_API_SECRETconst applicationId = process.env.VONAGE_APPLICATION_IDconst privateKeyContent = process.env.VONAGE_PRIVATE_KEY_CONTENTconst privateKeyPath = process.env.VONAGE_PRIVATE_KEY_PATHif (!apiKey || !apiSecret || !applicationId) {
logger.error('Missing Vonage API Key, Secret, or Application ID in environment variables.')
thrownewError('Vonage client API credential configuration is incomplete.')
}
// Determine the private key source: prioritize content over pathletprivateKey: string | Bufferif (privateKeyContent) {
logger.info('Using Vonage private key from VONAGE_PRIVATE_KEY_CONTENT.')
privateKey = privateKeyContent.replace(/\\n/g, '\n')
} elseif (privateKeyPath) {
logger.info(`Using Vonage private key from path: ${privateKeyPath}`)
const absolutePath = path.resolve(process.cwd(), privateKeyPath)
if (!fs.existsSync(absolutePath)) {
logger.error(`Private key file not found at path: ${absolutePath}`)
thrownewError(`Vonage private key file not found at specified path: ${privateKeyPath}`)
}
privateKey = absolutePath
} else {
logger.error('Missing Vonage Private Key configuration (neither VONAGE_PRIVATE_KEY_CONTENT nor VONAGE_PRIVATE_KEY_PATH found).')
thrownewError('Vonage private key configuration is incomplete.')
}
try {
vonageInstance = newVonage({
apiKey,
apiSecret,
applicationId,
privateKey,
})
logger.info('Vonage client initialized successfully.')
} catch (error) {
logger.error({ error }, 'Failed to initialize Vonage client')
throw error
}
}
return vonageInstance
}
Why this approach works: The singleton pattern prevents re-initializing the client on every request. Centralized credential validation and error handling occur during initialization. The code prioritizes reading private key content from VONAGE_PRIVATE_KEY_CONTENT if available, falling back to VONAGE_PRIVATE_KEY_PATH. RedwoodJS's Pino logger provides structured logging.
2.3 Implement sendSms Function:
Edit the generated service file (api/src/services/sms/sms.ts) to add the sending logic.
TypeScript
// api/src/services/sms/sms.tsimporttype { MutationResolvers } from'types/graphql'import { logger } from'src/lib/logger'import { getVonageClient } from'src/lib/vonage'// Import db later when we add logging// import { db } from 'src/lib/db'interfaceSendSmsInput {
to: stringtext: string
}
exportconst sendSms = async ({ to, text }: SendSmsInput): Promise<{ success: boolean; messageId?: string; error?: string }> => {
logger.info({ to, textLength: text.length }, 'Attempting to send SMS')
const vonage = getVonageClient()
const fromNumber = process.env.VONAGE_VIRTUAL_NUMBERif (!fromNumber) {
logger.error('VONAGE_VIRTUAL_NUMBER is not set in environment variables.')
return { success: false, error: 'Vonage sender number not configured.' }
}
// Basic validation (implement robust validation for production)if (!to || !text) {
logger.warn('Missing recipient (to) or text content.')
return { success: false, error: 'Recipient phone number and text message are required.' }
}
// E.164 format validation: ITU-T E.164 standard requires max 15 digits with + prefix// Example regex: /^\+?[1-9]\d{1,14}$/ validates: optional +, country code (1-3 digits), subscriber number// For production, use libphonenumber-js library for robust international validationtry {
const resp = await vonage.messages.send({
message_type: 'text',
to: to,
from: fromNumber,
channel: 'sms',
text: text,
})
logger.info({ messageId: resp.message_uuid }, 'SMS sent successfully via Vonage')
// --- TODO: Add database logging here (Section 6) ---return { success: true, messageId: resp.message_uuid }
} catch (error) {
logger.error({ error, to }, 'Failed to send SMS via Vonage')
const errorMessage = error.response?.data?.title || error.message || 'Unknown error sending SMS.'return { success: false, error: errorMessage }
}
}
// Note: Define the GraphQL Mutation in the SDL file next.// RedwoodJS automatically picks up the resolver function// based on the service function name matching the mutation name.
Why this works: This service function encapsulates SMS sending logic. It retrieves the Vonage client, validates input, constructs the Vonage Messages API payload, calls the send method, and handles errors using try...catch. It returns a structured response indicating success or failure. Logging provides process visibility.
How Do You Build a GraphQL Mutation for SMS in RedwoodJS?
Expose your sendSms service function through a GraphQL mutation.
3.1 Define GraphQL Schema (SDL):
Edit the schema definition file api/src/graphql/sms.sdl.ts.
TypeScript
// api/src/graphql/sms.sdl.tsexportconst schema = gql`type SmsResponse {success: Boolean!messageId: String
error: String
}typeMutation{"""Sends an SMS message using Vonage."""
sendSms(to: String!, text: String!): SmsResponse!@skipAuth# Use @requireAuth in production!}
`
Schema structure:
SmsResponse: Specifies the fields returned by the mutation.
Mutation: Defines available mutations. sendSms takes to and text as non-nullable String arguments and returns an SmsResponse.
@skipAuth: Development only. Disables authentication. Replace with @requireAuth in production to ensure only authenticated users trigger the mutation.
3.2 Test the Mutation:
Start the development server:
Bash
yarn rw dev
Open your browser to the RedwoodJS GraphQL Playground: http://localhost:8911/graphql.
Enter this mutation in the left panel (replace YOUR_REAL_PHONE_NUMBER with your actual phone number in E.164 format):
GraphQL
mutation SendTestSms {
sendSms(to:"YOUR_REAL_PHONE_NUMBER", text:"Hello from RedwoodJS and Vonage!"){
success
messageId
error
}}
Click the "Play" button.
You should receive an SMS on your phone, and the GraphQL response should look like:
If you get success: false, check the error message and review the terminal output where yarn rw dev is running for logs from api/src/services/sms/sms.ts. Common issues include incorrect API credentials, wrong phone number formats, or the private key file not being found.
How Do You Receive SMS Messages via Webhooks in RedwoodJS?
Configure Vonage to send incoming SMS messages to your application via webhooks.
4.1 Expose Localhost with ngrok:
Vonage can't reach your local RedwoodJS app directly. Create a secure tunnel using ngrok.
Stop yarn rw dev if running (Ctrl+C).
Start ngrok to forward to Redwood's API port (8911):
Bash
# Ensure you've configured your ngrok authtoken
ngrok http 8911
Copy the HTTPS "Forwarding" URL from ngrok's output (e.g., https://<unique-id>.ngrok-free.app).
4.2 Update Vonage Application Webhook URLs:
Open your Vonage Application settings in the Dashboard ("Applications" > Your App Name > Edit).
Update the Messages capability URLs:
Inbound URL:YOUR_NGROK_HTTPS_URL/webhooks/inbound
Status URL:YOUR_NGROK_HTTPS_URL/webhooks/status
(Replace YOUR_NGROK_HTTPS_URL with your ngrok URL).
Set the method for both to POST.
Save the changes.
4.3 Create RedwoodJS Webhook Handler:
Generate a RedwoodJS function for webhook handling:
Bash
yarn rw g function vonageWebhook
This creates api/src/functions/vonageWebhook.ts.
4.4 Implement Webhook Logic:
Modify api/src/functions/vonageWebhook.ts to handle incoming messages and status updates, supporting both JSON and form-urlencoded payloads.
TypeScript
// api/src/functions/vonageWebhook.tsimporttype { APIGatewayEvent, Context } from'aws-lambda'import { logger } from'src/lib/logger'import querystring from'node:querystring'// Import db later when we add logging// import { db } from 'src/lib/db'/**
* Parses request body based on Content-Type header.
* Handles JSON and form-urlencoded data.
*/const parseRequestBody = (event: APIGatewayEvent): Record<string, any> => {
const contentType = event.headers['content-type'] || event.headers['Content-Type'] || ''const body = event.body || ''if (!body) {
return {}
}
try {
if (contentType.includes('application/json')) {
returnJSON.parse(body)
} elseif (contentType.includes('application/x-www-form-urlencoded')) {
return querystring.parse(body)
} else {
logger.warn(`Unexpected Content-Type: ${contentType}. Attempting JSON parse.`)
returnJSON.parse(body)
}
} catch (error) {
logger.error({ error, body, contentType }, 'Failed to parse webhook request body')
return {}
}
}
/**
* Serverless function execution entry point.
* Provides access to request context: headers, path, query parameters, and body.
*
* @see https://redwoodjs.com/docs/functions
*/exportconsthandler = async (event: APIGatewayEvent, _context: Context) => {
logger.info({ path: event.path }, 'Received request on Vonage webhook handler')
// Vonage expects a 200 OK response quickly to avoid retriestry {
const body = parseRequestBody(event)
logger.debug({ body }, 'Webhook payload received and parsed')
if (Object.keys(body).length === 0 && event.body) {
logger.error('Webhook body parsing resulted in empty object, check parseRequestBody logic and logs.')
// Consider returning 400 Bad Request if parsing is essential// return { statusCode: 400, body: JSON.stringify({ error: 'Invalid request body' }) }
}
// Differentiate based on the path Vonage hitsif (event.path.endsWith('/webhooks/inbound')) {
// --- Handle Incoming SMS Message ---
logger.info('Processing inbound SMS webhook')
const { msisdn, to, messageId, text, type, 'message-timestamp': timestamp } = body
if (type === 'text' && msisdn && to && messageId && text) {
logger.info(
{ from: msisdn, to, messageId, textLength: text.length },
'Received valid inbound SMS'
)
// --- TODO: Add database logging here (Section 6) ---// --- TODO: Add business logic here (e.g., auto-reply, trigger action) ---
} else {
logger.warn({ body }, 'Received inbound webhook with unexpected format or missing fields')
}
} elseif (event.path.endsWith('/webhooks/status')) {
// --- Handle Message Status Update ---
logger.info('Processing message status webhook')
const { message_uuid, status, timestamp, to, from, error } = body
if (message_uuid) {
logger.info({ message_uuid, status, to }, 'Received message status update')
// --- TODO: Update message status in the database (Section 6) ---if (error) {
logger.error({ message_uuid, error }, 'Message delivery failed')
}
} else {
logger.warn({ body }, 'Received status webhook without message_uuid')
}
} else {
logger.warn(`Webhook called on unexpected path: ${event.path}`)
}
// Always return 200 OK to Vonage to prevent retriesreturn {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: 'Webhook received successfully' }),
}
} catch (error) {
logger.error({ error, requestBody: event.body }, 'Error processing Vonage webhook')
// Still return 200 to Vonage unless absolutely necessary to trigger retriesreturn {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ error: 'Failed to process webhook' }),
}
}
}
Why this works: This function serves as the Vonage endpoint. The parseRequestBody helper handles both application/json and application/x-www-form-urlencoded content types for robustness. The code uses event.path to determine whether it's an inbound message or status update, parses the payload, logs relevant information, and returns a 200 OK status promptly to acknowledge receipt. Error handling logs issues without causing Vonage to retry endlessly.
4.5 Test Receiving SMS:
Ensure ngrok is running and pointing to port 8911.
Start the RedwoodJS dev server:yarn rw dev.
Send an SMS from your phone to your Vonage virtual number.
Check the logs: Look at the terminal running yarn rw dev. You should see log entries from api/src/functions/vonageWebhook.ts indicating an "inbound SMS" was received, with details like sender number (msisdn) and message text. Check for parsing warnings or errors.
Check ngrok console: Inspect request details in the ngrok web interface (http://127.0.0.1:4040), including the Content-Type header Vonage sent.
How Do You Implement Error Handling and Logging for SMS?
Error Handling Strategy:
Component
Implementation
Purpose
sendSms service
try...catch blocks catch Vonage SDK errors
Returns { success: false, error: '...' } object
Webhook handler
try...catch with graceful degradation
Returns 200 OK to Vonage while logging errors internally
Body parsing
Handles JSON and form-urlencoded formats
Prevents parsing failures from breaking webhook reception
Future enhancements
Distinguish network errors from API errors
Implement specific recovery strategies per error type
For production, configure log levels and integrate with monitoring services (Datadog, Logflare, CloudWatch).
Retry Mechanisms:
Sending: The current sendSms doesn't implement retries. For critical messages, wrap the vonage.messages.send call in a retry loop with exponential backoff using libraries like async-retry or p-retry for transient network or API errors.
Receiving: Vonage retries automatically if your webhook endpoint doesn't return 200 OK within a few seconds. The handler returns 200 quickly, even if background processing fails, to prevent unnecessary retries.
Testing Error Scenarios:
Force an error by providing an invalid to number format or temporarily changing the API key in .env to something invalid. Trigger the sendSms mutation and observe the logged error and { success: false, ... } response.
How Do You Create a Database Schema for SMS Logging?
Log sent and received messages to the database using Prisma.
6.1 Define Prisma Schema:
Edit api/db/schema.prisma and add a SmsLog model.
Prisma
// api/db/schema.prisma
datasource db {
provider = "sqlite" // Or "postgresql", "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = "native"
}
// Define your database tables here.
// See https://redwoodjs.com/docs/schema-migrations for more info.
model SmsLog {
id String @id @default(cuid())
direction String // "OUTBOUND" or "INBOUND"
vonageId String @unique // message_uuid from Vonage, or a generated unique ID for failures
fromNumber String
toNumber String
body String? // The message text
status String? // e.g., "SUBMITTED", "DELIVERED", "FAILED", "RECEIVED"
vonageStatus String? // Raw status from Vonage status webhook
error String? // Error message if sending/processing failed
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Schema design: This table stores essential information about each SMS message: direction, unique Vonage ID, sender/recipient, content, status, and timestamps. The vonageId is unique to prevent duplicate entries from retried webhooks, using CUID as a fallback if the Vonage ID isn't obtained.
6.2 Create and Run Migration:
Apply the schema changes to your database.
Bash
# Create a new migration file based on schema changes
yarn rw prisma migrate dev --name add-sms-log
# This command also applies the migration
6.3 Update Service and Webhook to Log Data:
Modify the sendSms service and the vonageWebhook function to interact with the database. Add the cuid package if needed: yarn workspace api add cuid.
api/src/services/sms/sms.ts (sendSms):
TypeScript
// api/src/services/sms/sms.tsimporttype { MutationResolvers } from'types/graphql'import { logger } from'src/lib/logger'import { getVonageClient } from'src/lib/vonage'import { db } from'src/lib/db'import cuid from'cuid'interfaceSendSmsInput {
to: stringtext: string
}
exportconst sendSms = async ({ to, text }: SendSmsInput): Promise<{ success: boolean; messageId?: string; error?: string }> => {
logger.info({ to, textLength: text.length }, 'Attempting to send SMS')
const vonage = getVonageClient()
const fromNumber = process.env.VONAGE_VIRTUAL_NUMBERif (!fromNumber) {
logger.error('VONAGE_VIRTUAL_NUMBER is not set in environment variables.')
return { success: false, error: 'Vonage sender number not configured.' }
}
if (!to || !text) {
logger.warn('Missing recipient (to) or text content.')
return { success: false, error: 'Recipient phone number and text message are required.' }
}
letmessageId: string | undefined = undefined;
let success = false;
leterrorMessage: string | undefined = undefined;
const placeholderId = `failed-${cuid()}`try {
const resp = await vonage.messages.send({
message_type: 'text',
to: to,
from: fromNumber,
channel: 'sms',
text: text,
});
messageId = resp.message_uuid;
success = true;
logger.info({ messageId }, 'SMS sent successfully via Vonage');
} catch (error) {
logger.error({ error, to }, 'Failed to send SMS via Vonage');
errorMessage = error.response?.data?.title || error.message || 'Unknown error sending SMS.';
success = false;
}
// --- Log to Database ---const finalVonageId = messageId || placeholderId;
try {
await db.smsLog.create({
data: {
direction: 'OUTBOUND',
vonageId: finalVonageId,
fromNumber: fromNumber,
toNumber: to,
body: text,
status: success ? 'SUBMITTED' : 'FAILED',
error: errorMessage,
},
});
logger.debug({ vonageId: finalVonageId }, 'Logged outbound SMS attempt to database');
} catch (dbError) {
logger.error({ dbError, vonageId: finalVonageId }, 'Failed to log outbound SMS to database');
}
// --- End Log to Database ---return { success, messageId, error: errorMessage };
}
api/src/functions/vonageWebhook.ts (handler):
TypeScript
// api/src/functions/vonageWebhook.tsimporttype { APIGatewayEvent, Context } from'aws-lambda'import { logger } from'src/lib/logger'import { db } from'src/lib/db'import querystring from'node:querystring'const parseRequestBody = (event: APIGatewayEvent): Record<string, any> => {
const contentType = event.headers['content-type'] || event.headers['Content-Type'] || ''const body = event.body || ''if (!body) {
return {}
}
try {
if (contentType.includes('application/json')) {
returnJSON.parse(body)
} elseif (contentType.includes('application/x-www-form-urlencoded')) {
return querystring.parse(body)
} else {
logger.warn(`Unexpected Content-Type: ${contentType}. Attempting JSON parse.`)
returnJSON.parse(body)
}
} catch (error) {
logger.error({ error, body, contentType }, 'Failed to parse webhook request body')
return {}
}
}
exportconsthandler = async (event: APIGatewayEvent, _context: Context) => {
logger.info({ path: event.path }, 'Received request on Vonage webhook handler')
try {
const body = parseRequestBody(event)
logger.debug({ body }, 'Webhook payload received and parsed')
if (Object.keys(body).length === 0 && event.body) {
logger.error('Webhook body parsing resulted in empty object, check parseRequestBody logic and logs.')
}
if (event.path.endsWith('/webhooks/inbound')) {
logger.info('Processing inbound SMS webhook')
const { msisdn, to, messageId, text, type } = body
if (type === 'text' && msisdn && to && messageId && text) {
logger.info({ from: msisdn, to, messageId, textLength: text.length }, 'Received valid inbound SMS')
try {
await db.smsLog.create({
data: {
direction: 'INBOUND',
vonageId: messageId,
fromNumber: msisdn,
toNumber: to,
body: text,
status: 'RECEIVED',
},
})
logger.debug({ vonageId: messageId }, 'Logged inbound SMS to database')
} catch (dbError) {
// Handle potential duplicate messageId errors if Vonage retriesif (dbError.code === 'P2002' && dbError.meta?.target?.includes('vonageId')) {
logger.warn({ vonageId: messageId }, 'Attempted to log duplicate inbound SMS (vonageId exists). Ignoring.')
} else {
logger.error({ dbError, vonageId: messageId }, 'Failed to log inbound SMS to database')
}
}
} else {
logger.warn({ body }, 'Received inbound webhook with unexpected format or missing fields')
}
} elseif (event.path.endsWith('/webhooks/status')) {
logger.info('Processing message status webhook')
const { message_uuid, status, timestamp, to, from, error } = body
if (message_uuid) {
logger.info({ message_uuid, status, to }, 'Received message status update')
try {
await db.smsLog.update({
where: { vonageId: message_uuid },
data: {
status: status?.toUpperCase(),
vonageStatus: status,
error: error ? JSON.stringify(error) : undefined,
updatedAt: timestamp ? newDate(timestamp) : newDate(),
},
})
logger.debug({ vonageId: message_uuid, status }, 'Updated SMS status in database')
} catch (dbError) {
logger.error({ dbError, vonageId: message_uuid }, 'Failed to update SMS status in database')
}
if (error) {
logger.error({ message_uuid, error }, 'Message delivery failed according to status update')
}
} else {
logger.warn({ body }, 'Received status webhook without message_uuid')
}
} else {
logger.warn(`Webhook called on unexpected path: ${event.path}`)
}
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Webhook received successfully' }),
}
} catch (error) {
logger.error({ error, requestBody: event.body }, 'Error processing Vonage webhook')
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Failed to process webhook' }),
}
}
}
Database integration: The sendSms service logs each attempt to the SmsLog table, recording initial submission or failure. The vonageWebhook handler logs incoming messages (INBOUND) and updates outbound message statuses based on status webhooks. The code uses update for status changes and create for inbound messages with duplicate checking to handle message states correctly, including Vonage retries.
Frequently Asked Questions About Vonage SMS in RedwoodJS
What Node.js version does RedwoodJS require for Vonage integration?
RedwoodJS requires Node.js version 20 or higher as of 2025. Check your version with node -v and use nvm (Node Version Manager) to switch versions if needed. Vonage's @vonage/server-sdk works seamlessly with Node.js 20 LTS. Avoid Node.js 21+ for production deployments – it may cause compatibility issues with deployment targets like AWS Lambda.
How do I get Vonage API credentials for RedwoodJS?
Log into the Vonage API Dashboard and locate your API Key and Secret on the main page. Create a new Application under "Applications" > "Create a new application", enable the Messages capability, and generate a private key file. Download the private.key file immediately and store it in your project root. Copy the Application ID into your .env file. Purchase an SMS-capable virtual number from "Numbers" > "Buy numbers" and link it to your application.
What is E.164 phone number format and why does Vonage require it?
E.164 is the ITU-T international telephone numbering standard ensuring globally unique phone numbers. The format starts with a + sign, followed by the country code (1-3 digits) and subscriber number, with a maximum of 15 digits total (e.g., +14155551234). Vonage requires E.164 format to eliminate routing ambiguity across international SMS networks. Use the regex pattern /^\+?[1-9]\d{1,14}$/ for basic validation, or implement libphonenumber-js for production-grade validation.
How do I secure Vonage webhooks in RedwoodJS?
Secure Vonage webhooks in RedwoodJS functions by:
Validating webhook signatures – Check the Authorization header using Vonage's JWT verification
Implementing IP whitelists – Restrict access to Vonage's webhook IP addresses
Using HTTPS endpoints only – Required by Vonage for security
Messages exceeding these limits split into multiple segments automatically, with each segment consuming additional credits. Special characters like | ^ € { } [ ] ~ \ require escape codes in GSM-7, consuming two character positions each.
How do I implement SMS marketing campaigns with rate limiting in RedwoodJS?
Create a batch processing service that:
Reads recipient lists from your Prisma database
Sends messages with controlled rate limiting
Implements exponential backoff using p-retry or async-retry to handle Vonage API rate limits (typically 10-20 messages per second)
Stores campaign status in your database to track delivery states
Uses Vonage's status webhooks to update delivery receipts and handle failures
Always comply with TCPA (US), GDPR (Europe), and local SMS marketing regulations requiring user opt-in.
Can I send bulk SMS messages to multiple recipients with this RedwoodJS setup?
Yes, extend the sendSms service to accept an array of recipients and implement batch processing with rate limiting. Create a GraphQL mutation that queues messages in your database, then use RedwoodJS background jobs (via @redwoodjs/jobs) to process the queue asynchronously. This prevents timeout issues with large campaigns. Monitor Vonage API rate limits and implement exponential backoff for failed messages. Track campaign progress using Prisma database queries and provide real-time updates via GraphQL subscriptions.
How do I handle SMS delivery failures and retries in production?
Vonage sends delivery status updates to your status webhook endpoint. Implement this workflow:
Receive status webhooks – Update the SmsLog database record with status (DELIVERED, FAILED, etc.)
Queue failed messages – Store failures in a separate queue for retry processing
Limit retry attempts – Cap total retries at 3-5 attempts to prevent infinite loops
Log all failures – Include error details from Vonage's response for debugging
Implement dead letter queues – Route permanently failed messages for manual review
What security best practices should I follow for Vonage credentials in RedwoodJS?
Follow these security practices:
Practice
Implementation
Version control
Never commit .env or private.key files – add to .gitignore
Environment variables
Store all sensitive credentials (API Key, Secret, Application ID, Private Key) as env vars
Production secrets
Use AWS Secrets Manager, HashiCorp Vault, or Vercel Environment Variables
Credential rotation
Rotate credentials every 90 days
API usage auditing
Monitor usage through the Vonage Dashboard
File permissions
Set to 600 for private key files (chmod 600 private.key)
Containerized deployments
Use VONAGE_PRIVATE_KEY_CONTENT environment variable to avoid file system dependencies
Frequently asked questions
You can send SMS messages by creating a RedwoodJS service that uses the Vonage Node.js SDK. This service interacts with the Vonage Messages API to send messages programmatically, ideal for notifications or marketing campaigns within your RedwoodJS application.
The Vonage Messages API is a unified API that allows developers to send and receive messages across multiple channels, including SMS. This guide focuses on using the API for SMS communication within a RedwoodJS application.
RedwoodJS uses GraphQL for its API layer to provide a structured and efficient way to communicate between the front-end and back-end. This facilitates data fetching and mutations, like sending an SMS message, using a strongly typed schema.
ngrok is essential during local development with Vonage webhooks. Because your local RedwoodJS server isn't publicly accessible, ngrok creates a secure tunnel to expose it, allowing Vonage to send webhook data to your application for testing incoming SMS messages.
Yes, a free ngrok account is sufficient for development and testing purposes. It provides the necessary functionality to create a temporary public URL for your local server, enabling Vonage webhook integration.
You can receive SMS messages by setting up a webhook handler in your RedwoodJS application. Vonage will send incoming message data to this webhook, which you can then process and store using a RedwoodJS function and Prisma.
A Vonage Application ID is a unique identifier for your Vonage application settings and configurations. It groups your Vonage numbers and API settings, enabling you to manage your SMS integrations effectively. You need this to initialize the Vonage Node.js SDK.
You need Node.js version 20 or higher for this integration. The recommendation is to use NVM (Node Version Manager) to effectively manage and switch between Node.js versions as needed for different projects.
SMS message logs are stored using Prisma, RedwoodJS's default ORM. You define a schema in schema.prisma to structure your data and then use Prisma Client in your service and serverless functions to interact with the database.
While not explicitly covered in this guide, securing webhooks is crucial in production. Consider verifying the webhook signature using the Vonage SDK to ensure requests are genuinely from Vonage and haven't been tampered with. Never expose your webhook secrets publicly.
Setting the Default SMS API to "Messages API" in your Vonage Dashboard is crucial for correct integration with the '@vonage/server-sdk'. It ensures that incoming SMS messages are routed through the correct API and are processed as expected by your RedwoodJS application.
RedwoodJS uses .env files for managing environment variables, including sensitive API keys. Add your .env file and your private.key to your .gitignore file to prevent accidentally committing these credentials to your repository.
You'll need Node.js 20+, Yarn Classic (v1.x), a Vonage API account (with API Key and Secret), a Vonage virtual number, and ngrok for local testing.
The private.key file contains your Vonage Application's private key, crucial for authenticating your application with the Vonage API securely. Save this file securely and never expose it publicly or commit it to version control.
Build SMS marketing campaigns and two-way messaging in your RedwoodJS application using the Vonage Messages API. Send outbound SMS messages for notifications or marketing campaigns and receive inbound messages via webhooks, leveraging RedwoodJS's full-stack architecture.
Complete this guide to build a RedwoodJS application that:
Use this setup for alerts, two-factor authentication, marketing campaigns, customer support conversations, and user engagement.
Prerequisites:
node -v). Use nvm to manage Node versions.yarn -v).Project Overview and Goals
Build a RedwoodJS application with these SMS components:
Technologies:
@vonage/server-sdk– Official Vonage Node.js SDK for API interactionsSystem Architecture:
How Do You Set Up a RedwoodJS Project for Vonage SMS?
Create a new RedwoodJS project and configure the necessary Vonage components.
1.1 Create RedwoodJS App:
Open your terminal and run the Create Redwood App command. Use TypeScript (default) and initialize a git repository.
# Choose a name for your project (e.g., redwood-vonage-sms) yarn create redwood-app redwood-vonage-sms # Follow the prompts: # - Select your preferred language: TypeScript (press Enter for default) # - Do you want to initialize a git repo?: yes (press Enter for default) # - Enter a commit message: Initial commit (press Enter for default) # - Do you want to run yarn install?: yes (press Enter for default) # Navigate into your new project directory cd redwood-vonage-sms1.2 Environment Variables Setup:
Store API keys and sensitive information securely using
.envfiles.Create a
.envfile in the root of your project:touch .envAdd these environment variables to your
.envfile (you'll populate these values in the next steps):Add
.envandprivate.keyto your.gitignorefile to prevent committing secrets. The default RedwoodJS.gitignorealready includes.env, but verify and addprivate.key.1.3 Vonage Account Setup:
.envfile.14155551212) intoVONAGE_VIRTUAL_NUMBERin your.envfile.@vonage/server-sdkto function correctly. Save the changes.1.4 Create Vonage Application:
Vonage Applications group your numbers and configurations together.
private.keyfile that downloads. Save it in your RedwoodJS project root (or updateVONAGE_PRIVATE_KEY_PATHin.envif you save it elsewhere).https://example.com/webhooks/inboundhttps://example.com/webhooks/statusVONAGE_APPLICATION_IDin your.envfile.1.5 Install Vonage SDK:
Install the Vonage Node.js SDK in the
apiworkspace.How Do You Implement SMS Sending with Vonage in RedwoodJS?
Create a RedwoodJS service to handle sending SMS messages via Vonage.
2.1 Create SMS Service:
Generate the service files using the RedwoodJS CLI.
This creates
api/src/services/sms/sms.tsand related test/scenario files.2.2 Configure Vonage Client:
Initialize the Vonage client centrally using a utility file for better code organization and health monitoring.
Create
api/src/lib/vonage.ts:// api/src/lib/vonage.ts import { Vonage } from '@vonage/server-sdk' import { logger } from 'src/lib/logger' import fs from 'fs' import path from 'path' let vonageInstance: Vonage | null = null export const getVonageClient = (): Vonage => { if (!vonageInstance) { const apiKey = process.env.VONAGE_API_KEY const apiSecret = process.env.VONAGE_API_SECRET const applicationId = process.env.VONAGE_APPLICATION_ID const privateKeyContent = process.env.VONAGE_PRIVATE_KEY_CONTENT const privateKeyPath = process.env.VONAGE_PRIVATE_KEY_PATH if (!apiKey || !apiSecret || !applicationId) { logger.error('Missing Vonage API Key, Secret, or Application ID in environment variables.') throw new Error('Vonage client API credential configuration is incomplete.') } // Determine the private key source: prioritize content over path let privateKey: string | Buffer if (privateKeyContent) { logger.info('Using Vonage private key from VONAGE_PRIVATE_KEY_CONTENT.') privateKey = privateKeyContent.replace(/\\n/g, '\n') } else if (privateKeyPath) { logger.info(`Using Vonage private key from path: ${privateKeyPath}`) const absolutePath = path.resolve(process.cwd(), privateKeyPath) if (!fs.existsSync(absolutePath)) { logger.error(`Private key file not found at path: ${absolutePath}`) throw new Error(`Vonage private key file not found at specified path: ${privateKeyPath}`) } privateKey = absolutePath } else { logger.error('Missing Vonage Private Key configuration (neither VONAGE_PRIVATE_KEY_CONTENT nor VONAGE_PRIVATE_KEY_PATH found).') throw new Error('Vonage private key configuration is incomplete.') } try { vonageInstance = new Vonage({ apiKey, apiSecret, applicationId, privateKey, }) logger.info('Vonage client initialized successfully.') } catch (error) { logger.error({ error }, 'Failed to initialize Vonage client') throw error } } return vonageInstance }VONAGE_PRIVATE_KEY_CONTENTif available, falling back toVONAGE_PRIVATE_KEY_PATH. RedwoodJS's Pino logger provides structured logging.2.3 Implement
sendSmsFunction:Edit the generated service file (
api/src/services/sms/sms.ts) to add the sending logic.// api/src/services/sms/sms.ts import type { MutationResolvers } from 'types/graphql' import { logger } from 'src/lib/logger' import { getVonageClient } from 'src/lib/vonage' // Import db later when we add logging // import { db } from 'src/lib/db' interface SendSmsInput { to: string text: string } export const sendSms = async ({ to, text }: SendSmsInput): Promise<{ success: boolean; messageId?: string; error?: string }> => { logger.info({ to, textLength: text.length }, 'Attempting to send SMS') const vonage = getVonageClient() const fromNumber = process.env.VONAGE_VIRTUAL_NUMBER if (!fromNumber) { logger.error('VONAGE_VIRTUAL_NUMBER is not set in environment variables.') return { success: false, error: 'Vonage sender number not configured.' } } // Basic validation (implement robust validation for production) if (!to || !text) { logger.warn('Missing recipient (to) or text content.') return { success: false, error: 'Recipient phone number and text message are required.' } } // E.164 format validation: ITU-T E.164 standard requires max 15 digits with + prefix // Example regex: /^\+?[1-9]\d{1,14}$/ validates: optional +, country code (1-3 digits), subscriber number // For production, use libphonenumber-js library for robust international validation try { const resp = await vonage.messages.send({ message_type: 'text', to: to, from: fromNumber, channel: 'sms', text: text, }) logger.info({ messageId: resp.message_uuid }, 'SMS sent successfully via Vonage') // --- TODO: Add database logging here (Section 6) --- return { success: true, messageId: resp.message_uuid } } catch (error) { logger.error({ error, to }, 'Failed to send SMS via Vonage') const errorMessage = error.response?.data?.title || error.message || 'Unknown error sending SMS.' return { success: false, error: errorMessage } } } // Note: Define the GraphQL Mutation in the SDL file next. // RedwoodJS automatically picks up the resolver function // based on the service function name matching the mutation name.sendmethod, and handles errors usingtry...catch. It returns a structured response indicating success or failure. Logging provides process visibility.How Do You Build a GraphQL Mutation for SMS in RedwoodJS?
Expose your
sendSmsservice function through a GraphQL mutation.3.1 Define GraphQL Schema (SDL):
Edit the schema definition file
api/src/graphql/sms.sdl.ts.// api/src/graphql/sms.sdl.ts export const schema = gql` type SmsResponse { success: Boolean! messageId: String error: String } type Mutation { """Sends an SMS message using Vonage.""" sendSms(to: String!, text: String!): SmsResponse! @skipAuth # Use @requireAuth in production! } `SmsResponse: Specifies the fields returned by the mutation.Mutation: Defines available mutations.sendSmstakestoandtextas non-nullable String arguments and returns anSmsResponse.@skipAuth: Development only. Disables authentication. Replace with@requireAuthin production to ensure only authenticated users trigger the mutation.3.2 Test the Mutation:
Start the development server:
Open your browser to the RedwoodJS GraphQL Playground:
http://localhost:8911/graphql.Enter this mutation in the left panel (replace
YOUR_REAL_PHONE_NUMBERwith your actual phone number in E.164 format):mutation SendTestSms { sendSms(to: "YOUR_REAL_PHONE_NUMBER", text: "Hello from RedwoodJS and Vonage!") { success messageId error } }Click the "Play" button.
You should receive an SMS on your phone, and the GraphQL response should look like:
{ "data": { "sendSms": { "success": true, "messageId": "some-unique-message-uuid", "error": null } } }If you get
success: false, check theerrormessage and review the terminal output whereyarn rw devis running for logs fromapi/src/services/sms/sms.ts. Common issues include incorrect API credentials, wrong phone number formats, or the private key file not being found.3.3
curlExample:Test the GraphQL endpoint using
curl:curl http://localhost:8911/graphql \ -H 'Content-Type: application/json' \ --data-raw '{"query":"mutation SendTestSms { sendSms(to: \"YOUR_REAL_PHONE_NUMBER\", text: \"Hello via curl!\") { success messageId error } }"}'Replace
YOUR_REAL_PHONE_NUMBERaccordingly.How Do You Receive SMS Messages via Webhooks in RedwoodJS?
Configure Vonage to send incoming SMS messages to your application via webhooks.
4.1 Expose Localhost with ngrok:
Vonage can't reach your local RedwoodJS app directly. Create a secure tunnel using
ngrok.Stop
yarn rw devif running (Ctrl+C).Start
ngrokto forward to Redwood's API port (8911):# Ensure you've configured your ngrok authtoken ngrok http 8911Copy the HTTPS "Forwarding" URL from ngrok's output (e.g.,
https://<unique-id>.ngrok-free.app).4.2 Update Vonage Application Webhook URLs:
YOUR_NGROK_HTTPS_URL/webhooks/inboundYOUR_NGROK_HTTPS_URL/webhooks/statusYOUR_NGROK_HTTPS_URLwith your ngrok URL).POST.4.3 Create RedwoodJS Webhook Handler:
Generate a RedwoodJS function for webhook handling:
yarn rw g function vonageWebhookThis creates
api/src/functions/vonageWebhook.ts.4.4 Implement Webhook Logic:
Modify
api/src/functions/vonageWebhook.tsto handle incoming messages and status updates, supporting both JSON and form-urlencoded payloads.// api/src/functions/vonageWebhook.ts import type { APIGatewayEvent, Context } from 'aws-lambda' import { logger } from 'src/lib/logger' import querystring from 'node:querystring' // Import db later when we add logging // import { db } from 'src/lib/db' /** * Parses request body based on Content-Type header. * Handles JSON and form-urlencoded data. */ const parseRequestBody = (event: APIGatewayEvent): Record<string, any> => { const contentType = event.headers['content-type'] || event.headers['Content-Type'] || '' const body = event.body || '' if (!body) { return {} } try { if (contentType.includes('application/json')) { return JSON.parse(body) } else if (contentType.includes('application/x-www-form-urlencoded')) { return querystring.parse(body) } else { logger.warn(`Unexpected Content-Type: ${contentType}. Attempting JSON parse.`) return JSON.parse(body) } } catch (error) { logger.error({ error, body, contentType }, 'Failed to parse webhook request body') return {} } } /** * Serverless function execution entry point. * Provides access to request context: headers, path, query parameters, and body. * * @see https://redwoodjs.com/docs/functions */ export const handler = async (event: APIGatewayEvent, _context: Context) => { logger.info({ path: event.path }, 'Received request on Vonage webhook handler') // Vonage expects a 200 OK response quickly to avoid retries try { const body = parseRequestBody(event) logger.debug({ body }, 'Webhook payload received and parsed') if (Object.keys(body).length === 0 && event.body) { logger.error('Webhook body parsing resulted in empty object, check parseRequestBody logic and logs.') // Consider returning 400 Bad Request if parsing is essential // return { statusCode: 400, body: JSON.stringify({ error: 'Invalid request body' }) } } // Differentiate based on the path Vonage hits if (event.path.endsWith('/webhooks/inbound')) { // --- Handle Incoming SMS Message --- logger.info('Processing inbound SMS webhook') const { msisdn, to, messageId, text, type, 'message-timestamp': timestamp } = body if (type === 'text' && msisdn && to && messageId && text) { logger.info( { from: msisdn, to, messageId, textLength: text.length }, 'Received valid inbound SMS' ) // --- TODO: Add database logging here (Section 6) --- // --- TODO: Add business logic here (e.g., auto-reply, trigger action) --- } else { logger.warn({ body }, 'Received inbound webhook with unexpected format or missing fields') } } else if (event.path.endsWith('/webhooks/status')) { // --- Handle Message Status Update --- logger.info('Processing message status webhook') const { message_uuid, status, timestamp, to, from, error } = body if (message_uuid) { logger.info({ message_uuid, status, to }, 'Received message status update') // --- TODO: Update message status in the database (Section 6) --- if (error) { logger.error({ message_uuid, error }, 'Message delivery failed') } } else { logger.warn({ body }, 'Received status webhook without message_uuid') } } else { logger.warn(`Webhook called on unexpected path: ${event.path}`) } // Always return 200 OK to Vonage to prevent retries return { statusCode: 200, headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ message: 'Webhook received successfully' }), } } catch (error) { logger.error({ error, requestBody: event.body }, 'Error processing Vonage webhook') // Still return 200 to Vonage unless absolutely necessary to trigger retries return { statusCode: 200, headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ error: 'Failed to process webhook' }), } } }parseRequestBodyhelper handles bothapplication/jsonandapplication/x-www-form-urlencodedcontent types for robustness. The code usesevent.pathto determine whether it's an inbound message or status update, parses the payload, logs relevant information, and returns a200 OKstatus promptly to acknowledge receipt. Error handling logs issues without causing Vonage to retry endlessly.4.5 Test Receiving SMS:
ngrokis running and pointing to port 8911.yarn rw dev.yarn rw dev. You should see log entries fromapi/src/functions/vonageWebhook.tsindicating an "inbound SMS" was received, with details like sender number (msisdn) and message text. Check for parsing warnings or errors.http://127.0.0.1:4040), including theContent-Typeheader Vonage sent.How Do You Implement Error Handling and Logging for SMS?
Error Handling Strategy:
sendSmsservicetry...catchblocks catch Vonage SDK errors{ success: false, error: '...' }objecttry...catchwith graceful degradation200 OKto Vonage while logging errors internallyLogging Approach:
RedwoodJS's Pino logger (
src/lib/logger) tracks key events:For production, configure log levels and integrate with monitoring services (Datadog, Logflare, CloudWatch).
Retry Mechanisms:
sendSmsdoesn't implement retries. For critical messages, wrap thevonage.messages.sendcall in a retry loop with exponential backoff using libraries likeasync-retryorp-retryfor transient network or API errors.200 OKwithin a few seconds. The handler returns 200 quickly, even if background processing fails, to prevent unnecessary retries.Testing Error Scenarios:
Force an error by providing an invalid
tonumber format or temporarily changing the API key in.envto something invalid. Trigger thesendSmsmutation and observe the logged error and{ success: false, ... }response.How Do You Create a Database Schema for SMS Logging?
Log sent and received messages to the database using Prisma.
6.1 Define Prisma Schema:
Edit
api/db/schema.prismaand add aSmsLogmodel.vonageIdis unique to prevent duplicate entries from retried webhooks, using CUID as a fallback if the Vonage ID isn't obtained.6.2 Create and Run Migration:
Apply the schema changes to your database.
# Create a new migration file based on schema changes yarn rw prisma migrate dev --name add-sms-log # This command also applies the migration6.3 Update Service and Webhook to Log Data:
Modify the
sendSmsservice and thevonageWebhookfunction to interact with the database. Add thecuidpackage if needed:yarn workspace api add cuid.api/src/services/sms/sms.ts(sendSms):// api/src/services/sms/sms.ts import type { MutationResolvers } from 'types/graphql' import { logger } from 'src/lib/logger' import { getVonageClient } from 'src/lib/vonage' import { db } from 'src/lib/db' import cuid from 'cuid' interface SendSmsInput { to: string text: string } export const sendSms = async ({ to, text }: SendSmsInput): Promise<{ success: boolean; messageId?: string; error?: string }> => { logger.info({ to, textLength: text.length }, 'Attempting to send SMS') const vonage = getVonageClient() const fromNumber = process.env.VONAGE_VIRTUAL_NUMBER if (!fromNumber) { logger.error('VONAGE_VIRTUAL_NUMBER is not set in environment variables.') return { success: false, error: 'Vonage sender number not configured.' } } if (!to || !text) { logger.warn('Missing recipient (to) or text content.') return { success: false, error: 'Recipient phone number and text message are required.' } } let messageId: string | undefined = undefined; let success = false; let errorMessage: string | undefined = undefined; const placeholderId = `failed-${cuid()}` try { const resp = await vonage.messages.send({ message_type: 'text', to: to, from: fromNumber, channel: 'sms', text: text, }); messageId = resp.message_uuid; success = true; logger.info({ messageId }, 'SMS sent successfully via Vonage'); } catch (error) { logger.error({ error, to }, 'Failed to send SMS via Vonage'); errorMessage = error.response?.data?.title || error.message || 'Unknown error sending SMS.'; success = false; } // --- Log to Database --- const finalVonageId = messageId || placeholderId; try { await db.smsLog.create({ data: { direction: 'OUTBOUND', vonageId: finalVonageId, fromNumber: fromNumber, toNumber: to, body: text, status: success ? 'SUBMITTED' : 'FAILED', error: errorMessage, }, }); logger.debug({ vonageId: finalVonageId }, 'Logged outbound SMS attempt to database'); } catch (dbError) { logger.error({ dbError, vonageId: finalVonageId }, 'Failed to log outbound SMS to database'); } // --- End Log to Database --- return { success, messageId, error: errorMessage }; }api/src/functions/vonageWebhook.ts(handler):// api/src/functions/vonageWebhook.ts import type { APIGatewayEvent, Context } from 'aws-lambda' import { logger } from 'src/lib/logger' import { db } from 'src/lib/db' import querystring from 'node:querystring' const parseRequestBody = (event: APIGatewayEvent): Record<string, any> => { const contentType = event.headers['content-type'] || event.headers['Content-Type'] || '' const body = event.body || '' if (!body) { return {} } try { if (contentType.includes('application/json')) { return JSON.parse(body) } else if (contentType.includes('application/x-www-form-urlencoded')) { return querystring.parse(body) } else { logger.warn(`Unexpected Content-Type: ${contentType}. Attempting JSON parse.`) return JSON.parse(body) } } catch (error) { logger.error({ error, body, contentType }, 'Failed to parse webhook request body') return {} } } export const handler = async (event: APIGatewayEvent, _context: Context) => { logger.info({ path: event.path }, 'Received request on Vonage webhook handler') try { const body = parseRequestBody(event) logger.debug({ body }, 'Webhook payload received and parsed') if (Object.keys(body).length === 0 && event.body) { logger.error('Webhook body parsing resulted in empty object, check parseRequestBody logic and logs.') } if (event.path.endsWith('/webhooks/inbound')) { logger.info('Processing inbound SMS webhook') const { msisdn, to, messageId, text, type } = body if (type === 'text' && msisdn && to && messageId && text) { logger.info({ from: msisdn, to, messageId, textLength: text.length }, 'Received valid inbound SMS') try { await db.smsLog.create({ data: { direction: 'INBOUND', vonageId: messageId, fromNumber: msisdn, toNumber: to, body: text, status: 'RECEIVED', }, }) logger.debug({ vonageId: messageId }, 'Logged inbound SMS to database') } catch (dbError) { // Handle potential duplicate messageId errors if Vonage retries if (dbError.code === 'P2002' && dbError.meta?.target?.includes('vonageId')) { logger.warn({ vonageId: messageId }, 'Attempted to log duplicate inbound SMS (vonageId exists). Ignoring.') } else { logger.error({ dbError, vonageId: messageId }, 'Failed to log inbound SMS to database') } } } else { logger.warn({ body }, 'Received inbound webhook with unexpected format or missing fields') } } else if (event.path.endsWith('/webhooks/status')) { logger.info('Processing message status webhook') const { message_uuid, status, timestamp, to, from, error } = body if (message_uuid) { logger.info({ message_uuid, status, to }, 'Received message status update') try { await db.smsLog.update({ where: { vonageId: message_uuid }, data: { status: status?.toUpperCase(), vonageStatus: status, error: error ? JSON.stringify(error) : undefined, updatedAt: timestamp ? new Date(timestamp) : new Date(), }, }) logger.debug({ vonageId: message_uuid, status }, 'Updated SMS status in database') } catch (dbError) { logger.error({ dbError, vonageId: message_uuid }, 'Failed to update SMS status in database') } if (error) { logger.error({ message_uuid, error }, 'Message delivery failed according to status update') } } else { logger.warn({ body }, 'Received status webhook without message_uuid') } } else { logger.warn(`Webhook called on unexpected path: ${event.path}`) } return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'Webhook received successfully' }), } } catch (error) { logger.error({ error, requestBody: event.body }, 'Error processing Vonage webhook') return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Failed to process webhook' }), } } }Database integration: The
sendSmsservice logs each attempt to theSmsLogtable, recording initial submission or failure. ThevonageWebhookhandler logs incoming messages (INBOUND) and updates outbound message statuses based on status webhooks. The code usesupdatefor status changes andcreatefor inbound messages with duplicate checking to handle message states correctly, including Vonage retries.Frequently Asked Questions About Vonage SMS in RedwoodJS
What Node.js version does RedwoodJS require for Vonage integration?
RedwoodJS requires Node.js version 20 or higher as of 2025. Check your version with
node -vand use nvm (Node Version Manager) to switch versions if needed. Vonage's@vonage/server-sdkworks seamlessly with Node.js 20 LTS. Avoid Node.js 21+ for production deployments – it may cause compatibility issues with deployment targets like AWS Lambda.How do I get Vonage API credentials for RedwoodJS?
Log into the Vonage API Dashboard and locate your API Key and Secret on the main page. Create a new Application under "Applications" > "Create a new application", enable the Messages capability, and generate a private key file. Download the
private.keyfile immediately and store it in your project root. Copy the Application ID into your.envfile. Purchase an SMS-capable virtual number from "Numbers" > "Buy numbers" and link it to your application.What is E.164 phone number format and why does Vonage require it?
E.164 is the ITU-T international telephone numbering standard ensuring globally unique phone numbers. The format starts with a + sign, followed by the country code (1-3 digits) and subscriber number, with a maximum of 15 digits total (e.g., +14155551234). Vonage requires E.164 format to eliminate routing ambiguity across international SMS networks. Use the regex pattern
/^\+?[1-9]\d{1,14}$/for basic validation, or implementlibphonenumber-jsfor production-grade validation.How do I secure Vonage webhooks in RedwoodJS?
Secure Vonage webhooks in RedwoodJS functions by:
Authorizationheader using Vonage's JWT verificationWhat are SMS character limits with Vonage Messages API?
SMS messages use two encoding types:
Messages exceeding these limits split into multiple segments automatically, with each segment consuming additional credits. Special characters like
| ^ € { } [ ] ~ \require escape codes in GSM-7, consuming two character positions each.How do I implement SMS marketing campaigns with rate limiting in RedwoodJS?
Create a batch processing service that:
p-retryorasync-retryto handle Vonage API rate limits (typically 10-20 messages per second)Always comply with TCPA (US), GDPR (Europe), and local SMS marketing regulations requiring user opt-in.
Can I send bulk SMS messages to multiple recipients with this RedwoodJS setup?
Yes, extend the
sendSmsservice to accept an array of recipients and implement batch processing with rate limiting. Create a GraphQL mutation that queues messages in your database, then use RedwoodJS background jobs (via@redwoodjs/jobs) to process the queue asynchronously. This prevents timeout issues with large campaigns. Monitor Vonage API rate limits and implement exponential backoff for failed messages. Track campaign progress using Prisma database queries and provide real-time updates via GraphQL subscriptions.How do I handle SMS delivery failures and retries in production?
Vonage sends delivery status updates to your status webhook endpoint. Implement this workflow:
SmsLogdatabase record with status (DELIVERED, FAILED, etc.)What security best practices should I follow for Vonage credentials in RedwoodJS?
Follow these security practices:
.envorprivate.keyfiles – add to.gitignorechmod 600 private.key)VONAGE_PRIVATE_KEY_CONTENTenvironment variable to avoid file system dependencies