code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / vonage

RedwoodJS SMS with Vonage: Delivery Status & Webhooks Guide

Build RedwoodJS SMS with Vonage Messages API. Implement delivery receipts, webhook callbacks, database integration, and production-ready error handling.

Developer Guide: Implementing RedwoodJS SMS with Vonage Delivery Status

This guide provides a step-by-step walkthrough for building a RedwoodJS application capable of sending SMS messages via the Vonage Messages API, receiving inbound SMS messages, and handling delivery status callbacks. We'll cover everything from project setup and Vonage configuration to database integration, error handling, and deployment considerations.

By the end of this tutorial, you will have a functional RedwoodJS application with API endpoints to send SMS, and webhooks correctly configured to process incoming messages and delivery status updates from Vonage, storing relevant information in a database.

Project Overview and Goals

We aim to build a robust system within a RedwoodJS application that leverages the Vonage Messages API for SMS communication. This solves the common need for applications to programmatically send notifications, alerts, or engage in two-way conversations via SMS, while also providing visibility into message delivery success or failure.

Key Features:

  • Send outbound SMS messages via a RedwoodJS API function.
  • Receive inbound SMS messages directed to a Vonage virtual number.
  • Receive delivery status updates for outbound messages.
  • Store message details and statuses in a database (using Prisma).
  • Utilize webhooks for real-time updates from Vonage.

Technologies Used:

  • RedwoodJS: A full-stack JavaScript/TypeScript framework for the web. Chosen for its integrated structure (React frontend, GraphQL API, Prisma ORM) and developer experience features.
  • Node.js: The runtime environment for the RedwoodJS API side.
  • Vonage Messages API: A unified API for sending and receiving messages across various channels, including SMS. Chosen for its flexibility and features like delivery receipts.
  • @vonage/server-sdk: The official Vonage Node.js SDK for simplified API interaction.
  • Prisma: RedwoodJS's default ORM for database interaction.
  • ngrok: A tool to expose local development servers to the internet, essential for testing webhooks.

System Architecture:

(Note: For a final published version, consider replacing this ASCII diagram with a clearer graphical representation.)

plaintext
+-----------------+      +-----------------+      +-----------------+      +----------+      +-----------+
| User / Frontend | ---> | RedwoodJS API   | ---> | Vonage Platform | ---> | Carrier  | ---> | End User  |
| (React)         |      | (Node.js/GraphQL|      | (Messages API)  |      | Network  |      | Phone     |
|                 |      | /Prisma)        | <--- |                 | <--- |          | <--- |           |
+-----------------+      +-----------------+      +-----------------+      +----------+      +-----------+
        |                      ^      ^                                          |
        | Send SMS Request     |      | Inbound SMS / Status Update              |
        +----------------------+      +--(Webhook via ngrok/Public URL)-----------+
                                      |
                               +-----------------+
                               | Database        |
                               | (PostgreSQL/etc)|
                               +-----------------+

Prerequisites:

  • Node.js (LTS version recommended) and Yarn installed.
  • A Vonage API account (Sign up for free).
  • A Vonage virtual phone number capable of sending/receiving SMS.
  • ngrok installed and authenticated (Download ngrok).

1. Setting Up the Project

