code examples

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

How to Build SMS Marketing Campaigns with Vonage Node.js Express

Learn how to send SMS marketing campaigns using Node.js, Express, and Vonage Messages API. Step-by-step tutorial covers bulk SMS sending, TCPA compliance, webhooks, rate limiting, and database integration for production systems.

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

Learn how to build a production-ready SMS marketing campaign system using Node.js, Express, and the Vonage Messages API. This comprehensive tutorial covers everything from initial setup to deployment, including database integration, TCPA compliance, webhook handling, and rate limiting strategies.

Critical Compliance Notice: SMS marketing in the United States is regulated by the Telephone Consumer Protection Act (TCPA). You must obtain prior express written consent (PEWC) from recipients before sending marketing messages. Required consent elements include: your business name, clear statement about receiving marketing texts, disclosure that consent is not required for purchase, message/data rate notices, opt-out instructions (e.g., "Reply STOP"), and message frequency. Process opt-outs within 10 business days. TCPA violations carry penalties of $500–$1,500 per message, plus potential class-action exposure. Only send messages between 8 AM–9 PM recipient local time. This guide implements technical opt-out mechanisms but does not cover consent collection—consult legal counsel before deploying SMS marketing campaigns. See FCC TCPA Guidelines.

By the end of this Node.js SMS tutorial, you will have a functional application capable of:

  • Managing a contact list of SMS subscribers with subscription status tracking
  • Creating and sending bulk SMS marketing campaigns to subscribed contacts
  • Receiving incoming SMS messages and handling "STOP" requests for unsubscribes
  • Tracking message delivery statuses via Vonage webhooks

This system addresses the common business need to engage customers via SMS for marketing promotions, alerts, or updates, while respecting user preferences for opting out.

What You'll Build: SMS Marketing System Overview

Build a robust backend service that powers SMS marketing efforts. Send personalized or bulk messages via Vonage and handle inbound messages for critical functions like unsubscribes.

Technologies Used:

  • Node.js: A JavaScript runtime environment ideal for building scalable network applications and SMS APIs.
  • Express: A minimal and flexible Node.js web application framework providing essential web features for building REST APIs.
  • Vonage Messages API: A powerful API for sending and receiving messages across multiple channels, including SMS. We'll use its Node.js SDK (@vonage/server-sdk).
  • ngrok: A tool to expose local servers to the internet for webhook testing during development.
  • (Optional) Database: A persistent storage solution (e.g., PostgreSQL, MySQL, MongoDB) to store contact information, campaign details, and message logs. We'll illustrate concepts, but specific implementation might vary. For simplicity, initial examples might use in-memory storage, but database integration is crucial for production.
  • (Optional) ORM: Tools like Prisma or Sequelize can simplify database interactions.

Important Technical Notes:

  • SDK Version: This guide uses @vonage/server-sdk v3.24.1 (latest as of January 2025). Version 3.x provides Promise-based APIs, TypeScript support, and improved error handling over v2.x.
  • SMS Character Limits: Single SMS messages using GSM-7 encoding support up to 160 characters. Multi-part messages use 153 characters per segment (7 characters reserved for concatenation headers). Unicode messages are limited to 70 characters (single) or 67 characters per segment (multi-part). Plan message content accordingly to control costs.
  • Rate Limits: Vonage default throughput is 30 API requests per second (up to 2.6M messages/day). US 10DLC numbers have carrier-specific limits: T-Mobile uses brand trust scores, other carriers typically allow 600 texts per minute per number. Factor rate limiting into campaign design to avoid throttling or carrier filtering.

System Architecture:

The architecture involves an Admin/User interacting with an API Layer (Express). This layer communicates with Campaign and Contact Services, which interact with a Database and the Vonage SDK. The SDK sends SMS via the Vonage API to the User's Phone. Replies (like "STOP") and status updates from the User's Phone go back through the Vonage API, triggering Inbound and Status Webhooks handled by the Express application, which update the Contact Service and Database.

Prerequisites:

  • A Vonage API account (Sign up at vonage.com)
  • Node.js and npm (or yarn) installed on your development machine
  • A Vonage virtual phone number capable of sending/receiving SMS
  • ngrok installed and configured (a free account is sufficient)
  • Basic understanding of JavaScript, Node.js, and REST APIs
  • (Optional but recommended) A database system installed and running

Final Outcome:

A Node.js Express application with API endpoints to manage contacts and campaigns, logic to send SMS messages via Vonage Messages API, and webhook handlers to process inbound messages and status updates.

1. How to Set Up Your Node.js SMS Project

