code examples

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

Build SMS Marketing Campaigns with Node.js, Express & Vonage Messages API (2025 Guide)

Complete tutorial for building SMS marketing campaigns using Node.js, Express.js, and Vonage Messages API. Includes two-way messaging, webhook integration, TCPA compliance, JWT security, and marketing campaign best practices.

Build SMS Marketing Campaigns with Node.js, Express & Vonage Messages API

Build a complete SMS marketing campaign system using Node.js, Express.js, and the Vonage Messages API. This guide provides a step-by-step walkthrough for creating applications that send and receive SMS messages, forming the foundation for SMS marketing campaigns, notifications, or two-factor authentication.

Learn how to implement two-way SMS messaging, handle webhooks for incoming messages, ensure TCPA compliance with new 2025 opt-out rules, and secure your application with JWT signature verification. You'll build a production-ready system capable of handling thousands of messages while respecting carrier rate limits and regulatory requirements.

What You'll Build:

  1. Send SMS messages from a Vonage virtual number to specified destination numbers
  2. Receive incoming SMS messages via webhook endpoints
  3. Implement STOP/HELP keyword handling for TCPA compliance
  4. Secure webhooks with JWT signature verification
  5. Track messages in a database using Prisma ORM
  6. Handle errors with automatic retry logic

Prerequisites: Basic knowledge of Node.js, Express.js, APIs, webhooks, and environment variable management. Familiarity with async/await patterns and RESTful APIs recommended.

Project Overview and Goals

Goal: To create a robust Node.js service that leverages the Vonage Messages API for sending outgoing SMS and handling incoming SMS messages via webhooks, providing a reusable base for SMS-driven applications.

Problem Solved: This guide addresses the need for developers to integrate SMS capabilities into their Node.js applications reliably and efficiently, handling both sending and receiving workflows required for interactive communication or campaign responses.

Technologies Used:

  • Node.js: A JavaScript runtime environment ideal for building scalable network applications.
  • Express: A minimal and flexible Node.js web application framework used here to create webhook endpoints.
  • Vonage Messages API: A unified API for sending and receiving messages across multiple channels, including SMS. We specifically use this for its flexibility and features compared to older Vonage SMS APIs.
  • @vonage/server-sdk: The official Vonage Node.js SDK for interacting with Vonage APIs.
  • ngrok: A tool to expose local development servers to the internet, necessary for testing webhooks.
  • dotenv: A module to load environment variables from a .env file into process.env.

System Architecture:

text
+-----------------+      +------------------------+      +-------------+      +---------+
| User's Phone    |<---->| Carrier Network        |<---->| Vonage API  |<---->| Carrier |<----> User's Phone
| (Recipient for  |      |                        |      | (Messages   |      | Network |      (Sender/Recipient)
| Outbound SMS,   |      |                        |      |  API)       |      |         |
| Sender for      |      |                        |      +-------------+      +---------+
| Inbound SMS)    |      |                        |      (Webhook POST: Inbound/Status)
+-----------------+      +------------------------+       |    |
                                                   (API Call: Send SMS) |    v
                                                                    +--------------------+
                                                                    | Node.js/Express App|
                                                                    | (Your Server)      |
                                                                    |  - Sends API Req   |
                                                                    |  - Receives Webhook|
                                                                    +--------------------+
                                                                            ^ | (Local Dev via ngrok)
                                                                            | v
                                                                       +----------+
                                                                       |  ngrok   |
                                                                       +----------+

Prerequisites:

1. Setting Up the Project

Let's initialize our Node.js project and install the necessary dependencies.

1. Create Project Directory: Open your terminal and create a new directory for your project, then navigate into it.

bash
mkdir vonage-sms-app
cd vonage-sms-app

2. Initialize Node.js Project: This creates a package.json file to manage dependencies and project metadata.

bash
npm init -y

3. Install Dependencies: We need Express for the web server, the Vonage SDK to interact with the API, and dotenv for managing environment variables securely.

bash
npm install express @vonage/server-sdk dotenv

4. Create Project Files: Create the main files for our application logic and environment variables.

bash
touch index.js server.js .env .gitignore
  • index.js: Will contain the logic for sending SMS messages.
  • server.js: Will contain the Express server logic for receiving SMS messages via webhooks.
  • .env: Will store sensitive credentials like Application ID, Private Key path/content, and phone numbers. Never commit this file to version control.
  • .gitignore: Specifies intentionally untracked files that Git should ignore.

5. Configure .gitignore: Add node_modules and .env to your .gitignore file to prevent committing them.

text
# .gitignore

node_modules/
.env
*.log
private.key # Or wherever you store your private key locally

6. Project Structure: Your project directory should now look like this:

text
vonage-sms-app/
├── node_modules/
├── .env
├── .gitignore
├── index.js
├── package-lock.json
├── package.json
└── server.js

This basic structure separates the sending logic (index.js, often run as a one-off script) from the receiving server logic (server.js, typically run as a long-running process) for clarity and modularity, although they could be combined in a single file for simpler applications. Using .env ensures credentials are kept out of the codebase.

2. Implementing Core Functionality: Sending SMS

We'll start by implementing the code to send an SMS message using the Vonage Messages API.

1. Configure Environment Variables: Open the .env file and add placeholders for your Vonage Application credentials and numbers. You will obtain these values in Section 4 (Integrating with Vonage). Note that the Vonage Node SDK uses Application ID and Private Key for authentication with the Messages API.

dotenv
# .env