Let's initialize a new RedwoodJS project and configure our environment.

  1. Create RedwoodJS App: Open your terminal and run:

    bash
    yarn create redwood-app ./redwood-vonage-sms
    cd redwood-vonage-sms

    This scaffolds a new RedwoodJS project in the redwood-vonage-sms directory.

  2. Install Vonage SDK: Navigate to the API workspace and install the Vonage Node.js SDK:

    bash
    yarn workspace api add @vonage/server-sdk
  3. Configure Vonage Account:

    • API Credentials: Log in to your Vonage API Dashboard. Find your API Key and API Secret on the main page. Note: While visible, this guide uses Application ID/Private Key authentication for the Messages API.
    • Create Vonage Application:
      • Navigate to Applications -> Create a new application.
      • Give it a name (e.g., "Redwood SMS App").
      • Click "Generate public and private key". Save the private.key file securely, for example, in the root directory of your Redwood project (outside the api or web folders). Do not commit this file to Git.
      • Enable the Messages capability.
      • For now, you can leave the "Inbound URL" and "Status URL" blank or use placeholders like http://example.com/inbound. We'll update these with ngrok URLs later.
      • Click "Generate application". Note down the Application ID.
    • Link Number: Go back to the Application settings, scroll down to "Link virtual numbers", and link your purchased Vonage virtual number to this application.
    • Set Default SMS API: Important: Navigate to your main Vonage Dashboard Settings. Scroll down to "SMS settings". Ensure that the API preference is set to Messages API. This is crucial for the webhooks and SDK usage in this guide. Click "Save changes". (Ensure this setting is correct in your dashboard; it determines how SMS features behave).
  4. Environment Variables: Create a .env file in the root of your RedwoodJS project (alongside redwood.toml). Add your Vonage credentials and number:

    dotenv
    # .env
    
    # Vonage Credentials (Used for SDK initialization with Messages API)
    VONAGE_APPLICATION_ID="YOUR_VONAGE_APPLICATION_ID"
    # Path relative to project root. Ensure this resolves correctly at runtime (see sendSms.ts).
    VONAGE_PRIVATE_KEY_PATH="./private.key"
    
    # Vonage API Key/Secret (Not used for Messages API auth in this guide, but potentially for other Vonage APIs)
    # VONAGE_API_KEY="YOUR_VONAGE_API_KEY"
    # VONAGE_API_SECRET="YOUR_VONAGE_API_SECRET"
    
    # Your Vonage Virtual Number
    VONAGE_NUMBER="YOUR_VONAGE_VIRTUAL_NUMBER" # e.g., 14155551212
    • Replace placeholders with your actual Application ID and Vonage number.
    • Ensure VONAGE_PRIVATE_KEY_PATH points correctly to where you saved the private.key file relative to the project root. Note the potential runtime path resolution issue discussed later.
    • Crucially: Add .env and private.key to your .gitignore file to prevent committing secrets.
    text
    # .gitignore
    # ... other entries
    .env
    private.key
    api/db/dev.db # Example for SQLite, adjust as needed
    api/db/dev.db-journal
    *.key
  5. Setup ngrok: Webhooks require a publicly accessible URL. ngrok tunnels requests to your local machine.

    • Open a new terminal window in your project root.

    • Run ngrok to forward to Redwood's default API port (8911):

      bash
      ngrok http 8911
    • ngrok will display forwarding URLs (e.g., https://<unique-code>.ngrok-free.app). Copy the https URL. (Check your terminal for output similar to the example shown in ngrok documentation).

    • Note: ngrok is for development/testing. For production, you'll need a stable public URL for your deployed API endpoint, managed via your hosting platform's ingress, load balancer, or direct server exposure.

  6. Update Vonage Webhook URLs:

    • Go back to your Vonage Application settings.
    • Set the Inbound URL to YOUR_NGROK_HTTPS_URL/api/webhooks/inbound.
    • Set the Status URL to YOUR_NGROK_HTTPS_URL/api/webhooks/status.
    • Ensure the HTTP method for both is set to POST.
    • Save the changes.

    Now, Vonage will send POST requests to these ngrok URLs, which will be forwarded to your local RedwoodJS API running on port 8911.


2. Database Schema and Data Layer

We need a way to store information about the SMS messages we send and receive.

  1. Define Prisma Schema: Open api/db/schema.prisma and add the following 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"
    }
    
    model SmsMessage {
      id              String    @id @default(cuid())
      vonageMessageId String?   @unique // Vonage's message_uuid
      direction       String    // 'inbound' or 'outbound'
      fromNumber      String
      toNumber        String
      body            String?
      status          String    // Valid DLR status: 'submitted', 'delivered', 'accepted', 'buffered', 'expired', 'failed', 'rejected', 'unknown'
      errorCode       String?   // Vonage error code (0-99) if status is 'failed' or 'rejected'
      statusTimestamp DateTime? // Timestamp from the status webhook
      createdAt       DateTime  @default(now())
      updatedAt       DateTime  @updatedAt
    
      // Add indexes for frequently queried fields if needed beyond vonageMessageId
      // @@index([status])
      // @@index([createdAt])
    }
    • vonageMessageId: Stores the unique ID returned by Vonage, crucial for correlating status updates. The @unique attribute automatically creates a database index.
    • direction: Tracks if the message was sent or received by our app.
    • status: Holds the latest known delivery receipt (DLR) status. Valid values per Vonage SMS Delivery Receipts API: accepted (queued for delivery), delivered (successfully delivered), buffered (buffered for later delivery), expired (retry timeout exceeded), failed (delivery failed), rejected (carrier rejected), unknown (no useful information available). Additional internal statuses: submitted (initial state before Vonage response), received (for inbound messages).
    • errorCode: Stores Vonage DLR error codes (0-99) if delivery fails. See Error Code Reference for details. Code 0 indicates successful delivery; non-zero codes indicate specific failure reasons (e.g., 3 = permanent number disconnection, 6 = anti-spam rejection, 39 = illegal sender for US destinations).
  2. Apply Migrations: Run the Prisma migration command to apply these changes to your database:

    bash
    yarn rw prisma migrate dev --name create_sms_message

    This creates/updates the database schema and generates the Prisma client.