Let's initialize our Node.js project and install necessary dependencies for sending SMS with Vonage.

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

    bash
    mkdir vonage-sms-campaigns
    cd vonage-sms-campaigns
  2. Initialize Node.js Project: This creates a package.json file to manage project dependencies and scripts.

    bash
    npm init -y
  3. Install Dependencies: We need Express for the web server, the Vonage SDK for SMS functionality, and dotenv for managing environment variables securely.

    bash
    npm install express @vonage/server-sdk dotenv
    • express: Web framework for building REST APIs
    • @vonage/server-sdk: Official Vonage SDK for Node.js
    • dotenv: Loads environment variables from a .env file into process.env
  4. Project Structure: Create the following basic structure:

    text
    vonage-sms-campaigns/
    ├── src/
    │   ├── services/
    │   │   ├── vonage.service.js  # Vonage SDK initialization and interaction logic
    │   │   └── campaign.service.js # Logic for sending campaigns
    │   ├── routes/
    │   │   ├── api.js             # Main API router
    │   │   └── webhooks.js        # Webhook handlers
    │   ├── controllers/
    │   │   ├── campaign.controller.js # API request handlers for campaigns
    │   │   └── contact.controller.js  # API request handlers for contacts
    │   ├── models/                # (Optional) Database models/schemas
    │   │   └── contact.model.js
    │   └── app.js                 # Express application setup
    ├── .env                       # Environment variables (DO NOT COMMIT)
    ├── .gitignore                 # Files/folders to ignore in Git
    ├── package.json
    └── server.js                  # Main entry point

    This structure promotes separation of concerns, making the application easier to manage and scale.

  5. Create .gitignore: Prevent sensitive files and unnecessary folders from being committed to version control.

    plaintext
    # .gitignore
    node_modules/
    .env
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    private.key
  6. Create .env file: Store sensitive credentials and configuration here. We'll populate this later.

    plaintext
    # .env
    PORT=3000
    BASE_URL= # Will be set later using ngrok
    VONAGE_API_KEY=
    VONAGE_API_SECRET=
    VONAGE_APPLICATION_ID=
    VONAGE_PRIVATE_KEY_PATH=./private.key # Assuming private key is in project root
    VONAGE_NUMBER= # Your Vonage virtual number
    DATABASE_URL= # (Optional) Your database connection string

    Crucially, ensure your VONAGE_PRIVATE_KEY_PATH points to the actual location where you save the private.key file generated by Vonage (see Step 4). Adding private.key to .gitignore is vital for security.

  7. Basic Server Entry Point (server.js): This file loads environment variables and starts the Express app.

    javascript
    // server.js
    require('dotenv').config(); // Load .env variables first
    const app = require('./src/app');
    
    const PORT = process.env.PORT || 3000;
    
    app.listen(PORT, () => {
      console.log(`Server listening on port ${PORT}`);
      if (!process.env.BASE_URL) {
        console.warn('WARN: BASE_URL environment variable not set. Webhooks may not function correctly until ngrok is running and BASE_URL is updated.');
      }
      // Log essential Vonage config to verify it's loaded (avoid logging secrets in production)
      console.log(`Vonage Number: ${process.env.VONAGE_NUMBER ? 'Loaded' : 'MISSING'}`);
      console.log(`Vonage App ID: ${process.env.VONAGE_APPLICATION_ID ? 'Loaded' : 'MISSING'}`);
      console.log(`Vonage API Key: ${process.env.VONAGE_API_KEY ? 'Loaded' : 'MISSING'}`);
      console.log(`Private Key Path: ${process.env.VONAGE_PRIVATE_KEY_PATH ? 'Loaded' : 'MISSING'}`);
    });
  8. Basic Express App Setup (src/app.js): Configure the Express application, middleware, and routes.

    javascript
    // src/app.js
    const express = require('express');
    const apiRoutes = require('./routes/api');
    const webhookRoutes = require('./routes/webhooks');
    
    const app = express();
    
    // Middleware
    app.use(express.json()); // Parse JSON request bodies
    app.use(express.urlencoded({ extended: true })); // Parse URL-encoded request bodies
    
    // Simple Logging Middleware (replace with a proper logger like Winston or Pino in production)
    app.use((req, res, next) => {
      console.log(`${new Date().toISOString()} - ${req.method} ${req.originalUrl}`);
      next();
    });
    
    // Routes
    app.get('/health', (req, res) => {
      res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
    });
    app.use('/api', apiRoutes);
    app.use('/webhooks', webhookRoutes);
    
    // Basic Error Handling Middleware (improve for production)
    app.use((err, req, res, next) => {
      console.error("Error:", err.stack || err.message || err);
      res.status(err.status || 500).json({
        error: {
          message: err.message || 'Internal Server Error',
        },
      });
    });
    
    module.exports = app;

This establishes the foundational structure and configuration for our SMS marketing application.

2. How to Send SMS Messages with Vonage Node.js SDK