# Vonage Application Credentials (Generated when creating a Vonage Application)
# These are typically used by the Vonage SDK for Messages API authentication.
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
VONAGE_APPLICATION_PRIVATE_KEY_PATH=./private.key # Path to your downloaded private key (for local dev)
# VONAGE_PRIVATE_KEY_CONTENT=""-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"" # Optional: For deployments, store key *content* here (ensure newlines are escaped if needed)

# Phone Numbers
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # The Vonage number you purchased
TO_NUMBER=RECIPIENT_PHONE_NUMBER # The number you want to send the SMS to (E.164 format, e.g., 14155550100)

2. Write Sending Logic (index.js): Edit index.js to initialize the Vonage SDK and use the messages.send() method.

javascript
// index.js
require('dotenv').config(); // Load environment variables from .env file
const { Vonage } = require('@vonage/server-sdk');
const { SMS } = require('@vonage/messages');
const fs = require('fs'); // Needed to read the private key file if using path

// --- Configuration ---
// Ensure required environment variables are set
const requiredEnv = [
  'VONAGE_APPLICATION_ID',
  'VONAGE_NUMBER',
  'TO_NUMBER'
];
// Either private key path or content must be provided
const privateKeyPath = process.env.VONAGE_APPLICATION_PRIVATE_KEY_PATH;
const privateKeyContent = process.env.VONAGE_PRIVATE_KEY_CONTENT;

if (!privateKeyPath && !privateKeyContent) {
    console.error('Error: Environment variable VONAGE_APPLICATION_PRIVATE_KEY_PATH or VONAGE_PRIVATE_KEY_CONTENT must be set.');
    process.exit(1);
}
if (privateKeyPath && !privateKeyContent && !fs.existsSync(privateKeyPath)) {
    console.error(`Error: Private key file not found at path: ${privateKeyPath}`);
    process.exit(1);
}

for (const variable of requiredEnv) {
  if (!process.env[variable]) {
    console.error(`Error: Environment variable ${variable} is not set.`);
    process.exit(1); // Exit if essential config is missing
  }
}

// Initialize Vonage SDK using Application ID and Private Key
// Prefers key content ENV var if present, otherwise uses path
const vonage = new Vonage({
  applicationId: process.env.VONAGE_APPLICATION_ID,
  privateKey: privateKeyContent || privateKeyPath,
});

const fromNumber = process.env.VONAGE_NUMBER;
const toNumber = process.env.TO_NUMBER;
const messageText = `Hello from Vonage and Node.js! Sent on ${new Date().toLocaleTimeString()}`;

// --- Send SMS Function ---
async function sendSms() {
  console.log(`Attempting to send SMS from ${fromNumber} to ${toNumber}`);
  console.log(`Message: ""${messageText}""`);

  try {
    const resp = await vonage.messages.send(
      new SMS({
        to: toNumber,
        from: fromNumber,
        text: messageText,
      }),
    );
    console.log('Message sent successfully!');
    console.log('Message UUID:', resp.messageUuid); // Log the unique ID for tracking
  } catch (err) {
    console.error('Error sending SMS:');
    // Log detailed error information if available
    if (err.response && err.response.data) {
      console.error('Status:', err.response.status);
      console.error('Data:', JSON.stringify(err.response.data, null, 2));
    } else {
      console.error(err);
    }
    // Consider adding more robust error handling or retry logic here (See Section 5)
  }
}

// --- Execute Sending ---
sendSms();

Explanation:

  • require('dotenv').config();: Loads the variables from your .env file into process.env.
  • Configuration Check: Basic validation ensures critical environment variables (Application ID, numbers, and either private key path or content) are present before proceeding. It also checks if the key file exists if the path is provided and content is not.
  • new Vonage({...}): Initializes the SDK using your Application ID and the private key. The logic prioritizes using the key content from VONAGE_PRIVATE_KEY_CONTENT if set (common for deployments), otherwise it uses the file path from VONAGE_APPLICATION_PRIVATE_KEY_PATH. The Messages API primarily uses Application ID and Private Key for authentication when using the SDK.
  • vonage.messages.send(...): This is the core function for sending messages.
  • new SMS({...}): We create an SMS message object specifying the to, from, and text parameters. The SDK handles structuring the request correctly for the Messages API.
  • Async/Await: We use async/await for cleaner handling of the asynchronous API call.
  • Response/Error Handling: The code logs the messageUuid on success. The catch block logs detailed error information if the request fails, which is crucial for debugging.

3. Building an API Layer: Receiving SMS via Webhook

To receive SMS messages, Vonage needs a publicly accessible URL (a webhook) to send HTTP POST requests to whenever your virtual number receives a message. We'll use Express to create this endpoint.

1. Write Server Logic (server.js): Edit server.js to set up an Express server listening for POST requests on a specific path.

javascript
// server.js
require('dotenv').config(); // Load environment variables
const express = require('express');
const { json, urlencoded } = express;

const app = express();
const PORT = process.env.PORT || 3000; // Use environment variable for port or default to 3000

// --- Middleware ---
// Parse incoming JSON requests (Vonage webhook format is JSON)
app.use(json());
// Parse URL-encoded requests (less common for Vonage webhooks, but good practice)
app.use(urlencoded({ extended: true }));

// --- Webhook Endpoint ---
// Define the route Vonage will POST to. Match this path in your Vonage Application settings.
const inboundWebhookPath = '/webhooks/inbound';