3. Building the API Layer (RedwoodJS Functions)

RedwoodJS Functions are serverless functions deployed alongside your API. We'll create functions to send SMS and handle incoming webhooks.

Important Production Consideration: Vonage webhooks expect a quick 200 OK response (within a few seconds). Performing database operations directly within the webhook handler, as shown here for simplicity, is risky in production. If the database write fails temporarily, you'll return 200 OK (to prevent Vonage retries), but the data might be lost. For production, strongly consider using a background job queue (e.g., BullMQ, Redis Streams) to process webhook payloads asynchronously. This allows the handler to return 200 OK immediately while ensuring reliable data processing.

  1. Send SMS Function: Generate a function to handle sending messages:

    bash
    yarn rw g function sendSms

    Implement the logic in api/src/functions/sendSms.ts (or .js):

    typescript
    // api/src/functions/sendSms.ts
    import type { APIGatewayEvent, Context } from 'aws-lambda'
    import { Vonage } from '@vonage/server-sdk'
    // For better type safety, import specific types:
    // import { MessagesSendRequest } from '@vonage/messages'
    import path from 'path' // Required for resolving private key path
    import fs from 'fs' // Needed if reading key content from env var
    
    import { db } from 'src/lib/db' // Redwood's Prisma client
    import { logger } from 'src/lib/logger'
    
    // --- Private Key Handling ---
    // Option 1: Resolve path (Default in this guide)
    // This path is relative to the *built* API output directory (e.g., api/dist),
    // NOT the source directory. Adjust the relative path ('../private.key') if needed based on your build structure.
    const privateKeyPath = process.env.VONAGE_PRIVATE_KEY_PATH
      ? path.resolve(process.cwd(), process.env.VONAGE_PRIVATE_KEY_PATH)
      : path.resolve(process.cwd(), '../private.key') // Default assumption
    
    // Option 2: Read key content directly from environment variable (Recommended for deployment)
    // const privateKeyContent = process.env.VONAGE_PRIVATE_KEY_CONTENT;
    // if (!privateKeyContent) {
    //   logger.error('VONAGE_PRIVATE_KEY_CONTENT environment variable is not set.');
    //   throw new Error('Missing private key configuration.');
    // }
    
    // Initialize Vonage client using Application ID and Private Key for Messages API
    const vonage = new Vonage(
      {
        applicationId: process.env.VONAGE_APPLICATION_ID,
        // Use Option 1 (Path) or Option 2 (Content)
        privateKey: privateKeyPath, // For Option 1
        // privateKey: privateKeyContent, // For Option 2
      },
      { logger: logger } // Optional: Use Redwood's logger
    )
    
    export const handler = async (event: APIGatewayEvent, _context: Context) => {
      logger.info('Received sendSms request')
    
      if (event.httpMethod !== 'POST') {
        return { statusCode: 405, body: 'Method Not Allowed' }
      }
    
      let requestBody
      try {
        requestBody = JSON.parse(event.body || '{}')
      } catch (error) {
        logger.error('Failed to parse request body:', error)
        return { statusCode: 400, body: 'Bad Request: Invalid JSON' }
      }
    
      const { to, text } = requestBody
      const from = process.env.VONAGE_NUMBER
    
      if (!to || !text || !from) {
        logger.error('Missing required parameters: to, text, or VONAGE_NUMBER')
        return {
          statusCode: 400,
          body: 'Bad Request: Missing "to", "text" parameters, or VONAGE_NUMBER configuration.',
        }
      }
    
      logger.info(`Attempting to send SMS from ${from} to ${to}`)
    
      let initialDbRecord
      try {
        // Create an initial record before sending
        initialDbRecord = await db.smsMessage.create({
          data: {
            direction: 'outbound',
            fromNumber: from,
            toNumber: to,
            body: text,
            status: 'submitted', // Initial status
          },
        })
        logger.info(`Created initial DB record with ID: ${initialDbRecord.id}`)
    
        // Prepare the payload. Use specific types for better safety.
        const messagePayload /*: MessagesSendRequest */ = {
          channel: 'sms',
          message_type: 'text',
          to: to,
          from: from,
          text: text,
          // client_ref: initialDbRecord.id // Optional: include DB ID for correlation
        }
    
        // Using 'as any' for simplicity in this example.
        // Replace with the actual type (e.g., MessagesSendRequest) for production code.
        const resp = await vonage.messages.send(messagePayload as any)
    
        logger.info(`Vonage API response: ${JSON.stringify(resp)}`)
    
        // Update the DB record with the Vonage message ID
        await db.smsMessage.update({
          where: { id: initialDbRecord.id },
          data: { vonageMessageId: resp.message_uuid },
        })
    
        logger.info(`SMS submitted successfully to Vonage. Message UUID: ${resp.message_uuid}`)
    
        return {
          statusCode: 200,
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            success: true,
            message: 'SMS submitted successfully.',
            messageId: resp.message_uuid, // Vonage's ID
            dbId: initialDbRecord.id,   // Your internal DB ID
          }),
        }
      } catch (error) {
        logger.error('Error sending SMS via Vonage or updating DB:', error)
    
        // Attempt to update the DB record status to 'failed' if possible
        if (initialDbRecord) {
          try {
            await db.smsMessage.update({
              where: { id: initialDbRecord.id },
              data: { status: 'failed_submission' }, // Indicate failure during submission
            })
          } catch (dbError) {
            logger.error('Failed to update DB record status after submission error:', dbError)
          }
        }
    
        // Check if the error is from the Vonage SDK and has specific details
        let errorMessage = 'Failed to send SMS.'
        // Type assertion needed as 'error' is 'unknown' in catch block
        const vonageError = error as any;
        if (vonageError?.response?.data) {
            errorMessage = `Vonage API Error: ${JSON.stringify(vonageError.response.data)}`;
        } else if (vonageError?.message) {
            errorMessage = vonageError.message;
        }
    
    
        return {
          statusCode: 500,
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            success: false,
            message: errorMessage,
            errorDetails: error.toString(), // Provide generic error string
            dbId: initialDbRecord?.id,
          }),
        }
      }
    }
    • Key Points:
      • We initialize Vonage using the Application ID and Private Key (either path or content). Path resolution needs care during build/deployment.
      • We parse the to number and text from the POST request body.
      • We immediately create a database record with status: 'submitted'.
      • We call vonage.messages.send(). Using specific SDK types like MessagesSendRequest is recommended over as any.
      • On success, we update the database record with the vonageMessageId returned by the API.
      • We include robust error handling and logging.
  2. Inbound SMS Webhook: Generate a function to handle incoming messages:

    bash
    # Note: Redwood maps the path to the function name by default
    # We specify the path explicitly to match the Vonage configuration
    yarn rw g function webhooks --path /webhooks/inbound

    Implement the logic in api/src/functions/webhooks/inbound.ts:

    typescript
    // api/src/functions/webhooks/inbound.ts
    import type { APIGatewayEvent, Context } from 'aws-lambda'
    import { db } from 'src/lib/db'
    import { logger } from 'src/lib/logger'
    
    export const handler = async (event: APIGatewayEvent, _context: Context) => {
      logger.info('Inbound webhook received')
    
      if (event.httpMethod !== 'POST') {
        logger.warn('Received non-POST request to inbound webhook')
        return { statusCode: 405, body: 'Method Not Allowed' }
      }
    
      let body
      try {
        // Redwood typically parses application/json automatically into event.body.
        // Check Content-Type header (via logs/ngrok) if Vonage sends urlencoded.
        // If urlencoded and not auto-parsed, add parsing logic here.
        body = typeof event.body === 'string' ? JSON.parse(event.body) : event.body
        logger.debug({ custom: body }, 'Inbound webhook payload')
    
        if (!body || !body.message_uuid || !body.from || !body.to || !body.text) {
           logger.error('Inbound webhook missing required fields.')
           // Still return 200 OK to prevent Vonage retries
           return { statusCode: 200 }
        }
    
        // Store the inbound message
        // Production recommendation: Offload this DB write to a background queue.
        await db.smsMessage.create({
          data: {
            vonageMessageId: body.message_uuid,
            direction: 'inbound',
            fromNumber: body.from,
            toNumber: body.to, // Your Vonage number
            body: body.text,
            status: 'received', // Status for inbound
            statusTimestamp: body.timestamp ? new Date(body.timestamp) : new Date(),
            createdAt: new Date(), // Use current time or webhook timestamp if reliable
          },
        })
    
        logger.info(`Stored inbound SMS from ${body.from} with message ID ${body.message_uuid}`)
    
      } catch (error) {
        logger.error('Error processing inbound webhook (e.g., DB write failed):', error)
        // Still return 200 OK even if DB write fails to stop Vonage retries.
        // Implement proper error monitoring/alerting and consider queuing for reliability.
      }
    
      // IMPORTANT: Always return 200 OK quickly to acknowledge receipt by Vonage
      return { statusCode: 200 }
    }
    • Key Points:
      • The function listens at /api/webhooks/inbound (matching Vonage config).
      • It expects a POST request.
      • It parses the request body (check Content-Type if issues arise).
      • It extracts relevant fields (message_uuid, from, to, text, timestamp).
      • It creates a new record in the SmsMessage table with direction: 'inbound' and status: 'received'.
      • It must return a 200 OK status code promptly. Offload heavy processing (like DB writes) to queues in production.
  3. Status Update Webhook: Generate a function to handle delivery status updates:

    bash
    yarn rw g function webhooks --path /webhooks/status

    Implement the logic in api/src/functions/webhooks/status.ts:

    typescript
    // api/src/functions/webhooks/status.ts
    import type { APIGatewayEvent, Context } from 'aws-lambda'
    import { db } from 'src/lib/db'
    import { logger } from 'src/lib/logger'
    
    export const handler = async (event: APIGatewayEvent, _context: Context) => {
      logger.info('Status webhook received')
    
      if (event.httpMethod !== 'POST') {
        logger.warn('Received non-POST request to status webhook')
        return { statusCode: 405, body: 'Method Not Allowed' }
      }
    
      let body
      try {
        // Similar parsing logic as inbound webhook
        body = typeof event.body === 'string' ? JSON.parse(event.body) : event.body
        logger.debug({ custom: body }, 'Status webhook payload')
    
        if (!body || !body.message_uuid || !body.status) {
           logger.error('Status webhook missing required fields.')
           // Still return 200 OK
           return { statusCode: 200 }
        }
    
        const { message_uuid, status, timestamp, error } = body
    
        // Verify the exact structure of the 'error' object against Vonage docs if needed.
        const errorCode = error?.['error_code'] ?? error?.['code'] ?? null;
    
        // Find the corresponding outbound message and update its status
        // Production recommendation: Offload this DB update to a background queue.
        const updatedMessage = await db.smsMessage.updateMany({
          where: {
            vonageMessageId: message_uuid,
            direction: 'outbound', // Ensure we only update outbound messages
          },
          data: {
            status: status, // e.g., 'delivered', 'failed', 'rejected', 'accepted'
            statusTimestamp: timestamp ? new Date(timestamp) : new Date(),
            errorCode: errorCode, // Capture error code if present
            updatedAt: new Date(),
          },
        })
    
        if (updatedMessage.count > 0) {
          logger.info(`Updated status for message ${message_uuid} to ${status}`)
        } else {
          logger.warn(`Could not find outbound message with Vonage ID ${message_uuid} to update status. (Might be an inbound status, or timing issue)`)
          // This might happen if the status arrives before the sendSms function updates the DB,
          // or if it's a status for an inbound message (which we usually ignore here).
        }
    
      } catch (error) {
        logger.error('Error processing status webhook (e.g., DB update failed):', error)
        // Still return 200 OK. Implement monitoring/alerting and consider queuing.
      }
    
      // IMPORTANT: Always return 200 OK quickly
      return { statusCode: 200 }
    }
    • Key Points:
      • Listens at /api/webhooks/status.
      • Expects POST.
      • Parses the body to get message_uuid, status, timestamp, and potential error details. Verify error structure in Vonage docs.
      • Uses db.smsMessage.updateMany with the vonageMessageId to find the original outbound message.
      • Updates the status, statusTimestamp, and errorCode fields.
      • Logs whether the update was successful or if the message wasn't found.
      • Must return 200 OK promptly. Offload heavy processing to queues in production.