Now, let's implement the core logic for sending SMS and handling Vonage interactions.

  1. Vonage Service (src/services/vonage.service.js): Initialize the Vonage SDK and create functions for sending messages.

    javascript
    // src/services/vonage.service.js
    const { Vonage } = require('@vonage/server-sdk');
    const path = require('path');
    
    // Ensure environment variables are loaded
    if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET || !process.env.VONAGE_APPLICATION_ID || !process.env.VONAGE_PRIVATE_KEY_PATH) {
      throw new Error('Vonage API credentials missing in environment variables.');
    }
    
    // Resolve the private key path relative to the project root
    const privateKeyPath = path.resolve(process.cwd(), process.env.VONAGE_PRIVATE_KEY_PATH);
    
    const vonage = new Vonage({
      apiKey: process.env.VONAGE_API_KEY,
      apiSecret: process.env.VONAGE_API_SECRET, // Note: Secret might not be needed for Messages API with private key auth
      applicationId: process.env.VONAGE_APPLICATION_ID,
      privateKey: privateKeyPath, // Use the resolved absolute path
    });
    
    /**
     * Sends an SMS message using the Vonage Messages API.
     * @param {string} to - The recipient phone number (E.164 format).
     * @param {string} text - The message content.
     * @returns {Promise<string>} - The message UUID on success.
     * @throws {Error} - If sending fails.
     */
    async function sendSms(to, text) {
      const from = process.env.VONAGE_NUMBER;
      if (!from) {
        throw new Error('VONAGE_NUMBER environment variable is not set.');
      }
      if (!to || !text) {
          throw new Error('Recipient number (to) and message text are required.');
      }
    
      console.log(`Attempting to send SMS from ${from} to ${to}`);
    
      try {
        // Use vonage.messages.send for the Messages API
        const resp = await vonage.messages.send({
          message_type: 'text',
          text: text,
          to: to,
          from: from,
          channel: 'sms',
        });
        console.log(`Message sent successfully to ${to}. UUID: ${resp.message_uuid}`);
        return resp.message_uuid; // Return the message UUID for tracking
      } catch (err) {
        console.error(`Error sending SMS to ${to}:`, err.response ? err.response.data : err.message);
        // Rethrow a more specific error or handle it as needed
        throw new Error(`Failed to send SMS via Vonage: ${err.message}`);
      }
    }
    
    module.exports = {
      sendSms,
      // Add other Vonage related functions here if needed
    };
    • Why Private Key? The Messages API primarily uses Application ID and Private Key for authentication, ensuring more secure communication than just API Key/Secret for sending.
    • Why vonage.messages.send? Research highlights using the newer Messages API (vonage.messages.send) over the older SMS API (vonage.message.sendSms) for broader channel support and features, even if we only use SMS here. Ensure your Vonage account is configured to use the Messages API as default for SMS (see Step 4).
    • Error Handling: The try...catch block is crucial for handling potential API errors (e.g., invalid number, insufficient funds, network issues). Logging the error details helps debugging.
  2. Campaign Service (src/services/campaign.service.js): This service will orchestrate sending a campaign to multiple contacts. For now, we'll use a simple in-memory array for contacts. Replace this with database interaction later (Step 6).

    javascript
    // src/services/campaign.service.js
    const { sendSms } = require('./vonage.service');
    
    // --- In-Memory Store (Replace with Database in Step 6) ---
    let contacts = [
      // Example: { number: '14155550100', status: 'subscribed' }
    ];
    let messageLogs = []; // Store message attempts { contactNumber, campaignId, messageUuid, status, timestamp }
    // --- End In-Memory Store ---
    
    /**
     * Sends a campaign message to all subscribed contacts.
     * @param {string} campaignId - An identifier for the campaign.
     * @param {string} messageText - The text of the SMS message.
     * @param {number} delayMs - Optional delay between sending messages in milliseconds (for rate limiting).
     */
    async function sendCampaign(campaignId, messageText, delayMs = 100) { // Add small default delay
      console.log(`Starting campaign: ${campaignId}`);
      const subscribedContacts = contacts.filter(c => c.status === 'subscribed');
    
      if (subscribedContacts.length === 0) {
        console.log(`Campaign ${campaignId}: No subscribed contacts to send to.`);
        return;
      }
    
      console.log(`Campaign ${campaignId}: Sending to ${subscribedContacts.length} contacts.`);
    
      for (const contact of subscribedContacts) {
        try {
          const messageUuid = await sendSms(contact.number, messageText);
          console.log(`Campaign ${campaignId}: Successfully sent message to ${contact.number}, UUID: ${messageUuid}`);
          messageLogs.push({
            contactNumber: contact.number,
            campaignId: campaignId,
            messageUuid: messageUuid,
            status: 'submitted', // Initial status from Vonage SDK
            timestamp: new Date(),
          });
          // Optional: Add delay to avoid hitting rate limits
          if (delayMs > 0) {
            await new Promise(resolve => setTimeout(resolve, delayMs));
          }
        } catch (error) {
          console.error(`Campaign ${campaignId}: Failed to send message to ${contact.number}: ${error.message}`);
          messageLogs.push({
            contactNumber: contact.number,
            campaignId: campaignId,
            messageUuid: null,
            status: 'failed',
            error: error.message,
            timestamp: new Date(),
          });
          // Decide if you want to stop the campaign on failure or continue
          // continue;
        }
      }
      console.log(`Campaign ${campaignId} finished.`);
    }
    
    // --- Contact Management Functions (Replace with DB in Step 6) ---
    function addContact(number) {
        if (!contacts.some(c => c.number === number)) {
            contacts.push({ number: number, status: 'subscribed' });
            console.log(`Contact ${number} added and subscribed.`);
            return true;
        }
        console.log(`Contact ${number} already exists.`);
        return false;
    }
    
    function findContact(number) {
        return contacts.find(c => c.number === number);
    }
    
    function updateContactStatus(number, status) {
        const contact = findContact(number);
        if (contact) {
            contact.status = status;
            console.log(`Contact ${number} status updated to ${status}.`);
            return true;
        }
        return false;
    }
    
    function getContacts() {
        return [...contacts]; // Return a copy
    }
    
    function getMessageLogs() {
        return [...messageLogs];
    }
    // --- End Contact Management ---
    
    module.exports = {
      sendCampaign,
      addContact,
      findContact,
      updateContactStatus,
      getContacts,
      getMessageLogs,
      // Function exposed for testing in-memory store reset
      __test_resetContacts: () => { contacts = []; messageLogs = []; }
    };
    • Rate Limiting: Vonage enforces a default limit of 30 API requests per second (up to 2.6M messages/day). US 10DLC numbers face additional carrier restrictions: most carriers limit throughput to 600 texts per minute per number, while T-Mobile uses brand trust scores. The 100ms default delay (delayMs = 100) allows ~10 messages/second, staying well within limits. For large campaigns, implement job queues (Step 9) for systematic rate control and retry handling.
    • Asynchronous Operations: Sending SMS is asynchronous. async/await is used to handle promises returned by sendSms cleanly within the loop.
    • State Management: The in-memory contacts array is temporary. A database is essential for persistence.