app.post(inboundWebhookPath, (req, res) => {
  console.log('-----------------------------------------');
  console.log(`Received Inbound SMS at ${new Date().toISOString()}`);
  console.log('Request Body:');
  console.log(JSON.stringify(req.body, null, 2)); // Log the entire incoming payload

  // --- Process the Incoming Message ---
  // Basic validation: Check for expected properties
  if (req.body.from && req.body.to && req.body.text && req.body.message_uuid) {
    console.log(`From: ${req.body.from.number || req.body.from.id}`); // Handle potential variations in 'from' structure
    console.log(`To: ${req.body.to.number || req.body.to.id}`);
    console.log(`Text: ${req.body.text}`);
    console.log(`Message UUID: ${req.body.message_uuid}`);

    // TODO: Add your business logic here (See Section 6 & 8)
    // - Store the message in a database
    // - Check for keywords (e.g., STOP, HELP)
    // - Trigger automated replies
    // - Enqueue tasks for processing

  } else {
    console.warn('Received unexpected webhook format:', req.body);
  }

  // --- Respond to Vonage ---
  // IMPORTANT: Always send a 200 OK status back to Vonage quickly.
  // Failure to do so will cause Vonage to retry sending the webhook,
  // potentially leading to duplicate processing.
  res.status(200).send('OK');
  // Alternatively, use res.status(200).end(); if no body is needed.
  console.log('Sent 200 OK response to Vonage.');
  console.log('-----------------------------------------');
});

// --- Optional: Status Webhook Endpoint ---
// Vonage sends delivery receipts (DLRs) and other status updates here.
const statusWebhookPath = '/webhooks/status';
app.post(statusWebhookPath, (req, res) => {
    console.log('=========================================');
    console.log(`Received Status Update at ${new Date().toISOString()}`);
    console.log('Request Body:');
    console.log(JSON.stringify(req.body, null, 2));

    // TODO: Process the status update (See Section 6 & 8)
    // - Update message status in your database using message_uuid
    // - Handle 'delivered', 'failed', 'rejected' statuses

    res.status(200).send('OK');
    console.log('Sent 200 OK response to Vonage.');
    console.log('=========================================');
});


// --- Health Check Endpoint (Good Practice) ---
app.get('/health', (req, res) => {
  res.status(200).send('Server is healthy');
});

// --- Start Server ---
app.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
  console.log(`Inbound SMS Webhook expected at: POST ${inboundWebhookPath}`);
  console.log(`Status Webhook expected at: POST ${statusWebhookPath}`);
  console.log(`Health check available at: GET /health`);
});

Explanation:

  • Middleware: express.json() is essential for parsing the JSON payload sent by Vonage webhooks. express.urlencoded() is included for general robustness.
  • Webhook Route (/webhooks/inbound): This specific path (POST) listens for incoming messages. The path name can be customized, but it must match the Inbound URL configured in your Vonage application.
  • Logging: The entire request body (req.body) is logged. This is crucial for understanding the data structure Vonage sends. Specific fields like sender (from), recipient (to), message text (text), and message_uuid are logged individually.
  • Status Webhook Route (/webhooks/status): A separate endpoint to receive delivery receipts and status updates for outgoing messages you sent. This is vital for tracking message delivery.
  • res.status(200).send('OK'): This is critical. You must respond to Vonage with a 2xx status code (usually 200 OK) quickly to acknowledge receipt of the webhook. If Vonage doesn't receive this, it will assume the delivery failed and retry, leading to duplicate processing on your end.
  • Health Check: A simple /health endpoint is good practice for monitoring systems to check if the server is running.
  • Port: The server listens on the port specified by the PORT environment variable or defaults to 3000.

4. Integrating with Vonage: Configuration Steps

Now, let's configure your Vonage account and link it to the application code.

1. Purchase a Vonage Virtual Number:

  • Log in to your Vonage API Dashboard.
  • Navigate to Numbers > Buy numbers.
  • Search for numbers with SMS capability in your desired country.
  • Purchase a number.
  • Copy the purchased number (in E.164 format, e.g., 14155550100) into the VONAGE_NUMBER variable in your .env file.