4. Integrating with Vonage (Recap & Details)

  • SDK Initialization: Done within the sendSms function using new Vonage({ applicationId, privateKey }). Ensure the privateKey source (path or env var content) is correctly configured and accessible at runtime. The path resolution (path.resolve(process.cwd(), ...) in sendSms.ts) needs careful testing, especially after building the API (yarn rw build api), as process.cwd() will point to the build output directory (e.g., api/dist). Consider using environment variable content or copying the key during build for robustness.
  • API Keys/Secrets: Handled via .env file for local development. Never commit these files. Use environment variable management provided by your deployment platform for production. The Messages API specifically uses Application ID and Private Key for authentication in this setup.
  • Dashboard Configuration:
    • Application created with Messages capability enabled.
    • Correct Vonage number linked to the application.
    • Webhook URLs (/api/webhooks/inbound, /api/webhooks/status) correctly set using your public URL (ngrok for dev, actual domain for prod). Method must be POST.
    • Account default SMS setting configured to Messages API.
  • Environment Variables:
    • VONAGE_APPLICATION_ID: Your Vonage application's unique ID.
    • VONAGE_PRIVATE_KEY_PATH: Relative path from the project root to your saved private.key file (if using path method).
    • VONAGE_NUMBER: Your purchased Vonage virtual number in E.164 format (e.g., 14155551212).
    • VONAGE_PRIVATE_KEY_CONTENT: (Alternative/Recommended for Deployment) The actual content of the private key file.