3. Building API Endpoints for SMS Campaigns

We'll create Express routes and controllers to manage contacts and trigger SMS campaigns.

  1. Contact Controller (src/controllers/contact.controller.js): Handles API requests related to contacts.

    javascript
    // src/controllers/contact.controller.js
    const contactService = require('../services/campaign.service'); // Using campaign service for now
    
    // Basic validation (improve with libraries like Joi or express-validator)
    function isValidPhoneNumber(number) {
      // Simple check - improve for E.164 format enforcement
      return /^\+?[1-9]\d{1,14}$/.test(number);
    }
    
    exports.addContact = (req, res, next) => {
      const { number } = req.body;
      if (!number || !isValidPhoneNumber(number)) {
        return res.status(400).json({ error: 'Valid phone number is required. Please use E.164 format (e.g., +12015550101).' });
      }
      try {
        const added = contactService.addContact(number);
        res.status(added ? 201 : 200).json({
            message: added ? 'Contact added and subscribed.' : 'Contact already exists.',
            contact: contactService.findContact(number)
         });
      } catch (error) {
        next(error);
      }
    };
    
    exports.listContacts = (req, res, next) => {
      try {
        const contacts = contactService.getContacts();
        res.status(200).json(contacts);
      } catch (error) {
        next(error);
      }
    };
    
    exports.getContact = (req, res, next) => {
        const { number } = req.params;
         if (!number || !isValidPhoneNumber(number)) {
            return res.status(400).json({ error: 'Valid phone number parameter is required.' });
        }
        try {
            const contact = contactService.findContact(number);
            if (contact) {
                res.status(200).json(contact);
            } else {
                res.status(404).json({ error: 'Contact not found.' });
            }
        } catch (error) {
            next(error);
        }
    };
    
    exports.unsubscribeContact = (req, res, next) => {
        // Usually handled by webhook, but allow manual unsubscribe via API
        const { number } = req.params;
         if (!number || !isValidPhoneNumber(number)) {
            return res.status(400).json({ error: 'Valid phone number parameter is required.' });
        }
        try {
            const updated = contactService.updateContactStatus(number, 'unsubscribed');
             if (updated) {
                res.status(200).json({ message: 'Contact unsubscribed successfully.' });
            } else {
                res.status(404).json({ error: 'Contact not found.' });
            }
        } catch (error) {
            next(error);
        }
    };
  2. Campaign Controller (src/controllers/campaign.controller.js): Handles API requests for sending campaigns and viewing logs.

    javascript
    // src/controllers/campaign.controller.js
    const campaignService = require('../services/campaign.service');
    
    exports.sendCampaign = async (req, res, next) => {
      const { campaignId, messageText } = req.body;
      if (!campaignId || !messageText) {
        return res.status(400).json({ error: 'campaignId and messageText are required.' });
      }
    
      try {
        // Trigger sending asynchronously - don't wait for all messages to finish
        campaignService.sendCampaign(campaignId, messageText);
        res.status(202).json({ message: `Campaign ${campaignId} accepted and is being processed.` });
      } catch (error) {
        next(error); // Pass errors to the error handling middleware
      }
    };
    
    exports.getLogs = (req, res, next) => {
        try {
            const logs = campaignService.getMessageLogs();
            res.status(200).json(logs);
        } catch (error) {
            next(error);
        }
    };
    • Asynchronous Trigger: Notice sendCampaign is called without await in the controller. This immediately returns a 202 Accepted response to the client, indicating the request is processing in the background. This prevents long-running requests for large campaigns. For production, a dedicated job queue (Step 9) is better.
  3. API Router (src/routes/api.js): Define the API endpoints and link them to controllers.

    javascript
    // src/routes/api.js
    const express = require('express');
    const contactController = require('../controllers/contact.controller');
    const campaignController = require('../controllers/campaign.controller');
    
    const router = express.Router();
    
    // Contact Routes
    router.post('/contacts', contactController.addContact);
    router.get('/contacts', contactController.listContacts);
    router.get('/contacts/:number', contactController.getContact);
    router.delete('/contacts/:number/unsubscribe', contactController.unsubscribeContact); // DELETE or POST for unsubscribe
    
    // Campaign Routes
    router.post('/campaigns/send', campaignController.sendCampaign);
    router.get('/campaigns/logs', campaignController.getLogs);
    
    
    module.exports = router;
  4. Testing API Endpoints (Examples):

    • Add Contact:

      bash
      curl -X POST http://localhost:3000/api/contacts \
           -H "Content-Type: application/json" \
           -d '{"number": "+14155550101"}' # Use a real test number format

      Expected Response (201): {"message":"Contact added and subscribed.","contact":{"number":"+14155550101","status":"subscribed"}}

    • List Contacts:

      bash
      curl http://localhost:3000/api/contacts

      Expected Response (200): [{"number":"+14155550101","status":"subscribed"}]

    • Send Campaign:

      bash
      curl -X POST http://localhost:3000/api/campaigns/send \
           -H "Content-Type: application/json" \
           -d '{"campaignId": "spring-promo", "messageText": "Special offer just for you!"}'

      Expected Response (202): {"message":"Campaign spring-promo accepted and is being processed."}

    • View Logs:

      bash
      curl http://localhost:3000/api/campaigns/logs

      Expected Response (200): [{"contactNumber":"+14155550101","campaignId":"spring-promo","messageUuid":"<some-uuid>","status":"submitted","timestamp":"..."}] (Status might update later via webhooks)

