code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

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:

ComponentPurpose
API ServiceEncapsulates Vonage interactions on the API side
GraphQL MutationTriggers sending an SMS message
Webhook HandlerReceives incoming SMS messages from Vonage
Database LoggingLogs 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:

plaintext
[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 directory
cd 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):

    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 .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.

    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 .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.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_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.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 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.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. 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:

  1. Start the development server:

    bash
    yarn rw dev
  2. Open your browser to the RedwoodJS GraphQL Playground: http://localhost:8911/graphql.

  3. 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
      }
    }
  4. Click the "Play" button.

You should receive an SMS on your phone, and the GraphQL response should look like:

json
{
  "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:

bash
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 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.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 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:

  1. Ensure ngrok is running and pointing to port 8911.
  2. Start the RedwoodJS dev server: yarn rw dev.
  3. Send an SMS from your phone to your Vonage virtual number.
  4. 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.
  5. 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:

ComponentImplementationPurpose
sendSms servicetry...catch blocks catch Vonage SDK errorsReturns { success: false, error: '...' } object
Webhook handlertry...catch with graceful degradationReturns 200 OK to Vonage while logging errors internally
Body parsingHandles JSON and form-urlencoded formatsPrevents parsing failures from breaking webhook reception
Future enhancementsDistinguish network errors from API errorsImplement 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 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.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 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
  • 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:

EncodingCharacters per SegmentUse Case
GSM-7160 charactersStandard Latin characters
UCS-270 charactersEmojis, 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:

  1. Reads recipient lists from your Prisma database
  2. Sends messages with controlled rate limiting
  3. Implements exponential backoff using p-retry or async-retry to handle Vonage API rate limits (typically 10-20 messages per second)
  4. Stores campaign status in your database to track delivery states
  5. 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:

  1. Receive status webhooks – Update the SmsLog database record with status (DELIVERED, FAILED, etc.)
  2. Queue failed messages – Store failures in a separate queue for retry processing
  3. Implement exponential backoff – Retry at 1 minute, 5 minutes, 30 minutes intervals
  4. Limit retry attempts – Cap total retries at 3-5 attempts to prevent infinite loops
  5. Log all failures – Include error details from Vonage's response for debugging
  6. 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:

PracticeImplementation
Version controlNever commit .env or private.key files – add to .gitignore
Environment variablesStore all sensitive credentials (API Key, Secret, Application ID, Private Key) as env vars
Production secretsUse AWS Secrets Manager, HashiCorp Vault, or Vercel Environment Variables
Credential rotationRotate credentials every 90 days
API usage auditingMonitor usage through the Vonage Dashboard
File permissionsSet to 600 for private key files (chmod 600 private.key)
Containerized deploymentsUse 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.