5. Error Handling, Logging, and Retry Mechanisms

  • Error Handling:
    • try...catch blocks are used in all functions interacting with the Vonage API or database.
    • Specific errors from the Vonage SDK (e.g., error.response.data) are logged for better debugging.
    • Database interaction failures are caught and logged.
    • Clear error messages are returned in the API response for sendSms.
  • Logging:
    • RedwoodJS's built-in logger (api/src/lib/logger.js) is used.
    • Informative logs track the request flow, API calls, webhook processing, and errors.
    • Use logger.info, logger.warn, logger.error, and logger.debug({ custom: payload }) for detailed payloads. Configure log levels and destinations (e.g., structured logging) for production.
  • Retry Mechanisms (Vonage Side):
    • Vonage automatically retries webhook delivery if it doesn't receive a 200 OK response within its timeout period. This is why our webhook handlers must return 200 OK quickly.
    • Crucially: If your internal processing (like DB writes) fails after you return 200 OK, Vonage will not retry. This highlights the need for asynchronous processing (queues) for critical webhook tasks in production to handle transient internal failures reliably.
  • Retry Mechanisms (Client Side - Optional):
    • If your frontend calls the /api/sendSms endpoint, implement retry logic there (e.g., using exponential backoff) for transient network errors or 5xx responses from your API.