4. Configuring Vonage Webhooks for SMS

This step connects our local application to the outside world using ngrok and configures Vonage to communicate with it.

  1. Get Vonage Credentials:

    • Navigate to your Vonage API Dashboard.

    • Find your API Key and API Secret on the main dashboard page. Add these to your .env file:

      plaintext
      # .env
      VONAGE_API_KEY=YOUR_API_KEY
      VONAGE_API_SECRET=YOUR_API_SECRET
    • Purchase a Vonage virtual number if you don't have one (Numbers -> Buy numbers). Ensure it supports SMS. Add it to your .env file (use E.164 format, e.g., +12015550123):

      plaintext
      # .env
      VONAGE_NUMBER=YOUR_VONAGE_NUMBER
  2. Create a Vonage Application: The Messages API requires a Vonage Application for authentication (using Application ID and Private Key) and webhook configuration.

    • Go to Your applications in the Vonage Dashboard.

    • Click "+ Create a new application".

    • Give it a name (e.g., "Node SMS Marketing App").

    • Click "Generate public and private key". Immediately save the private.key file that downloads. Place it in your project's root directory (or the path specified in VONAGE_PRIVATE_KEY_PATH in .env). Add private.key to your .gitignore file!

    • Enable the Messages capability.

    • You'll see fields for Inbound URL and Status URL. We need ngrok running to get these URLs. Leave them blank for now, but keep this page open.

    • Scroll down and click "Generate new application".

    • Copy the generated Application ID and add it to your .env file:

      plaintext
      # .env
      VONAGE_APPLICATION_ID=YOUR_APPLICATION_ID
    • Link your Vonage Number: On the application page, find the "Link virtual numbers" section, click "Link", select your purchased Vonage number, and confirm. This directs incoming messages for that number to this application's webhooks.

  3. Run ngrok: Expose your local server (running on port 3000) to the internet.

    • Open a new terminal window (keep your Node server running in the other).

    • Run:

      bash
      ngrok http 3000
    • ngrok will display forwarding URLs. Copy the https:// URL (e.g., https://<random-string>.ngrok-free.app). This is your public base URL.

      text
      Forwarding                    https://<random-string>.ngrok-free.app -> http://localhost:3000
  4. Configure Webhooks in Vonage Dashboard:

    • Go back to your Vonage Application settings page (Your applications -> Click your app name).
    • Under the Messages capability:
      • Set Inbound URL to: YOUR_NGROK_HTTPS_URL/webhooks/inbound (e.g., https://<random-string>.ngrok-free.app/webhooks/inbound)
      • Set Status URL to: YOUR_NGROK_HTTPS_URL/webhooks/status (e.g., https://<random-string>.ngrok-free.app/webhooks/status)
      • Ensure the HTTP Method for both is set to POST.
    • Click Save changes.
  5. Configure Webhooks in Vonage Account Settings: Ensure your account is set to use the Messages API for SMS webhooks. This is crucial as Vonage has two SMS APIs (the older SMS API and the newer Messages API) which use different webhook formats.

    • Go to your Vonage Account Settings.
    • Scroll down to API settings, then SMS settings.
    • Ensure "Default SMS Setting" is set to Messages API.
    • Click Save changes.
  6. Set BASE_URL Environment Variable: Update your .env file with the ngrok URL so the application knows its public address if needed.

    plaintext
    # .env
    BASE_URL=https://<random-string>.ngrok-free.app

    Restart your Node.js server (node server.js) after updating .env for the changes to take effect.

Now, Vonage knows where to send inbound SMS messages and delivery status updates for your linked number, directing them through ngrok to your running Express application.

5. Implementing Error Handling and Logging

Production systems need robust error handling and visibility.

  1. Consistent Error Handling: Our basic error middleware in src/app.js catches unhandled errors. Enhance it for clarity:

    javascript
    // src/app.js (Error Handling Middleware - Enhanced)
    app.use((err, req, res, next) => {
      // Log the full error stack for debugging
      console.error(`[${new Date().toISOString()}] ERROR: ${req.method} ${req.originalUrl}`);
      console.error(err.stack || err.message || err);
    
      // Avoid leaking stack traces in production
      const status = err.status || 500;
      const message = (process.env.NODE_ENV === 'production' && status === 500)
                      ? 'Internal Server Error'
                      : err.message || 'Internal Server Error'; // Show more detail in dev
    
      res.status(status).json({
        error: {
          message: message,
          // Optionally include error code or type in non-production
          ...(process.env.NODE_ENV !== 'production' && { type: err.name, stack: err.stack }),
        },
      });
    });
    • Production vs. Development: This enhanced handler avoids sending detailed stack traces to the client in production (NODE_ENV=production) for security.
    • Logging: It logs the full error details on the server regardless of environment.
  2. Structured Logging: Replace console.log/console.error with a dedicated library like Pino (fast, JSON-based) or Winston.

    bash
    npm install pino pino-http

    Update src/app.js:

    javascript
    // src/app.js
    const express = require('express');
    const pino = require('pino');
    const pinoHttp = require('pino-http');
    
    // Configure logger (adjust level for production)
    const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
    const httpLogger = pinoHttp({ logger });
    
    const apiRoutes = require('./routes/api');
    const webhookRoutes = require('./routes/webhooks');
    
    const app = express();
    
    // Middleware
    app.use(httpLogger); // Add pino-http logger for requests/responses
    app.use(express.json());
    app.use(express.urlencoded({ extended: true }));
    
    // Routes
    app.get('/health', (req, res) => {
      req.log.info('Health check accessed'); // Use req.log from pino-http
      res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
    });
    app.use('/api', apiRoutes);
    app.use('/webhooks', webhookRoutes);
    
    // Error Handling Middleware (using logger)
    app.use((err, req, res, next) => {
      req.log.error({ err }, `Error processing ${req.method} ${req.originalUrl}`); // Log error object
    
      const status = err.status || 500;
      const message = (process.env.NODE_ENV === 'production' && status === 500)
                      ? 'Internal Server Error'
                      : err.message || 'Internal Server Error';
    
      res.status(status).json({
        error: { message: message },
      });
    });
    
    module.exports = app; // Export app, not starting server here
    
    // Update server.js to use the logger
    // server.js
    // require('dotenv').config();
    // const app = require('./src/app');
    // const logger = require('pino')(); // Or get logger instance appropriately
    // const PORT = process.env.PORT || 3000;
    // app.listen(PORT, () => { logger.info(`Server listening on port ${PORT}`); });
    • Benefits: Structured (JSON) logs are easily searchable and parsable by log aggregation tools (Datadog, Splunk, ELK Stack). pino-http automatically logs request/response details.
  3. Retry Mechanisms:

    • Vonage API Calls: Network issues or temporary Vonage outages can cause sendSms to fail. Implement retries with exponential backoff for transient errors (e.g., 5xx errors from Vonage, network timeouts). Libraries like async-retry can help.
    • Webhook Processing: If processing a webhook fails (e.g., database temporarily unavailable), Vonage might retry sending it. Ensure your webhook handlers are idempotent – processing the same webhook multiple times should not cause incorrect side effects (e.g., unsubscribing an already unsubscribed user is fine). Use unique constraints (like vonageMessageUuid in the Message table) to prevent duplicate record creation.
    • Complex Retries: For critical operations like sending campaign messages, using a job queue (see Step 9) is the most robust way to handle failures and retries systematically.

6. Database Integration for SMS Campaign Data

For a production system, storing contacts, campaigns, and message logs persistently is essential. We'll outline using Prisma as an example ORM with PostgreSQL, but you can adapt this to other databases (MySQL, MongoDB) or ORMs (Sequelize).

  1. Install Prisma:

    bash
    npm install prisma --save-dev
    npm install @prisma/client
  2. Initialize Prisma: This creates a prisma directory with a schema.prisma file and updates .env with a DATABASE_URL.

    bash
    npx prisma init --datasource-provider postgresql # Or mysql, mongodb, sqlite

    Update the DATABASE_URL in your .env file with your actual database connection string.

    plaintext
    # .env
    DATABASE_URL="postgresql://user:password@host:port/database_name"
  3. Define Database Schema (prisma/schema.prisma):

    prisma
    // prisma/schema.prisma
    generator client {
      provider = "prisma-client-js"
    }
    
    datasource db {
      provider = "postgresql"
      url      = env("DATABASE_URL")
    }
    
    model Contact {
      id            Int      @id @default(autoincrement())
      phoneNumber   String   @unique @map("phone_number")
      status        String   @default("subscribed") // subscribed, unsubscribed
      subscribedAt  DateTime @default(now()) @map("subscribed_at")
      unsubscribedAt DateTime? @map("unsubscribed_at")
      messages      Message[]
    
      @@map("contacts")
    }
    
    model Campaign {
      id          Int       @id @default(autoincrement())
      campaignId  String    @unique @map("campaign_id")
      messageText String    @map("message_text")
      createdAt   DateTime  @default(now()) @map("created_at")
      messages    Message[]
    
      @@map("campaigns")
    }
    
    model Message {
      id                  Int      @id @default(autoincrement())
      vonageMessageUuid   String?  @unique @map("vonage_message_uuid")
      contactId           Int      @map("contact_id")
      campaignId          Int?     @map("campaign_id")
      status              String   // submitted, delivered, failed, etc.
      errorMessage        String?  @map("error_message")
      sentAt              DateTime @default(now()) @map("sent_at")
      deliveredAt         DateTime? @map("delivered_at")
    
      contact             Contact  @relation(fields: [contactId], references: [id])
      campaign            Campaign? @relation(fields: [campaignId], references: [id])
    
      @@map("messages")
    }
  4. Run Migrations: Generate database tables from the schema.

    bash
    npx prisma migrate dev --name init
  5. Generate Prisma Client:

    bash
    npx prisma generate
  6. Update Services to Use Database: Replace in-memory storage with Prisma queries. Example for campaign.service.js:

    javascript
    // src/services/campaign.service.js (Database version)
    const { PrismaClient } = require('@prisma/client');
    const { sendSms } = require('./vonage.service');
    
    const prisma = new PrismaClient();
    
    async function sendCampaign(campaignIdStr, messageText, delayMs = 100) {
      console.log(`Starting campaign: ${campaignIdStr}`);
    
      // Create campaign record
      let campaign = await prisma.campaign.findUnique({ where: { campaignId: campaignIdStr } });
      if (!campaign) {
        campaign = await prisma.campaign.create({
          data: { campaignId: campaignIdStr, messageText }
        });
      }
    
      // Get subscribed contacts
      const subscribedContacts = await prisma.contact.findMany({
        where: { status: 'subscribed' }
      });
    
      if (subscribedContacts.length === 0) {
        console.log(`Campaign ${campaignIdStr}: No subscribed contacts.`);
        return;
      }
    
      for (const contact of subscribedContacts) {
        try {
          const messageUuid = await sendSms(contact.phoneNumber, messageText);
          await prisma.message.create({
            data: {
              vonageMessageUuid: messageUuid,
              contactId: contact.id,
              campaignId: campaign.id,
              status: 'submitted'
            }
          });
    
          if (delayMs > 0) {
            await new Promise(resolve => setTimeout(resolve, delayMs));
          }
        } catch (error) {
          console.error(`Failed to send to ${contact.phoneNumber}:`, error.message);
          await prisma.message.create({
            data: {
              contactId: contact.id,
              campaignId: campaign.id,
              status: 'failed',
              errorMessage: error.message
            }
          });
        }
      }
    }
    
    async function addContact(phoneNumber) {
      const existing = await prisma.contact.findUnique({ where: { phoneNumber } });
      if (existing) return false;
    
      await prisma.contact.create({ data: { phoneNumber } });
      return true;
    }
    
    async function updateContactStatus(phoneNumber, status) {
      const contact = await prisma.contact.findUnique({ where: { phoneNumber } });
      if (!contact) return false;
    
      await prisma.contact.update({
        where: { phoneNumber },
        data: {
          status,
          ...(status === 'unsubscribed' && { unsubscribedAt: new Date() })
        }
      });
      return true;
    }
    
    module.exports = {
      sendCampaign,
      addContact,
      updateContactStatus,
      getContacts: () => prisma.contact.findMany(),
      getMessageLogs: () => prisma.message.findMany({ include: { contact: true, campaign: true } }),
      findContact: (phoneNumber) => prisma.contact.findUnique({ where: { phoneNumber } })
    };

7. How to Receive SMS Messages with Webhooks

Implement webhook handlers to receive incoming SMS messages and delivery status updates.

  1. Create Webhook Routes (src/routes/webhooks.js):

    javascript
    // src/routes/webhooks.js
    const express = require('express');
    const router = express.Router();
    const campaignService = require('../services/campaign.service');
    
    // Inbound SMS webhook
    router.post('/inbound', async (req, res) => {
      console.log('Received inbound SMS:', JSON.stringify(req.body, null, 2));
    
      const { from, text, message_type } = req.body;
    
      if (message_type === 'text' && text) {
        const normalizedText = text.trim().toUpperCase();
    
        // Handle STOP/UNSUBSCRIBE requests
        if (normalizedText === 'STOP' || normalizedText === 'UNSUBSCRIBE') {
          try {
            await campaignService.updateContactStatus(from, 'unsubscribed');
            console.log(`Contact ${from} unsubscribed via SMS`);
          } catch (error) {
            console.error(`Error unsubscribing ${from}:`, error);
          }
        }
      }
    
      res.status(200).send('OK');
    });
    
    // Status webhook for delivery receipts
    router.post('/status', async (req, res) => {
      console.log('Received status update:', JSON.stringify(req.body, null, 2));
    
      const { message_uuid, status, error } = req.body;
    
      // Update message status in database
      try {
        const { PrismaClient } = require('@prisma/client');
        const prisma = new PrismaClient();
    
        await prisma.message.update({
          where: { vonageMessageUuid: message_uuid },
          data: {
            status,
            ...(status === 'delivered' && { deliveredAt: new Date() }),
            ...(error && { errorMessage: JSON.stringify(error) })
          }
        });
      } catch (err) {
        console.error('Error updating message status:', err);
      }
    
      res.status(200).send('OK');
    });
    
    module.exports = router;
  2. Test Webhooks:

    • Send an SMS to your Vonage number from your phone
    • Check your server logs for the inbound webhook payload
    • Try sending "STOP" to test unsubscribe functionality

Next Steps: Enhancing Your SMS Marketing System

Additional Features to Consider:

  • Job Queues: Implement Bull or BullMQ for robust background job processing
  • Scheduled Campaigns: Add campaign scheduling with node-cron or agenda
  • Message Templates: Create reusable templates with variable substitution
  • Analytics Dashboard: Track campaign performance and engagement metrics
  • Sender ID Registration: Complete 10DLC registration for improved deliverability
  • Contact Segmentation: Group contacts by attributes for targeted campaigns
  • A/B Testing: Test different message variations for optimization
  • Rate Limiting: Implement more sophisticated rate limiting for high-volume campaigns

Related Resources:

Conclusion

You've successfully built a complete SMS marketing campaign system using Node.js, Express, and the Vonage Messages API. This tutorial covered project setup, sending SMS messages, webhook configuration, database integration, and compliance considerations.

The system you've created can manage contacts, send bulk SMS campaigns, handle unsubscribe requests, and track delivery status—all essential features for production SMS marketing applications.

Remember to always prioritize TCPA compliance, implement proper rate limiting, and thoroughly test your system before deploying to production. With this foundation, you can expand your SMS marketing capabilities to include advanced features like segmentation, scheduling, and analytics.