2. Create a Vonage Application: This application links your number, credentials, and webhook URLs together.

  • Navigate to Applications > Create a new application.
  • Give your application a meaningful name (e.g., Node SMS App - Dev).
  • Generate Public/Private Key: Click the Generate public and private key button. This will automatically download the private.key file. Save this file securely in your project directory (or another secure location for local development). Update VONAGE_APPLICATION_PRIVATE_KEY_PATH in your .env file to point to the correct path (e.g., ./private.key if it's in the root). The public key is stored by Vonage. (For deployment, you'll likely use the key content via VONAGE_PRIVATE_KEY_CONTENT - see Section 12 if available, or adapt based on deployment strategy).
  • Capabilities: Toggle on the Messages capability.
  • Configure Webhooks:
    • Inbound URL: Enter YOUR_NGROK_HTTPS_URL/webhooks/inbound (You'll get YOUR_NGROK_HTTPS_URL in the next step). Set the method to POST.
    • Status URL: Enter YOUR_NGROK_HTTPS_URL/webhooks/status. Set the method to POST.
  • Click Generate new application.
  • Copy Application ID: After creation, you'll see the Application ID. Copy this value into the VONAGE_APPLICATION_ID variable in your .env file.

3. Link Your Number to the Application:

  • Go back to Numbers > Your numbers.
  • Find the number you purchased.
  • Click the Link button (or edit icon) next to the number.
  • Select the application you just created (Node SMS App - Dev) from the Forward to Application dropdown under the Messages capability.
  • Click Save.

4. Set Default SMS API (Crucial):

  • Navigate to your main Dashboard Settings.
  • Scroll down to API settings.
  • Find the Default SMS Setting section.
  • Select Messages API as the default handler for SMS. This ensures incoming messages use the Messages API format and webhooks.
  • Click Save changes.

5. Run ngrok: Now that the server code is ready, expose your local server to the internet using ngrok so Vonage can reach your webhooks. Run this in a separate terminal window.

bash
# Make sure your Express server (server.js) is running or will run on port 3000
ngrok http 3000
  • ngrok will display output including a Forwarding URL using https. It looks something like https://<random_string>.ngrok.io.
  • Copy this HTTPS URL.
  • Update Vonage Application: Go back to your Vonage Application settings (Applications > Your App Name > Edit). Paste the copied ngrok HTTPS URL into the Inbound URL and Status URL fields, making sure to append the correct paths (/webhooks/inbound and /webhooks/status).
    • Example Inbound URL: https://a1b2c3d4e5f6.ngrok.io/webhooks/inbound
    • Example Status URL: https://a1b2c3d4e5f6.ngrok.io/webhooks/status
  • Save the changes to your Vonage Application.

Your local development environment is now configured to communicate with Vonage.

5. Implementing Error Handling, Logging, and Retry Mechanisms

Robust applications need proper error handling and logging.

Error Handling:

  • Sending (index.js): The try...catch block already handles errors during the vonage.messages.send() call. It logs detailed error information from err.response.data if available. For production, consider:

    • Specific Error Codes: Check err.response.status or error codes within err.response.data to handle specific issues differently (e.g., invalid number format, insufficient funds).
    • Retry Logic: For transient network errors or Vonage service issues (e.g., 5xx status codes), implement a retry mechanism with exponential backoff using libraries like async-retry or p-retry. Caution: Do not retry errors like invalid number format (4xx errors) indefinitely.

    First, install the library:

    bash
    npm install async-retry

    Then, you can implement retry logic like this:

    javascript
    // Example using async-retry in index.js
    const retry = require('async-retry');
    // Assume vonage, SMS, fromNumber, toNumber, messageText are defined as before
    
    async function sendSmsWithRetry() {
      const smsDetails = new SMS({
        to: toNumber,
        from: fromNumber,
        text: messageText,
      });
    
      try {
        await retry(
          async bail => {
            console.log('Attempting to send SMS...');
            try {
              const resp = await vonage.messages.send(smsDetails);
              console.log('Message sent successfully!', resp.messageUuid);
              // If successful, return the response or true to stop retry
              return resp;
            } catch (err) {
              console.error('Attempt failed:', err.message);
              // Don't retry on non-recoverable errors (e.g., 4xx client errors)
              if (err.response && err.response.status >= 400 && err.response.status < 500) {
                console.error('Non-retriable error:', JSON.stringify(err.response.data, null, 2));
                bail(new Error('Non-retriable error encountered')); // Stop retrying
                return; // Exit async function
              }
              // For other errors (network, 5xx), throw to trigger retry
              throw err;
            }
          },
          {
            retries: 3, // Number of retries
            minTimeout: 1000, // Initial delay ms
            factor: 2, // Exponential backoff factor
            onRetry: (error, attempt) => {
                console.warn(`Retrying sendSMS (Attempt ${attempt}). Error: ${error.message}`);
            }
          }
        );
      } catch (error) {
          console.error('Failed to send SMS after multiple retries:', error.message);
          // Handle final failure (e.g., log to monitoring, notify admin)
      }
    }
    // Replace the simple sendSms() call in index.js with sendSmsWithRetry()
    // sendSmsWithRetry(); // Uncomment to use retry logic
  • Receiving (server.js):

    • Wrap the core logic inside the webhook handlers (app.post) in a try...catch block to handle unexpected errors during message processing (e.g., database errors).
    • Crucially, ensure the res.status(200).send('OK') happens outside the main try...catch or within a finally block if you need to guarantee it, even if your internal processing fails. This prevents Vonage retries for issues unrelated to receiving the webhook itself.
    javascript
    // Example in server.js inbound webhook
    app.post(inboundWebhookPath, (req, res) => {
      try {
        console.log('-----------------------------------------');
        console.log(`Received Inbound SMS at ${new Date().toISOString()}`);
        console.log('Request Body:');
        console.log(JSON.stringify(req.body, null, 2));
    
        // --- Start of Your Processing Logic ---
        if (req.body.from && req.body.to && req.body.text && req.body.message_uuid) {
          console.log(`Processing message from ${req.body.from.number}...`);
          // Your database interaction, keyword checking, etc.
          // Simulate potential error:
          // if (Math.random() > 0.8) throw new Error(""Simulated DB Error!"");
        } else {
          console.warn('Received unexpected webhook format');
        }
        // --- End of Your Processing Logic ---
    
      } catch (error) {
        console.error('!!! Error processing inbound webhook:', error);
        // Log error to your tracking system (e.g., Sentry, Datadog)
      } finally {
        // Always acknowledge receipt to Vonage
        res.status(200).send('OK');
        console.log('Sent 200 OK response to Vonage.');
        console.log('-----------------------------------------');
      }
    });

Logging:

  • The current console.log is suitable for development.

  • For production, use a structured logging library like Pino or Winston:

    • Structured Format (JSON): Easier for log aggregation tools (Datadog, Splunk, ELK stack) to parse.
    • Log Levels: Differentiate between info, warn, error, debug.
    • Log Destinations: Output to files, standard output, or external logging services.

    Install Pino:

    bash
    npm install pino pino-pretty # pino-pretty for dev readability

    Integrate Pino:

    javascript
    // Example integration in server.js
    const pino = require('pino');
    const logger = pino({
      level: process.env.LOG_LEVEL || 'info',
      // Pretty print for development, JSON for production
      transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined,
    });
    
    // Replace console.log with logger methods:
    // console.log(...) -> logger.info(...)
    // console.warn(...) -> logger.warn(...)
    // console.error(...) -> logger.error(...)
    
    // Example in inbound webhook
    app.post(inboundWebhookPath, (req, res) => {
      const logData = { webhook: 'inbound', body: req.body };
      logger.info(logData, 'Received Inbound SMS');
      try {
         // ... processing ...
         if (req.body.from && req.body.text && req.body.message_uuid) {
            logger.info(`Processing message from ${req.body.from.number}`);
         } else {
            logger.warn(logData, 'Unexpected webhook format');
         }
      } catch (error) {
         logger.error({ err: error, webhook: 'inbound' }, 'Error processing inbound webhook');
      } finally {
         res.status(200).send('OK');
         logger.info({ webhook: 'inbound' }, 'Sent 200 OK response');
      }
    });
    
    // Similar replacements in status webhook and server start logging
    
    app.listen(PORT, () => {
      logger.info(`Server listening on port ${PORT}`);
      logger.info(`Inbound SMS Webhook expected at: POST ${inboundWebhookPath}`);
      logger.info(`Status Webhook expected at: POST ${statusWebhookPath}`);
      logger.info(`Health check available at: GET /health`);
    });

Retry Mechanisms:

  • Vonage Webhook Retries: Vonage automatically retries sending webhooks if it doesn't receive a 2xx response within a certain timeout (usually a few seconds). It uses an exponential backoff strategy. This handles temporary network issues between Vonage and your server. Your primary responsibility is to respond 200 OK promptly.
  • Application-Level Retries (Sending): As shown above with async-retry, implement this for sending SMS if you need resilience against temporary API failures on the Vonage side or network issues from your server to Vonage.

6. Creating a Database Schema and Data Layer (Conceptual)

While this basic guide doesn't implement a database, a real-world application (especially for campaigns) needs one.

Purpose:

  • Log sent and received messages for audit trails and debugging.
  • Track message delivery status using updates from the Status Webhook.
  • Manage recipient lists and opt-out status for campaigns.
  • Store campaign definitions and track progress.

Conceptual Schema (using Prisma as an example ORM):

prisma
// prisma/schema.prisma

generator client {
  provider = ""prisma-client-js""
}

datasource db {
  provider = ""postgresql"" // Or mysql, sqlite, etc.
  url      = env(""DATABASE_URL"")
}

model Message {
  id                String    @id @default(cuid())
  vonageMessageUuid String?   @unique // From sending response or status/inbound webhook
  direction         Direction // INBOUND or OUTBOUND
  fromNumber        String
  toNumber          String
  text              String?
  status            String?   // e.g., 'submitted', 'delivered', 'failed', 'read', 'received'
  vonageStatus      String?   // Raw status code from Vonage (e.g., from status webhook)
  errorCode         String?   // Error code if status is 'failed' or 'rejected' (from status webhook)
  price             Decimal?  // Cost from Vonage status/usage data
  currency          String?
  submittedAt       DateTime  @default(now()) // When our system processed/sent it
  receivedAt        DateTime? // When our system received it (for inbound)
  vonageTimestamp   DateTime? // Timestamp from Vonage webhook
  lastUpdatedAt     DateTime  @updatedAt

  @@index([vonageMessageUuid])
  @@index([toNumber])
  @@index([fromNumber])
  @@index([status])
  @@index([submittedAt])
}

enum Direction {
  INBOUND
  OUTBOUND
}

// Add models for Campaigns, Recipients, OptOuts etc. as needed

Implementation Steps (High-Level):

  1. Choose ORM/Driver: Select Prisma, Sequelize, TypeORM, or a database driver (pg, mysql2).
  2. Install Dependencies: npm install @prisma/client and npm install -D prisma.
  3. Initialize Prisma: npx prisma init.
  4. Define Schema: Create models in prisma/schema.prisma.
  5. Set DATABASE_URL: Add your database connection string to .env.
  6. Run Migrations: npx prisma migrate dev --name init to create tables.
  7. Generate Client: npx prisma generate.
  8. Use Prisma Client: Import and use the client in your index.js and server.js to interact with the database.
javascript
// Example usage in server.js webhook handler
// Assumes logger is defined as in Section 5
// Assumes Prisma client is initialized
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient(); // Initialize Prisma Client

// Inside app.post('/webhooks/inbound', async (req, res) => { ... });
// Make the handler async to use await
app.post(inboundWebhookPath, async (req, res) => { // Note: async handler
  const logData = { webhook: 'inbound', body: req.body };
  // Use logger if integrated, otherwise console.log
  // logger.info(logData, 'Received Inbound SMS');
  console.log('Received Inbound SMS:', logData);

  try {
    if (req.body.from && req.body.text && req.body.to && req.body.message_uuid) {
       const newMessage = await prisma.message.create({
          data: {
            vonageMessageUuid: req.body.message_uuid,
            direction: 'INBOUND',
            fromNumber: req.body.from.number,
            toNumber: req.body.to.number,
            text: req.body.text,
            status: 'received', // Initial status for inbound
            vonageTimestamp: req.body.timestamp ? new Date(req.body.timestamp) : new Date(), // Use Vonage timestamp if available
            receivedAt: new Date(), // Record when our server got it
          }
       });
       // logger.info({ messageId: newMessage.id }, 'Inbound message saved to DB');
       console.log('Inbound message saved to DB:', newMessage.id);

       // Check for STOP keyword (Example opt-out logic)
       if (req.body.text.trim().toUpperCase() === 'STOP') {
           // Add logic to mark fromNumber as opted-out in your recipient/opt-out table
           // logger.warn({ number: req.body.from.number }, 'Received STOP keyword - Processing opt-out');
           console.warn('Received STOP keyword - Processing opt-out for:', req.body.from.number);
           // await prisma.optOut.upsert(...) or similar
       }
       // TODO: Add other keyword handling (HELP, etc.) or business logic

       // **IMPORTANT: TCPA Compliance for Marketing Campaigns**
       //
       // If you're building SMS marketing campaigns, [new TCPA opt-out rules effective April 11, 2025](https://www.bclplaw.com/en-US/events-insights-news/the-tcpas-new-opt-out-rules-take-effect-on-april-11-2025-what-does-this-mean-for-businesses.html) require significant changes:
       //
       // *   **Expanded Opt-Out Methods:** You must honor opt-out requests through ["any reasonable means,"](https://activeprospect.com/blog/tcpa-opt-out-requirements/) not just "STOP" – including emails, voicemails, or informal messages like "Leave me alone."
       // *   **10-Business-Day Deadline:** [You have just 10 business days to stop all SMS communications](https://www.textmymainnumber.com/blog/sms-compliance-in-2025-your-tcpa-text-message-compliance-checklist) after receiving an opt-out request (reduced from up to 30 days).
       // *   **STOP Keyword Required:** Always include opt-out instructions with every message sent. [Implement 'STOP' response capabilities](https://www.textedly.com/sms-compliance-guide/tcpa-compliance-checklist) where customers can text "STOP" to cease communications immediately.
       // *   **HELP Keyword:** Include "Text HELP for more information" in your initial opt-in message alongside other disclosures.
       // *   **Confirmation Messages:** You're allowed to send a one-time follow-up message to confirm opt-out preferences, but [only if sent within 5 minutes](https://activeprospect.com/blog/tcpa-opt-out-requirements/) and containing no promotional content.
       // *   **Prior Express Written Consent:** [Obtain "prior express written consent"](https://www.textedly.com/sms-compliance-guide/tcpa-compliance-checklist) before sending marketing texts, with clear disclosures about message type, charges ("message and data rates may apply"), and opt-out options.
       // *   **Record-Keeping:** [Retain documentation of opt-out requests for at least 4 years](https://www.carltonfields.com/insights/publications/2025/mastering-the-new-tcpa-opt-out-regulations) (TCPA statute of limitations).
       // *   **Penalties:** TCPA violations carry [$500-1,500 per violation](https://activeprospect.com/blog/tcpa-text-messages/) with no requirement to prove actual injury.
       //
       // **Best Practices for Marketing Campaigns:**
       // *   [Maintain 95%+ deliverability rates](https://www.infobip.com/blog/sms-campaign-best-practices) as an indicator of program health
       // *   [Send 2-4 messages per month maximum](https://www.omnisend.com/blog/sms-marketing/) to avoid high opt-out rates
       // *   [Follow 8 AM to 9 PM local time guidelines](https://www.klaviyo.com/products/sms-marketing/best-practices) per carrier requirements
       // *   [Register for 10DLC (10-Digit Long Code)](https://www.infobip.com/blog/sms-campaign-best-practices) for US messaging to improve deliverability (up to 60 msg/sec vs. 1 msg/sec unverified)
    } else {
      // logger.warn(logData, 'Received unexpected webhook format');
      console.warn('Received unexpected webhook format:', logData);
    }

  } catch (error) {
      // logger.error({ err: error, webhook: 'inbound' }, 'Error processing/saving inbound webhook');
      console.error('Error processing/saving inbound webhook:', error);
      // Even if DB fails, we should still acknowledge receipt to Vonage
  } finally {
     res.status(200).send('OK');
     // logger.info({ webhook: 'inbound' }, 'Sent 200 OK response');
     console.log('Sent 200 OK response to Vonage.');
  }
});

// Similar logic needed for the status webhook:
// Find the message by vonageMessageUuid and update its status, errorCode, price etc.
// Example:
// app.post(statusWebhookPath, async (req, res) => {
//   try {
//     const { message_uuid, status, timestamp, error, price, currency } = req.body;
//     if (message_uuid) {
//       await prisma.message.update({
//         where: { vonageMessageUuid: message_uuid },
//         data: {
//           status: status, // Update with the received status
//           vonageStatus: status, // Store raw Vonage status if needed
//           errorCode: error ? error.code : null, // Store error code if present
//           price: price ? parseFloat(price) : null,
//           currency: currency,
//           vonageTimestamp: timestamp ? new Date(timestamp) : new Date(),
//           lastUpdatedAt: new Date(), // Explicitly set or rely on @updatedAt
//         }
//       });
//       console.log(`Updated status for message ${message_uuid} to ${status}`);
//     } else {
//       console.warn('Received status webhook without message_uuid:', req.body);
//     }
//   } catch (error) {
//     console.error('Error processing status webhook:', error);
//   } finally {
//     res.status(200).send('OK');
//   }
// });

Note: These code snippets assume you have fully set up Prisma (schema, database connection, migrations, client generation) as outlined in the high-level steps above. They will not run correctly without that setup.

7. Adding Security Features

Securing your application and webhook endpoints is crucial.

  • Webhook Security:
    • Signed Webhooks (JWT Verification - Recommended): The Vonage Messages API supports webhook signing using JSON Web Tokens (JWT) for verification. It is strongly recommended to implement JWT signature verification on your webhook endpoints to ensure requests genuinely originate from Vonage. Consult the Vonage Server SDK documentation or Messages API security guides for details on how to validate the incoming JWT, typically found in the Authorization header (e.g., Bearer <token>). The @vonage/server-sdk may offer helper functions for this (check its documentation for verifySignature or similar methods).
    • Obscurity: Use long, unguessable paths for your webhook URLs (less secure than signing, but better than simple paths like /webhook).
    • IP Whitelisting (Less Flexible): If Vonage publishes a list of egress IP addresses for webhooks, you could configure your firewall/load balancer to only accept requests from those IPs. This is often brittle as IPs can change without notice. Signature verification is generally preferred.
  • Input Validation:
    • Always validate data received from webhooks before processing or storing it (e.g., check expected data types, lengths, formats). Libraries like joi or zod can help define schemas for validation.
    • Sanitize any data that might be displayed back to users or used in database queries to prevent injection attacks (though ORMs often handle SQL injection).
  • Environment Variables: Keep sensitive information (API keys, private keys, database URLs) out of your codebase using environment variables and a .env file (added to .gitignore). For production deployments, use secure environment variable management provided by your hosting platform (e.g., AWS Secrets Manager, Google Secret Manager, Heroku Config Vars).
  • Rate Limiting: Implement rate limiting on your webhook endpoints (using middleware like express-rate-limit) to prevent abuse or denial-of-service attacks.
  • HTTPS: Always use HTTPS for your webhook URLs (ngrok provides this for local testing; production deployments must have valid SSL/TLS certificates).

Frequently Asked Questions About SMS Marketing with Node.js and Vonage

How do I handle STOP keyword opt-outs for TCPA compliance?

Implement keyword detection in your webhook handler by checking if req.body.text.trim().toUpperCase() === 'STOP'. When detected, immediately mark the sender's number as opted-out in your database (using Prisma's upsert or similar). New TCPA rules effective April 11, 2025 require you to stop all communications within 10 business days and honor opt-outs through "any reasonable means," not just the STOP keyword. Send a confirmation message within 5 minutes containing no promotional content.