6. Database Schema (Recap)

The Prisma schema (api/db/schema.prisma) defines the SmsMessage model with fields like vonageMessageId (indexed), direction, fromNumber, toNumber, body, status, errorCode, and timestamps. The migration (yarn rw prisma migrate dev) applies this schema. Consider adding indexes to other fields based on query patterns.


7. Security Features

  • Secrets Management: API keys, Application ID, and Private Key are stored in .env locally and never committed to source control. Use secure environment variable management or secret stores in your deployment environment (see Section 12).
  • Input Validation: Basic validation is done in sendSms (checking for to and text). Implement more robust validation (e.g., phone number format, length checks) for production using libraries like zod.
  • Webhook Security (Essential for Production):
    • Signed Webhooks Enabled by Default: Per Vonage Webhook Documentation, webhook signing is enabled by default for Messages, Dispatch, Verify, and Voice APIs (effective 2020+). This provides cryptographic verification that requests originate from Vonage.
    • JWT-Based Verification: Webhooks include a JWT token in the Authorization header (format: Bearer <JWT>). The JWT is signed using HMAC-SHA256 with your signature secret (visible in Dashboard under API Settings).
    • JWT Payload Structure: The JWT contains:
      • iat: Issued-at timestamp (Unix seconds)
      • jti: Unique JWT ID
      • iss: Always "Vonage"
      • payload_hash: SHA-256 hash of request body (verify payload integrity)
      • api_key: Your Vonage API key (identifies which signature secret to use)
      • application_id: (Optional) Application ID if applicable
    • Implementation Required: Install @vonage/jwt package and verify the JWT signature using your signature secret before processing webhook payloads. Extract the JWT from Authorization header, decode it using your signature secret, and optionally verify payload_hash matches SHA-256 of request body.
    • Security Benefits: Prevents spoofed webhooks, ensures payload integrity, and defends against replay attacks.
    • Production Requirement: Implementing webhook signature verification is mandatory for production deployments to prevent security vulnerabilities. Never accept unsigned webhooks in production environments.
  • Rate Limiting: Implement rate limiting on the /api/sendSms endpoint to prevent abuse and manage costs. RedwoodJS doesn't have built-in rate limiting; use platform features (like API Gateway limits) or middleware patterns with external stores (like Redis).

