code examples
code examples
Vonage SMS with RedwoodJS & Node.js: Marketing Campaigns & Two-Way Messaging
Build SMS marketing campaigns in RedwoodJS with Vonage Messages API. Complete guide covering outbound messages, inbound webhooks, database logging, and Node.js integration.
Developer Guide: Implementing Vonage SMS in RedwoodJS with Node.js
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
- Node.js – JavaScript runtime environment powering RedwoodJS
- 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:
[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.
# 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 .env files.
-
Create a
.envfile in the root of your project:bashtouch .env -
Add these environment variables to your
.envfile (you'll populate these values in the next steps):plaintext# .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
.envandprivate.keyto your.gitignorefile to prevent committing secrets. The default RedwoodJS.gitignorealready includes.env, but verify and addprivate.key.plaintext# .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
.envfile. - Virtual Number: Navigate to "Numbers" > "Buy numbers" to purchase an SMS-capable number. Copy this number in E.164 format (e.g.,
14155551212) intoVONAGE_VIRTUAL_NUMBERin your.envfile. - 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-sdkto 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.keyfile that downloads. Save it in your RedwoodJS project root (or updateVONAGE_PRIVATE_KEY_PATHin.envif 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
- Inbound URL:
- Click "Generate new application".
- Copy the Application ID into
VONAGE_APPLICATION_IDin your.envfile. - 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.
yarn workspace api add @vonage/server-sdkHow 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.
yarn rw g service smsThis 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.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 }- 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_CONTENTif available, falling back toVONAGE_PRIVATE_KEY_PATH. RedwoodJS's Pino logger provides structured logging.
- 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
2.3 Implement sendSms Function:
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.- 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
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 sendSms service 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!
}
`- Schema structure:
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:
bashyarn 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_NUMBERwith your actual phone number in E.164 format):graphqlmutation 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 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.
3.3 curl Example:
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_NUMBER accordingly.
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):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_URLwith your ngrok URL).
- Inbound URL:
- Set the method for both to
POST. - Save the changes.
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.ts to 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' }),
}
}
}- Why this works: This function serves as the Vonage endpoint. The
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:
- Ensure
ngrokis 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 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. - Check ngrok console: Inspect request details in the ngrok web interface (
http://127.0.0.1:4040), including theContent-Typeheader 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 |
Logging Approach:
RedwoodJS's Pino logger (src/lib/logger) tracks key events:
- Send attempts with recipient and message length
- Success/failure of sending operations
- Webhook reception with payload details
- Errors with full context
For production, configure log levels and integrate with monitoring services (Datadog, Logflare, CloudWatch).
Retry Mechanisms:
- Sending: The current
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. - Receiving: Vonage retries automatically if your webhook endpoint doesn't return
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 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.
// 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
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 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.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):typescript// 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 -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
Authorizationheader 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
- Storing webhook URLs as environment variables – Prevent hardcoding sensitive URLs
- Returning HTTP 200 quickly – Respond within seconds to prevent Vonage from retrying
What are SMS character limits with Vonage Messages API?
SMS messages use two encoding types:
| Encoding | Characters per Segment | Use Case |
|---|---|---|
| GSM-7 | 160 characters | Standard Latin characters |
| UCS-2 | 70 characters | Emojis, Arabic, Chinese, Korean, Japanese, Cyrillic scripts |
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-retryorasync-retryto 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
SmsLogdatabase record with status (DELIVERED, FAILED, etc.) - Queue failed messages – Store failures in a separate queue for retry processing
- Implement exponential backoff – Retry at 1 minute, 5 minutes, 30 minutes intervals
- 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
How to send SMS messages with RedwoodJS and Vonage?
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.
What is the Vonage Messages API?
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.
Why does RedwoodJS use GraphQL for its API?
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.
When should I use ngrok with Vonage?
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.
Can I use a free ngrok account for testing?
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.
How to receive SMS messages in RedwoodJS with Vonage?
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.
What is a Vonage Application ID?
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.
What Node.js version is required for RedwoodJS with Vonage SMS?
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.
How to store SMS message logs in RedwoodJS?
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.
How do I handle Vonage webhook security in RedwoodJS?
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.
Why should I set the Default SMS API to Messages API in Vonage Dashboard?
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.
How to handle environment variables securely in RedwoodJS?
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.
What are the prerequisites for implementing Vonage SMS in RedwoodJS?
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.
What is the purpose of the private.key file from Vonage?
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.