What Node.js version should I use for this Express SMS application?

Use Node.js 22.x (Active LTS until October 2025) or Node.js 20.x (maintenance mode) for production deployments. This guide uses Express.js 5.1.0, which requires Node.js 18+. Node.js 18.x reaches end-of-life on April 30, 2025, so upgrade to Node.js 22 for extended support (maintenance until April 2027).

How do I verify Vonage webhook signatures with JWT?

Vonage uses JWT Bearer Authorization (HMAC-SHA256) for webhook signing. Extract the JWT from the Authorization: Bearer <token> header, decode it using your signature secret from the dashboard (minimum 32 bits recommended), verify the payload_hash claim matches an SHA-256 hash of the request body to prevent replay attacks, and check the iat (issued at) timestamp to reject stale tokens. The @vonage/server-sdk may offer helper functions like verifySignature.

What are the E.164 phone number format requirements for Vonage?

E.164 format requires: no leading + or 00, starts with country code, maximum 15 digits, and no trunk prefix 0 after country code. Example: US number 212 123 1234 becomes 14155552671. Vonage requires proper E.164 formatting for all API calls. Use validation libraries like google-libphonenumber or implement regex validation before sending.

What are the alternatives to ngrok for webhook testing?

Top ngrok alternatives in 2025 include: Pinggy (unlimited bandwidth, UDP support, no signup required), Localtunnel (npm package, quick testing), Cloudflare Tunnel (no bandwidth limits on free plan), Zrok (open-source, zero-trust networking), and Tunnelmole (open-source, works with Node, Docker, Python). For CI/CD webhook testing, consider InstaTunnel or LocalXpose.