8. Handling Special Cases

  • Phone Number Formatting: Vonage expects numbers in E.164 format (e.g., +14155551212). Ensure input numbers are normalized before sending to the API. Libraries like libphonenumber-js can help.
  • Character Limits & Encoding: Standard SMS messages have character limits (160 GSM-7 characters, fewer for non-GSM alphabets using UCS-2). The Vonage Messages API handles concatenation for longer messages, but be mindful of potential increased costs.
  • Delivery Receipt Support: Delivery receipts (DLRs) are carrier-dependent and not guaranteed globally. The status might remain 'accepted' even if delivered. Check Vonage's country-specific feature documentation. Do not rely solely on 'delivered' status for critical logic if operating in regions with poor DLR support.
  • Webhook Order: Status updates might occasionally arrive before your sendSms function finishes updating the database with the vonageMessageId. The status webhook handler includes a warning log for this. More robust solutions might involve retries within the status handler or temporary caching if this becomes a frequent issue. Using client_ref in the send request might also help correlation.

9. Performance Optimizations

  • Database Indexing: The @unique directive on vonageMessageId creates an essential index. Add indexes to other frequently queried fields (status, createdAt, fromNumber, toNumber) in schema.prisma based on your application's needs.
  • Asynchronous Processing (Webhooks): As mentioned previously, move database writes and other potentially slow operations within webhook handlers (inbound.ts, status.ts) to a background job queue (e.g., using Redis/BullMQ, platform-specific queues like AWS SQS) to ensure the required fast 200 OK response to Vonage and improve reliability.
  • Caching: If you build API endpoints to query message status frequently, consider implementing caching strategies (e.g., Redis, Memcached) to reduce database load.

10. Monitoring, Observability, and Analytics

  • Logging: Configure Redwood's logger for production (e.g., JSON format, appropriate log level) and ship logs to a centralized logging platform (e.g., Datadog, Logtail, Grafana Loki, ELK stack). This is crucial for debugging API behavior and webhook processing.
  • Health Checks: Implement a basic health check endpoint (e.g., /api/health) that verifies API and database connectivity, usable by monitoring systems.
  • Error Tracking: Integrate error tracking services (e.g., Sentry, Bugsnag) to capture, aggregate, and alert on exceptions in your RedwoodJS functions.
  • Metrics: Monitor key performance indicators (KPIs): function execution times, invocation counts, error rates, database query latency, and webhook processing times. Use your deployment platform's monitoring tools or integrate dedicated observability platforms (Prometheus/Grafana, Datadog). Track Vonage API usage and costs via the Vonage Dashboard.