How do I handle SMS marketing campaign compliance in 2025?

Follow TCPA compliance requirements: obtain prior express written consent before sending marketing messages, include clear disclosures about message type and charges ("message and data rates may apply"), provide opt-out instructions with every message, process opt-outs within 10 business days, maintain 95%+ deliverability rates, send 2-4 messages per month maximum, follow 8 AM to 9 PM local time guidelines, and register for 10DLC for US messaging to improve deliverability (up to 60 msg/sec vs. 1 msg/sec unverified).

What Prisma version should I use for message tracking?

Use Prisma ORM 6.16.0 or later (latest as of 2025). The Rust-free architecture is production-ready with ~90% smaller bundle size, faster queries, and lower CPU footprint. The new Query Compiler replaces legacy query engine with TypeScript implementation. For edge runtimes like Vercel Edge, use the prisma-client generator with engineType: "client". Prisma 6.16.0 stabilized driverAdapters and queryCompiler features.

How do I implement retry logic for failed SMS sends?

Install async-retry (npm install async-retry) and wrap vonage.messages.send() with retry logic: set retries: 3 for 3 attempts, minTimeout: 1000 for initial 1-second delay, factor: 2 for exponential backoff. Important: Don't retry 4xx client errors (invalid number format, insufficient funds) – only retry network errors and 5xx server errors. Check err.response.status to determine if error is retriable. Use bail() to stop retrying non-recoverable errors.

What database indexes should I create for SMS message tracking?

Create indexes on frequently queried columns in your Prisma schema: @@index([vonageMessageUuid]) for webhook lookups, @@index([toNumber]) and @@index([fromNumber]) for recipient/sender queries, @@index([status]) for filtering by delivery status (submitted, delivered, failed), and @@index([submittedAt]) for time-based queries. Add composite index @@index([toNumber, status]) for efficient opt-out status checks.

How do I handle SMS delivery receipts from Vonage?

Create a separate webhook endpoint (e.g., /webhooks/status) to receive Delivery Receipts (DLRs). Extract message_uuid, status (delivered, failed, rejected), timestamp, error.code, price, and currency from the request body. Use prisma.message.update() with where: { vonageMessageUuid: message_uuid } to update the corresponding database record. Always respond with 200 OK quickly to prevent Vonage retries. Track statuses: submitted → delivered/failed/rejected.


Next Steps for Production SMS Marketing Campaigns

Now that you've built your SMS foundation, enhance it with these production features:

  1. Implement JWT Webhook Verification – Secure your /webhooks/inbound and /webhooks/status endpoints with JWT signature validation using the HMAC-SHA256 secret from your dashboard. Verify payload_hash and iat claims to prevent replay attacks.

  2. Add TCPA-Compliant Opt-Out System – Create OptOut Prisma model with phoneNumber, optOutDate, and method fields. Process not just "STOP" but "any reasonable means" including informal messages. Implement 10-business-day deadline enforcement and 4-year record retention.

  3. Implement 10DLC Registration – Register your 10DLC Brand and Campaign through the Vonage dashboard to improve US deliverability rates (60 msg/sec vs. 1 msg/sec unverified). Provide business verification, campaign use case, and sample messages for carrier approval.

  4. Add Structured Logging with Pino – Replace console.log with Pino for JSON-structured logs: logger.info({ webhook: 'inbound', from: number }, 'Received SMS'). Configure log levels (info, warn, error) and use pino-pretty for development readability.

  5. Create Campaign Management System – Build Prisma models for Campaign, Recipient, and CampaignMessage. Track campaign progress, schedule sends, segment recipients, and monitor performance metrics (sent, delivered, failed, opt-out rates).

  6. Implement Rate Limiting – Use express-rate-limit middleware to protect webhook endpoints: app.use('/webhooks', rateLimit({ windowMs: 60000, max: 100 })). Prevent abuse and DoS attacks while allowing legitimate Vonage traffic.

  7. Add Delivery Analytics Dashboard – Query Prisma for aggregate metrics: total sent, delivery rate (status: 'delivered'), failure rate, average delivery time, opt-out percentage. Group by date range, campaign, or recipient segment.

  8. Configure Webhook Retry Handling – Vonage automatically retries failed webhooks with exponential backoff. Ensure idempotency by checking vonageMessageUuid uniqueness before creating database records. Use prisma.message.upsert() to handle duplicate webhook deliveries.

  9. Implement HELP Keyword Auto-Response – Detect "HELP" keyword in inbound messages and automatically reply with support information: "For help, visit example.com/support or call 555-0100. Reply STOP to unsubscribe. Msg&data rates may apply."

  10. Add Environment-Specific Configurations – Create separate .env.development, .env.staging, and .env.production files. Use different Vonage numbers, database URLs, and logging levels per environment. Implement dotenv-flow for automatic environment loading.