11. Frequently Asked Questions

How do I verify Vonage webhook signatures in RedwoodJS?

Install @vonage/jwt and extract the JWT from the Authorization header. Verify it using your signature secret from the Vonage Dashboard. Return a 401 status code for invalid signatures. Webhook signing is enabled by default for Messages API applications created after 2020.

What are the valid Vonage delivery status codes?

Valid DLR status codes per Vonage documentation: accepted (queued), delivered (successfully delivered), buffered (delayed delivery), expired (retry timeout), failed (delivery failed), rejected (carrier rejected), unknown (no information available).

Why is my webhook not receiving delivery status updates?

Check: (1) Vonage application has correct Status URL configured, (2) Virtual number is linked to the application, (3) Account default SMS setting is "Messages API", (4) ngrok is running and forwarding to port 8911, (5) Webhook returns 200 OK within timeout.

How do I handle webhook retries from Vonage?

Return 200 OK immediately to prevent retries. Move database operations to a background queue (BullMQ, Redis Streams) to avoid data loss if internal processing fails after returning 200 OK. Vonage retries webhooks only if it doesn't receive a 2xx status code.

What error codes should I handle for failed SMS delivery?

Critical error codes: 3 (permanent disconnection – remove number), 6 (anti-spam rejection), 39 (illegal US sender), 50-54 (regulatory compliance failures). See the complete error code table for all 99 codes.

How do I test webhooks locally without ngrok?

Use RedwoodJS's mockSignedWebhook testing utility. Create scenario data, mock the webhook payload with proper JWT signature, invoke the handler, and test responses. This approach is faster and more reliable than manual testing with ngrok.

What is the difference between SMS API and Messages API?

Messages API is Vonage's unified multi-channel API supporting SMS, MMS, WhatsApp, Viber, and Facebook Messenger. It uses Application ID/Private Key authentication. SMS API is the legacy single-channel API using API Key/Secret. Set your account default to Messages API in Dashboard settings.

How do I format phone numbers for Vonage Messages API?

Use E.164 format: +[country code][subscriber number] with no spaces or special characters (e.g., +14155551212). The libphonenumber-js library provides robust parsing and validation. Vonage rejects improperly formatted numbers.


12. Troubleshooting and Caveats

  • ngrok Issues:
    • Ensure ngrok is running and forwarding http traffic to the correct Redwood API port (default: 8911).
    • Verify the https URL from ngrok is correctly configured in the Vonage Application settings for both Inbound and Status webhooks.
    • Check the ngrok web interface (usually http://localhost:4040) to inspect incoming requests and responses.
  • Private Key Path Issues:
    • If using VONAGE_PRIVATE_KEY_PATH, double-check the path relative to the built API directory (api/dist) where the function executes, not the source directory.
    • Consider using VONAGE_PRIVATE_KEY_CONTENT environment variable for deployment to avoid path issues. Ensure the variable contains the exact key content, including newlines.
  • Webhook Not Triggering:
    • Confirm the Vonage number is correctly linked to the Application.
    • Ensure the account default SMS setting is "Messages API".
    • Check ngrok logs/interface for incoming requests. If none arrive, the issue might be with Vonage configuration or ngrok setup.
    • Send a test SMS to your Vonage number to trigger the inbound webhook.
    • Send a test SMS from your application to trigger the status webhook (delivery status might take time).
  • Database Errors:
    • Ensure the database is running and accessible from the API function environment.
    • Verify DATABASE_URL in .env is correct.
    • Check Prisma migrations (yarn rw prisma migrate dev) were applied successfully.
  • 405 Method Not Allowed: Ensure Vonage is configured to send POST requests to your webhooks. Check your function code isn't incorrectly rejecting POST.
  • Missing vonageMessageId on Update: This can happen due to timing (status webhook arrives before sendSms DB update completes) or if the status update is for an inbound message. The warning log helps identify this. Consider using client_ref or more robust correlation logic if needed.
  • Authentication Errors: Double-check VONAGE_APPLICATION_ID and the private key (path or content) are correct and accessible. Ensure you are using Application ID/Private Key auth for the Messages API as intended.