Additional Resources:

Frequently Asked Questions

How to send SMS with Node.js and Vonage?

Use the Vonage Messages API with the Node.js SDK. Initialize the SDK with your API credentials, then use `vonage.messages.send()` with `to`, `from`, and `text` parameters. This sends an SMS from your Vonage number to the specified recipient.

What is the Vonage Messages API?

The Vonage Messages API is a unified API for sending and receiving messages across various channels, including SMS. It offers more flexibility and features compared to older Vonage SMS APIs and is used in this guide for two-way SMS communication.

Why does Vonage need a webhook URL for SMS?

Vonage uses webhooks (publicly accessible URLs) to send real-time notifications to your application whenever an SMS is received on your Vonage virtual number or when delivery status updates. This allows you to process incoming messages and track delivery efficiently.

When should I use the Vonage Messages API?

Use the Vonage Messages API for projects requiring SMS capabilities, such as building SMS marketing campaigns, setting up automated notifications, implementing two-factor authentication, or creating interactive SMS services.

Can I receive SMS with this Node.js setup?

Yes, create a webhook endpoint in your Node.js/Express application. When your Vonage number receives an SMS, Vonage sends an HTTP POST request to this endpoint. Ensure the path you define in your server matches the Inbound URL configured in your Vonage application.

How to set up a Vonage application for SMS?

In the Vonage Dashboard, create a new application, enable the Messages capability, generate public/private keys (securely store the private key), then configure Inbound and Status URLs pointing to your application's webhooks.

What is ngrok used for with Vonage?

ngrok creates a secure tunnel to your local development server, providing a temporary public HTTPS URL. This is essential for testing webhooks locally because Vonage needs to reach your server, which would otherwise be inaccessible from the internet.

How to handle inbound SMS webhooks in Node.js?

Use Express.js to create a POST route that matches the Inbound URL in your Vonage application settings. Log the request body and process incoming message data, ensuring a quick '200 OK' response to prevent Vonage retries.

What is the purpose of the status webhook?

The status webhook provides updates on the delivery status of outbound SMS messages. It sends information like 'delivered', 'failed', 'rejected', allowing you to track message delivery success or troubleshoot issues.

How to implement retry logic for sending SMS?

Use a library like `async-retry` to handle temporary network issues or Vonage API failures. Configure the retry attempts, delay, and exponential backoff. Ensure non-recoverable errors (like incorrect number formats) do not trigger infinite retries.

Why is responding '200 OK' to Vonage webhooks important?

A 200 OK response tells Vonage that your server successfully received the webhook. Without it, Vonage will retry sending the request, potentially leading to duplicate message processing and errors in your application.

What security considerations are there with Vonage SMS integration?

Use signed webhooks (JWT verification) for secure communication between Vonage and your application. Validate all user inputs and use secure storage for your API keys. Also, implement rate limiting to prevent abuse.

How do I manage Vonage API keys and credentials?

Store your Vonage Application ID and private key securely. During development, you can store these in a .env file (listed in .gitignore) and load them with the dotenv library. For production, use your platform's secure secret storage.

What database schema should I use for SMS records?

A suitable schema should log inbound/outbound messages, track delivery statuses (using the message_uuid), manage recipients (for campaigns), and store campaign details. Using an ORM like Prisma simplifies database interactions.