code examples

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

Build SMS Marketing Campaigns with Plivo, Node.js & Express: Complete Guide

Build production-ready SMS marketing campaigns using Plivo API, Node.js, Express, and BullMQ. Learn subscriber management, bulk messaging, TCPA compliance, webhooks, and deployment with complete code examples.

Build Production-Ready SMS Campaigns with Node.js, Express, and Plivo

Build a robust SMS marketing campaign application using Node.js, the Express framework, and the Plivo communication platform API. Learn how to set up the project, send bulk SMS messages, handle replies and opt-outs, manage subscribers, and deploy your application securely and reliably.

Build a system capable of managing subscriber lists, creating SMS campaigns, sending messages asynchronously via a job queue, handling incoming messages (like replies or STOP requests), and logging message statuses. This approach solves the challenge of sending personalized or bulk SMS messages reliably and scalably, directly from your application backend.

Common Use Cases:

  • E-commerce: Order confirmations, shipping notifications, and promotional campaigns
  • Healthcare: Appointment reminders, prescription notifications, and health alerts
  • Financial Services: Transaction alerts, fraud detection, and account updates
  • Events & Hospitality: Booking confirmations, event reminders, and last-minute updates
  • SaaS Applications: User onboarding sequences, feature announcements, and engagement campaigns

Technologies Used:

  • Node.js: JavaScript runtime environment
  • Express.js: Minimalist web framework for Node.js, used for building the API layer
  • Plivo Node.js SDK: Simplifies interaction with the Plivo SMS API. Note: This guide uses Plivo SDK v4.x (plivo@4.x.x). Plivo SDK v5+ introduces breaking changes in initialization and API methods. Verify your installed version with npm list plivo and consult Plivo's migration guide if using v5+
  • PostgreSQL: Relational database for storing subscriber and campaign data
  • Prisma: Modern ORM for Node.js and TypeScript, simplifying database interactions
  • BullMQ: Robust job queue system based on Redis, for handling asynchronous SMS sending
  • Redis: In-memory data structure store, used by BullMQ
  • dotenv: Module for loading environment variables from a .env file
  • ngrok (for development): Tool to expose local servers to the internet for webhook testing

System Architecture:

The architecture follows a modular request-response pattern with asynchronous job processing:

  1. API Layer: Express receives campaign creation requests from clients
  2. Database Layer: Prisma ORM stores subscribers, campaigns, and message logs in PostgreSQL
  3. Job Queue: BullMQ queues SMS jobs to Redis for asynchronous processing
  4. Worker Process: BullMQ workers consume jobs and send messages via Plivo API
  5. Webhook Handler: Express endpoints receive delivery status and inbound SMS from Plivo
text
+-----------------+      +-----------------+      +----------------+      +----------------+
|   User/Admin    | ---> |  Express API    | ---> |    BullMQ      | ---> | Plivo SMS API  |
| (API Client)    |      | (Node.js)       |      |  (Redis Job Q) |      |                |
+-----------------+      +-------+---------+      +-------+--------+      +-------+--------+
                         |       |                |       |                |       |
                         |   Prisma ORM    |<----->| PostgreSQL DB  |                | Plivo Webhooks
                         |       |                | (Subscribers,  |                | (Incoming SMS,
                         +-------+---------+      |  Campaigns)    | <--------------+  Status Updates)
                                                  +----------------+

Prerequisites:

  • Node.js 16+ and npm (or yarn) installed
  • PostgreSQL 12+ database access
  • Redis 6+ instance (local or cloud-based)
  • A Plivo account with API credentials
  • Basic understanding of Node.js, Express, REST APIs, and databases
  • ngrok installed for local development webhook testing
  • Production Webhook URL: For production deployment, you need a stable, publicly accessible URL for Plivo webhooks. This is typically provided by your hosting platform (e.g., Heroku app URL, AWS Load Balancer URL) or a dedicated tunneling service. ngrok's free tier URLs are temporary and not suitable for production.
  • TCPA Compliance (US): If sending marketing SMS to US numbers, you must comply with the Telephone Consumer Protection Act (TCPA). Requirements include: (1) obtaining prior express written consent from recipients, (2) providing clear opt-out instructions in every message, (3) maintaining consent records, and (4) honoring opt-outs immediately. Non-compliance carries penalties up to $1,500 per violation. Similar regulations exist in other jurisdictions (e.g., GDPR in EU, CASL in Canada).

By the end of this guide, you'll have a functional backend application capable of managing and executing SMS campaigns, ready for further enhancement and deployment.

1. Project Setup and Configuration

Initialize the Node.js project and install necessary dependencies.

  1. Create Project Directory:

    bash
    mkdir sms-campaign-app
    cd sms-campaign-app
  2. Initialize Node.js Project:

    bash
    npm init -y
  3. Install Dependencies:

    bash
    npm install express plivo@4 @prisma/client dotenv bullmq@4 ioredis express-validator winston express-rate-limit

    Version Notes:

    • plivo@4: Ensures SDK v4.x compatibility with code examples (v5+ has breaking changes)
    • bullmq@4: Stable version compatible with the worker patterns shown (v5+ changes queue initialization)
    • Use exact versions in production package.json for stability

    Dependency Purposes:

    • express: Web framework for API endpoints
    • plivo@4: Plivo SDK for sending SMS
    • @prisma/client: Auto-generated database client
    • dotenv: Load environment variables from .env file
    • bullmq@4: Job queue for asynchronous SMS processing
    • ioredis: Redis client for BullMQ
    • express-validator: Request validation middleware
    • winston: Production-grade logging library
    • express-rate-limit: Rate limiting middleware
  4. Install Development Dependencies:

    bash
    npm install --save-dev prisma nodemon jest supertest
    • prisma: For Prisma CLI commands (migrations, generation)
    • nodemon: Automatically restarts the server during development
    • jest, supertest: For testing
  5. Initialize Prisma:

    bash
    npx prisma init --datasource-provider postgresql

    This creates a prisma directory with a schema.prisma file and a .env file.

  6. Configure Environment Variables (.env): Open the .env file created by Prisma and add the following variables. Remove any unnecessary quotes.

    Security Best Practices for .env Files:

    • Never commit .env files to version control (add to .gitignore)
    • Use different .env files for development, staging, and production
    • Rotate credentials regularly, especially after team member departures
    • Use secrets management services (AWS Secrets Manager, HashiCorp Vault) in production
    • Restrict file permissions: chmod 600 .env on Unix systems
    dotenv
    # .env
    
    # Database
    # Example: postgresql://user:password@host:port/database?schema=public
    # Use quotes only if password contains special characters like @, #, $, %, etc.
    DATABASE_URL="postgresql://your_db_user:your_db_password@localhost:5432/sms_campaigns"
    
    # Plivo Credentials
    PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
    PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
    PLIVO_SENDER_ID=YOUR_PLIVO_PHONE_NUMBER_OR_SENDER_ID # Plivo phone number in E.164 format or Alphanumeric Sender ID
    
    # Redis Connection (for BullMQ)
    # Example for local Redis: redis://localhost:6379
    # Example for cloud Redis with password: redis://:password@host:port
    REDIS_URL="redis://localhost:6379"
    
    # Application Settings
    PORT=3000
    API_KEY=YOUR_SECRET_API_KEY # Simple API key for securing endpoints
    BASE_URL=http://localhost:3000 # Base URL for generating webhook URLs (update for development/production)
    • DATABASE_URL: Connection string for your PostgreSQL database. Replace placeholders with your actual credentials. Use quotes if your password or user contains special characters.
    • PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN: Your Plivo API credentials
    • PLIVO_SENDER_ID: The Plivo phone number (in E.164 format, e.g., +14155551212) or Alphanumeric Sender ID you'll use to send messages. Numbers are required for US/Canada.
    • REDIS_URL: Connection string for your Redis instance
    • PORT: Port your Express application will run on
    • API_KEY: A simple secret key for basic API authentication (use more robust methods like JWT for production)
    • BASE_URL: The public-facing base URL of your application. Critical for webhook configuration. Use your ngrok URL during development, and your production URL otherwise.
  7. Add nodemon script to package.json: Update the scripts section in your package.json:

    json
    {
      "scripts": {
        "start": "node src/server.js",
        "dev": "nodemon src/server.js",
        "test": "jest"
      }
    }
  8. Create Project Structure: Organize your project for maintainability:

    text
    sms-campaign-app/
    ├── prisma/
    │   ├── schema.prisma
    │   └── migrations/
    ├── src/
    │   ├── config/         # Configuration files (db, plivo, redis)
    │   ├── controllers/    # Request handlers
    │   ├── jobs/           # BullMQ job definitions
    │   ├── middleware/     # Express middleware (auth, validation, error handling)
    │   ├── models/         # (Optional) Business logic models if needed beyond Prisma
    │   ├── routes/         # API route definitions
    │   ├── services/       # Business logic (interacting with DB, Plivo, etc.)
    │   ├── utils/          # Utility functions (logging, etc.)
    │   ├── workers/        # BullMQ worker processes
    │   ├── app.js          # Express app configuration
    │   └── server.js       # Server entry point
    ├── tests/              # Unit and integration tests
    ├── .env
    ├── .gitignore
    └── package.json

    Create these directories.

  9. Basic Server Setup (src/server.js):

    javascript
    // src/server.js
    require('dotenv').config();
    const app = require('./app');
    const logger = require('./utils/logger'); // We'll create this later
    
    const PORT = process.env.PORT || 3000;
    
    app.listen(PORT, () => {
      logger.info(`Server running on port ${PORT}`);
      logger.info(`API base URL: ${process.env.BASE_URL}`);
      // Note: BullMQ workers should typically run in a separate process.
    });
  10. Basic App Setup (src/app.js):

    javascript
    // src/app.js
    const express = require('express');
    const rateLimit = require('express-rate-limit');
    // Routes and middleware will be imported and used as defined in later sections
    
    const app = express();
    
    // Middleware (Order matters!)
    
    // Apply rate limiting early
    const limiter = rateLimit({
        windowMs: 15 * 60 * 1000, // 15 minutes
        max: 100, // Limit each IP to 100 requests per windowMs
        standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
        legacyHeaders: false, // Disable the `X-RateLimit-*` headers
        message: 'Too many requests from this IP, please try again after 15 minutes',
    });
    app.use(limiter);
    
    // We will mount webhook routes *before* express.json() if raw body is needed (See Section 6.4)
    
    // Parse JSON bodies for most routes
    app.use(express.json());
    // Parse URL-encoded bodies
    app.use(express.urlencoded({ extended: true }));
    
    // Request logging, Authentication, API routes, and Error Handling will be added later.
    
    // Health check endpoint
    app.get('/health', (req, res) => {
      res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
    });
    
    // Routes and error handler will be mounted here in subsequent sections.
    
    module.exports = app;

This initial setup provides a solid foundation with essential dependencies, environment configuration, and a basic project structure.

2. Integrating with Plivo

Configure Plivo and set up the necessary components to interact with its API.

  1. Sign Up/Log In to Plivo:

    • Go to plivo.com and sign up for an account or log in.
    • Trial Account Limitation: If using a trial account, you can only send SMS to phone numbers verified in your Plivo console (Phone Numbers > Sandbox Numbers). You'll also have a ""[Plivo Trial]"" prefix added to messages. Purchase credits to remove these limitations.
  2. Get API Credentials:

    • Navigate to your Plivo Console Dashboard
    • Locate the Auth ID and Auth Token on the right side of the dashboard
    • Copy these values and paste them into your .env file for PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN. Keep these secret! Do not commit them to version control.
    • Trial Account Limitations: Trial accounts have restricted capabilities: (1) SMS can only be sent to phone numbers verified in Plivo Console > Phone Numbers > Sandbox Numbers, (2) All messages include a "[Plivo Trial]" prefix, (3) Limited to 20 free messages. Purchase Plivo credits to remove these restrictions and enable production use.
  3. Get a Plivo Phone Number:

    • You need an SMS-enabled Plivo phone number to send and receive messages (required for US/Canada)
    • Go to Phone Numbers > Buy Numbers in the console
    • Search for numbers with SMS capability in your desired country
    • Purchase a number. Pricing: Plivo phone numbers typically cost $0.80–$2.00/month (varies by country). SMS rates vary by destination: US domestic SMS costs approximately $0.0035/segment, international rates range $0.005–$0.20/segment. Check Plivo's pricing page for current rates.
    • Copy the full number in E.164 format (e.g., +14155551212) and add it to your .env file as PLIVO_SENDER_ID
    • Alternatively, for supported countries outside North America, you might use an Alphanumeric Sender ID configured in your Plivo account under Messaging > Sender IDs.

    Alphanumeric Sender ID Notes:

    • Available in 200+ countries excluding US, Canada, and China
    • Limited to 11 characters, alphanumeric only
    • Supports one-way messaging (outbound only, no replies)
    • Requires registration in some countries (e.g., India, Saudi Arabia)
    • Check Plivo's Alphanumeric Sender ID documentation for country-specific requirements
  4. Configure Plivo Application for Webhooks: Plivo uses webhooks to notify your application about incoming messages and delivery status updates. You need to create a Plivo Application and link your Plivo number to it.

    • Go to Messaging > Applications > XML
    • Click ""Add New Application""
    • Application Name: Give it a descriptive name (e.g., ""Node Campaign App"")
    • Message URL: This is where Plivo sends incoming SMS data. Enter the publicly accessible URL for your incoming message handler. During development, use ngrok.
      • Start ngrok: ngrok http 3000 (or your PORT)
      • Copy the HTTPS forwarding URL provided by ngrok (e.g., https://<unique_id>.ngrok.io)
      • Your Message URL will be: https://<unique_id>.ngrok.io/webhooks/plivo/incoming (we'll create this endpoint later)
      • Crucially update your .env BASE_URL to this ngrok URL during development
    • Method: Set to POST
    • Delivery Report URL (Optional but Recommended): Where Plivo sends message status updates (sent, failed, delivered). Set this similarly: https://<unique_id>.ngrok.io/webhooks/plivo/status. Set method to POST.
    • Click ""Create Application""
    • Link Number to Application: Go back to Phone Numbers > Your Numbers. Click on the number you purchased. In the ""Application Type"" section, select ""XML Application"". From the ""Plivo Application"" dropdown, choose the application you just created (""Node Campaign App""). Click ""Update Number"".

    Common Webhook Configuration Issues:

    • Webhook not receiving requests: Verify ngrok is running and BASE_URL matches ngrok URL
    • Signature validation failures: Ensure raw request body is preserved before JSON parsing
    • Timeouts: Plivo expects responses within 3 seconds; process long-running tasks asynchronously
  5. Create Plivo Service (src/services/plivoService.js): This service encapsulates interaction with the Plivo SDK.

    javascript
    // src/services/plivoService.js
    const plivo = require('plivo');
    const logger = require('../utils/logger');
    
    const client = new plivo.Client(process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN);
    
    const sendSms = async (to, text, callbackUrl = null) => {
      const senderId = process.env.PLIVO_SENDER_ID;
      if (!senderId) {
        logger.error('PLIVO_SENDER_ID is not configured in .env');
        throw new Error('Sender ID not configured');
      }
    
      // Basic validation
      if (!to || !text) {
          throw new Error('Recipient number (to) and message text cannot be empty.');
      }
      // Add more robust E.164 validation if needed
    
      logger.info(`Attempting to send SMS from ${senderId} to ${to}`);
    
      try {
        const params = {
          src: senderId,
          dst: to,
          text: text,
        };
        // Include status callback URL if provided
        if (callbackUrl) {
            params.url = callbackUrl;
            params.method = 'POST'; // Method Plivo uses to call your callback URL
        }
    
        const response = await client.messages.create(params);
    
        logger.info(`SMS submitted to Plivo for ${to}. Message UUID: ${response.messageUuid}`);
        // Note: This response indicates submission to Plivo, not final delivery.
        // Delivery status comes via webhook if configured.
        return { success: true, messageUuid: response.messageUuid };
    
      } catch (error) {
        logger.error(`Error sending SMS via Plivo to ${to}: ${error.message}`, { error });
        // Plivo errors often have more details in error.response or error.message
        throw error; // Re-throw for handling upstream (e.g., in the job queue worker)
      }
    };
    
    // Function to generate Plivo XML for replying to incoming messages
    const createReplyXml = (responseText) => {
      const response = new plivo.Response();
      response.addMessage(responseText); // Simple text reply
      return response.toXML();
    };
    
    // Function to validate incoming Plivo webhooks
    const validateWebhookSignature = (req) => {
        const signature = req.headers['x-plivo-signature-v3'];
        const nonce = req.headers['x-plivo-signature-v3-nonce'];
        // Construct the full URL Plivo used.
        // Note: This might be unreliable if behind certain proxies that rewrite URL components.
        // Ensure BASE_URL is correctly set to the public-facing URL Plivo hits.
        const url = process.env.BASE_URL + req.originalUrl;
        const method = req.method;
        const authToken = process.env.PLIVO_AUTH_TOKEN;
    
        if (!signature || !nonce) {
            logger.warn('Missing Plivo signature headers.');
            return false;
        }
    
        try {
            // CRITICAL: Plivo's validation requires the raw, unparsed request body for POST/PUT.
            // If using express.json() globally *before* this check, `req.body` will be parsed JSON,
            // which WILL cause validation failure.
            // You MUST capture the raw body *before* JSON parsing for webhook routes.
            // See `plivoWebhookAuth.js` and `app.js` setup for handling this.
            // We assume here that the raw body (Buffer) is available via `req.rawBody` (or similar).
            const postParams = req.method === 'POST' ? (req.rawBody || req.body) : {}; // Prioritize rawBody if available
    
            // Note: Plivo SDK v4's validateV3Signature expects body params as an object for POST.
            // If using rawBody (Buffer), you might need to parse it *conditionally* ONLY for validation
            // if the SDK helper requires an object map, or check if the SDK offers a way to pass the raw buffer directly.
            // For simplicity, we pass `postParams` but emphasize the raw body requirement.
    
            const isValid = plivo.validateV3Signature(method, url, nonce, authToken, signature, postParams);
            // const isValid = plivo.Utils.validatesignature(url, nonce, signature, authToken); // Alternative if simpler validation is sufficient/available
    
            if (!isValid) {
                logger.warn('Invalid Plivo webhook signature received.', { url, method, nonce, signature, headers: req.headers, bodyUsed: postParams === req.rawBody ? 'rawBody' : 'req.body' });
            }
            return isValid;
        } catch (error) {
            logger.error('Error validating Plivo webhook signature:', { error, url, method });
            return false;
        }
    };
    
    
    module.exports = {
      sendSms,
      createReplyXml,
      validateWebhookSignature,
    };
    • Initialize the Plivo client using credentials from .env
    • sendSms handles sending a single message, including optional status callback configuration
    • createReplyXml generates the XML Plivo expects for automatic replies via webhooks
    • validateWebhookSignature secures your webhook endpoints. Implementation Note: This function requires the raw request body for validation to work correctly with POST requests. Ensure your middleware setup provides this (see Section 6). The URL construction using BASE_URL + req.originalUrl might also need adjustment if your application runs behind complex proxies.

    Common Plivo API Errors:

    • Error 15003 (Invalid From Number): Verify PLIVO_SENDER_ID is in E.164 format and number is active
    • Error 15006 (Destination Not Reachable): Check recipient number format and network availability
    • Error 20003 (Authentication Failed): Verify PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN are correct
    • Error 20006 (Insufficient Balance): Add credits to your Plivo account

3. Database Schema and Data Layer

Use Prisma to define your database schema and interact with PostgreSQL.

  1. Define Schema (prisma/schema.prisma): Update your schema file with models for Subscribers, Campaigns, and potentially Sent Messages for tracking.

    prisma
    // prisma/schema.prisma
    generator client {
      provider = "prisma-client-js"
    }
    
    datasource db {
      provider = "postgresql"
      url      = env("DATABASE_URL")
    }
    
    model Subscriber {
      id          Int      @id @default(autoincrement())
      phoneNumber String   @unique // E.164 format
      firstName   String?
      lastName    String?
      isActive    Boolean  @default(true) // For opt-outs
      createdAt   DateTime @default(now())
      updatedAt   DateTime @updatedAt
    
      // Optional: Link to campaigns they are part of (many-to-many)
      // campaigns Campaign[]
      sentMessages SentMessage[] // Track messages sent to this subscriber
    }
    
    model Campaign {
      id          Int       @id @default(autoincrement())
      name        String
      messageBody String
      status      String    @default("draft") // e.g., draft, scheduled, sending, sent, failed
      scheduledAt DateTime? // For future scheduling
      sentAt      DateTime?
      createdAt   DateTime  @default(now())
      updatedAt   DateTime  @updatedAt
    
      // Track messages sent for this campaign
      sentMessages SentMessage[]
    }
    
    model SentMessage {
      id             Int       @id @default(autoincrement())
      campaignId     Int
      subscriberId   Int
      messageUuid    String?   @unique // Plivo's message identifier
      status         String    // e.g., queued, processing, submitted, delivered, failed, undelivered
      statusCallback Json?     // Store the full callback payload from Plivo
      sentAt         DateTime  @default(now()) // Time job was queued/processed
      lastStatusAt   DateTime? // Time of last status update from Plivo
    
      campaign     Campaign   @relation(fields: [campaignId], references: [id], onDelete: Cascade) // Cascade delete if campaign deleted
      subscriber   Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Cascade) // Cascade delete if subscriber deleted
    
      @@index([campaignId])
      @@index([subscriberId])
      @@index([status])
      @@index([messageUuid])
    }
    • Subscriber: Stores contact information and opt-out status (isActive)
    • Campaign: Defines the message content and tracks the overall campaign status
    • SentMessage: Logs each individual message attempt, linking Campaigns and Subscribers, storing the Plivo messageUuid, and tracking delivery status. Added onDelete: Cascade for referential integrity.

    Status Field Design Considerations:

    ApproachProsConsBest For
    StringFlexible, easy to add valuesNo type safety, prone to typosRapid prototyping, changing requirements
    EnumType-safe, prevents invalid valuesRequires migration to add valuesStable, well-defined status flows

    For production applications, consider using enums for better type safety and validation. This guide uses strings for flexibility during development.

    Data Retention and Archival:

    • Implement soft deletes for subscribers (add deletedAt timestamp field)
    • Archive campaigns older than 90 days to separate archived_campaigns table
    • Implement retention policies compliant with GDPR (right to be forgotten)
    • Use partitioning for SentMessage table when exceeding 10M records
    • Schedule periodic cleanup jobs for bounced/failed messages older than 1 year
  2. Run Database Migration: Apply the schema changes to your database. Prisma creates SQL migration files.

    bash
    # Create the migration files based on schema changes
    npx prisma migrate dev --name init_campaign_schema
    
    # This command will also apply the migration to your database.
    # Ensure your DATABASE_URL in .env is correct and the database server is running.
  3. Generate Prisma Client: Whenever you change your schema, regenerate the Prisma Client.

    bash
    npx prisma generate

    This updates the @prisma/client library with typesafe methods based on your schema.

  4. Database Client Configuration (src/config/db.js):

    javascript
    // src/config/db.js
    const { PrismaClient } = require('@prisma/client');
    const logger = require('../utils/logger'); // Assuming logger is set up
    
    const prisma = new PrismaClient({
        log: [
            { emit: 'event', level: 'query' },
            { emit: 'stdout', level: 'info' },
            { emit: 'stdout', level: 'warn' },
            { emit: 'stdout', level: 'error' },
        ],
    });
    
    prisma.$on('query', (e) => {
        // Log query performance, useful for debugging slow queries
        if (e.duration > 100) { // Log queries taking longer than 100ms
            logger.warn(`Slow Query (${e.duration}ms): ${e.query}`, { params: e.params });
        }
        // logger.debug(`Query: ${e.query}`, { duration: e.duration, params: e.params }); // More verbose logging
    });
    
    // Optional: Test connection on startup (can add complexity)
    async function testDbConnection() {
        try {
            await prisma.$connect();
            logger.info('Database connection successful.');
        } catch (error) {
            logger.error('Database connection failed:', error);
            process.exit(1); // Exit if DB connection fails on startup
        } finally {
            // $connect is idempotent, no need to disconnect here if used normally
            // await prisma.$disconnect();
        }
    }
    
    // Call the test function if needed, e.g., in server.js before starting the server
    // testDbConnection();
    
    module.exports = prisma;

    This sets up a singleton Prisma client instance and includes basic query logging.

    Connection Pool Configuration Best Practices:

    • Pool Size: Default is num_physical_cpus * 2 + 1. Adjust via connection_limit in DATABASE_URL
    • Production Example: postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=30
    • Connection Limits: Set based on database server capacity and concurrent worker processes
    • Timeout Settings: Configure connect_timeout (default 5s) and pool_timeout (default 10s)
    • Health Checks: Implement periodic connection validation with prisma.$queryRaw
    • Monitoring: Track active connections, query duration, and connection pool exhaustion

4. Implementing Core Functionality (API & Services)

Build the API endpoints and service logic for managing subscribers and campaigns, and for sending messages.

Structure:

  • Routes (src/routes/): Define API endpoints and link them to controllers. Use express.Router
  • Controllers (src/controllers/): Handle HTTP requests, perform validation, call services, and send responses
  • Services (src/services/): Contain the core business logic, interacting with the database (Prisma) and external services (Plivo)

4.1. Subscriber Management

  1. Subscriber Routes (src/routes/subscriberRoutes.js):

    javascript
    // src/routes/subscriberRoutes.js
    const express = require('express');
    const subscriberController = require('../controllers/subscriberController');
    const { validateSubscriber, validateIdParam } = require('../middleware/validators'); // We'll create this
    const authenticateApiKey = require('../middleware/authMiddleware'); // We'll create this
    
    const router = express.Router();
    
    // Protect all subscriber routes with API key auth
    router.use(authenticateApiKey);
    
    router.post('/', validateSubscriber, subscriberController.createSubscriber);
    router.get('/', subscriberController.getAllSubscribers);
    router.get('/:id', validateIdParam, subscriberController.getSubscriberById);
    // Use PUT for full updates, PATCH for partial updates (like opt-out)
    router.patch('/:id', validateIdParam, validateSubscriber, subscriberController.updateSubscriber); // Allow partial updates
    router.delete('/:id', validateIdParam, subscriberController.deleteSubscriber);
    
    module.exports = router;
  2. Subscriber Service (src/services/subscriberService.js):

    javascript
    // src/services/subscriberService.js
    const prisma = require('../config/db');
    const logger = require('../utils/logger');
    
    const create = async (data) => {
      try {
        // Basic E.164 format check: + followed by 1-15 digits, starting with non-zero
        // Limitations: Doesn't validate country code validity or number length per country
        // For production: Use libphonenumber-js for comprehensive validation
        if (!/^\+[1-9]\d{1,14}$/.test(data.phoneNumber)) {
            throw new Error('Invalid phone number format. Use E.164 (e.g., +14155551212)');
        }
        const subscriber = await prisma.subscriber.create({ data });
        logger.info(`Subscriber created: ${subscriber.id} - ${subscriber.phoneNumber}`);
        return subscriber;
      } catch (error) {
        if (error.code === 'P2002' && error.meta?.target?.includes('phoneNumber')) {
          logger.warn(`Attempted to create duplicate subscriber: ${data.phoneNumber}`);
          throw new Error(`Subscriber with phone number ${data.phoneNumber} already exists.`);
        }
        logger.error('Error creating subscriber:', error);
        throw error;
      }
    };
    
    const findAll = async (activeOnly = true) => {
        const whereClause = activeOnly ? { isActive: true } : {};
        return prisma.subscriber.findMany({ where: whereClause, orderBy: { createdAt: 'desc' } });
    };
    
    const findById = async (id) => {
        const subscriber = await prisma.subscriber.findUnique({ where: { id: parseInt(id, 10) } });
        if (!subscriber) {
            throw new Error(`Subscriber with ID ${id} not found.`);
        }
        return subscriber;
    };
    
    const update = async (id, data) => {
        try {
            if (data.phoneNumber && !/^\+[1-9]\d{1,14}$/.test(data.phoneNumber)) {
                throw new Error('Invalid phone number format. Use E.164.');
            }
            // Ensure boolean values are handled correctly if passed as strings from form data
            if (data.isActive !== undefined && typeof data.isActive !== 'boolean') {
                data.isActive = !(data.isActive === 'false' || data.isActive === '0' || !data.isActive);
            }
    
            const subscriber = await prisma.subscriber.update({
                where: { id: parseInt(id, 10) },
                data, // Prisma handles partial updates automatically with `update`
            });
            logger.info(`Subscriber updated: ${subscriber.id}`);
            return subscriber;
        } catch (error) {
            if (error.code === 'P2025') { // Record to update not found
                throw new Error(`Subscriber with ID ${id} not found for update.`);
            }
             if (error.code === 'P2002' && error.meta?.target?.includes('phoneNumber')) {
                logger.warn(`Attempted to update subscriber to duplicate number: ${data.phoneNumber}`);
                throw new Error(`Another subscriber with phone number ${data.phoneNumber} already exists.`);
            }
            logger.error(`Error updating subscriber ${id}:`, error);
            throw error;
        }
    };
    
     // Handle opt-out specifically via phone number (used by webhook)
    const setOptOutStatus = async (phoneNumber, isOptedOut) => {
        try {
            // Ensure E.164 format for lookup
            if (!/^\+[1-9]\d{1,14}$/.test(phoneNumber)) {
                 logger.warn(`Received opt-out/in request for invalid number format: ${phoneNumber}`);
                 return null; // Or throw error
            }
            const subscriber = await prisma.subscriber.update({
                where: { phoneNumber: phoneNumber },
                data: { isActive: !isOptedOut },
            });
            logger.info(`Subscriber ${phoneNumber} opt-out status set to ${isOptedOut}`);
            return subscriber;
        } catch (error) {
             if (error.code === 'P2025') { // Record to update not found
                logger.warn(`Received opt-out/in request for unknown number: ${phoneNumber}`);
                // Decide if you want to create a subscriber record here or just log
                // Example: Create if opting in, ignore if opting out from unknown number
                // if (!isOptedOut) { ... create subscriber ... }
                return null;
             }
             logger.error(`Error setting opt-out status for ${phoneNumber}:`, error);
            throw error; // Rethrow to indicate failure
        }
    };
    
    const remove = async (id) => {
      try {
        // Consider implications: deleting a subscriber might orphan SentMessage records
        // or violate constraints if not handled carefully (e.g., using onDelete: Cascade in schema)
        await prisma.subscriber.delete({ where: { id: parseInt(id, 10) } });
        logger.info(`Subscriber deleted: ${id}`);
        return true;
      } catch (error) {
         if (error.code === 'P2025') { // Record to delete not found
            throw new Error(`Subscriber with ID ${id} not found for deletion.`);
         }
         // Handle foreign key constraint errors if cascade delete isn't set up (P2003)
         if (error.code === 'P2003') {
             logger.error(`Cannot delete subscriber ${id} due to related records (e.g., sent messages).`);
             throw new Error(`Cannot delete subscriber ${id} as they have related message history.`);
         }
         logger.error(`Error deleting subscriber ${id}:`, error);
         throw error;
      }
    };
    
    module.exports = { create, findAll, findById, update, remove, setOptOutStatus };
  3. Subscriber Controller (src/controllers/subscriberController.js):

    javascript
    // src/controllers/subscriberController.js
    const subscriberService = require('../services/subscriberService');
    const { validationResult } = require('express-validator');
    
    exports.createSubscriber = async (req, res, next) => {
      const errors = validationResult(req);
      if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
      }
      try {
        // Ensure only allowed fields are passed to the service
        const allowedData = {
            phoneNumber: req.body.phoneNumber,
            firstName: req.body.firstName,
            lastName: req.body.lastName,
            isActive: req.body.isActive // Let service handle default if undefined
        };
        const subscriber = await subscriberService.create(allowedData);
        res.status(201).json(subscriber);
      } catch (error) {
         // Handle specific "already exists" error
        if (error.message.includes('already exists')) {
            return res.status(409).json({ message: error.message }); // Conflict
        }
        next(error); // Pass other errors to global handler
      }
    };
    
    exports.getAllSubscribers = async (req, res, next) => {
       try {
         // Allow filtering active subscribers via query param ?activeOnly=false
         const activeOnly = req.query.activeOnly !== 'false';
         const subscribers = await subscriberService.findAll(activeOnly);
         res.status(200).json(subscribers);
       } catch (error) {
         next(error);
       }
    };
    
    exports.getSubscriberById = async (req, res, next) => {
      const errors = validationResult(req); // Check param validation
      if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
      }
      try {
        const subscriber = await subscriberService.findById(req.params.id);
        res.status(200).json(subscriber);
      } catch (error) {
        // Handle specific "not found" error
        if (error.message.includes('not found')) {
            return res.status(404).json({ message: error.message });
        }
        next(error);
      }
    };
    
    exports.updateSubscriber = async (req, res, next) => {
      const errors = validationResult(req); // Check param and body validation
       if (!errors.isEmpty()) {
         return res.status(400).json({ errors: errors.array() });
       }
       try {
         // Ensure only allowed fields are passed to the service
         const allowedData = {};
         if (req.body.phoneNumber !== undefined) allowedData.phoneNumber = req.body.phoneNumber;
         if (req.body.firstName !== undefined) allowedData.firstName = req.body.firstName;
         if (req.body.lastName !== undefined) allowedData.lastName = req.body.lastName;
         if (req.body.isActive !== undefined) allowedData.isActive = req.body.isActive;
    
         if (Object.keys(allowedData).length === 0) {
            return res.status(400).json({ message: 'No valid fields provided for update.' });
         }
    
         const subscriber = await subscriberService.update(req.params.id, allowedData);
         res.status(200).json(subscriber);
       } catch (error) {
         // Handle specific "not found" and "already exists" errors
         if (error.message.includes('not found')) {
             return res.status(404).json({ message: error.message });
         }
         if (error.message.includes('already exists')) {
             return res.status(409).json({ message: error.message }); // Conflict
         }
         next(error);
       }
    };
    
    exports.deleteSubscriber = async (req, res, next) => {
       const errors = validationResult(req); // Check param validation
       if (!errors.isEmpty()) {
         return res.status(400).json({ errors: errors.array() });
       }
       try {
         await subscriberService.remove(req.params.id);
         res.status(204).send(); // No Content
       } catch (error) {
         // Handle specific "not found" and constraint violation errors
         if (error.message.includes('not found')) {
             return res.status(404).json({ message: error.message });
         }
         if (error.message.includes('related message history')) {
             return res.status(409).json({ message: error.message }); // Conflict
         }
         next(error);
       }
    };

Deploy this application to production:

  • Use environment variables for all configuration
  • Implement proper monitoring and alerting
  • Set up automated backups for your database
  • Use a process manager like PM2 for Node.js
  • Configure SSL/TLS for secure connections
  • Implement proper logging and error tracking

Platform-Specific Deployment Examples:

AWS Deployment:

  • Deploy application on EC2 or ECS with Application Load Balancer
  • Use RDS PostgreSQL with automated backups and Multi-AZ for high availability
  • Use ElastiCache Redis for BullMQ job queue
  • Store secrets in AWS Secrets Manager, access via IAM roles
  • Configure SSL certificates through AWS Certificate Manager

Heroku Deployment:

  • Use Heroku Postgres add-on (Standard tier minimum for production)
  • Use Heroku Redis add-on for BullMQ
  • Configure dyno formation: web dyno for API, worker dyno for BullMQ workers
  • Set environment variables via heroku config:set
  • Enable automatic SSL with Heroku ACM

DigitalOcean Deployment:

  • Deploy on App Platform with managed PostgreSQL database
  • Use DigitalOcean Managed Redis for job queue
  • Configure health checks on /health endpoint
  • Set environment variables in App Platform console
  • Enable automatic HTTPS with Let's Encrypt

Docker Containerization:

dockerfile
# Dockerfile
FROM node:16-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npx prisma generate
EXPOSE 3000
CMD ["node", "src/server.js"]

Docker Compose for Local Development:

yaml
version: '3.8'
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://postgres:password@db:5432/sms_campaigns
      - REDIS_URL=redis://redis:6379
    depends_on:
      - db
      - redis
  db:
    image: postgres:14-alpine
    environment:
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=sms_campaigns
  redis:
    image: redis:6-alpine

CI/CD Pipeline Setup (GitHub Actions Example):

yaml
name: Deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test
      - name: Deploy to production
        run: |
          # Your deployment commands here

Frequently Asked Questions About Plivo SMS Marketing Campaigns

How do I set up Plivo for SMS campaigns in Node.js?

Set up Plivo SMS campaigns by: (1) creating a Plivo account and obtaining API credentials (Auth ID and Auth Token), (2) purchasing a Plivo phone number ($0.80–$2.00/month), (3) installing Plivo SDK v4 with npm install plivo@4, (4) initializing the client with new plivo.Client(authId, authToken), and (5) implementing subscriber management with PostgreSQL/Prisma and job queuing with BullMQ. This guide provides complete code examples for production-ready implementation.

What is TCPA compliance and do I need it for SMS marketing?

TCPA (Telephone Consumer Protection Act) compliance is required when sending marketing SMS to US numbers. Requirements include: (1) obtaining prior express written consent from recipients, (2) providing clear opt-out instructions in every message (e.g., "Reply STOP to unsubscribe"), (3) maintaining consent records with timestamps, and (4) honoring opt-outs immediately. Non-compliance carries penalties up to $1,500 per violation. Similar regulations exist globally: GDPR in EU and CASL in Canada. Consult legal counsel for compliance guidance.

Compliant vs Non-Compliant Message Examples:

Compliant:

ACME Store: Flash sale! 50% off all items today. Shop now: acme.co/sale Reply STOP to unsubscribe, HELP for info. Msg&data rates may apply.

Non-Compliant:

ACME Store: Buy now! Limited time offer!

Issues: Missing opt-out instructions, no consent reference, vague sender identification.

How much does Plivo SMS cost?

Plivo phone numbers cost $0.80–$2.00/month depending on country. SMS rates vary by destination: US domestic SMS costs approximately $0.0035 per segment (160 characters), while international rates range $0.005–$0.20 per segment. Trial accounts include 20 free messages but have restrictions (verified numbers only, "[Plivo Trial]" prefix). Check Plivo's pricing page for current rates and purchase credits to remove trial limitations.

What's the difference between Plivo SDK v4 and v5?

Plivo SDK v5+ introduces breaking changes in client initialization and API methods compared to v4. This guide uses SDK v4 (plivo@4.x.x) syntax. If you install v5+, client initialization changes from new plivo.Client() to different patterns, and method signatures differ. Verify your installed version with npm list plivo. Consult Plivo's migration guide when upgrading from v4 to v5. Pin versions in package.json for production stability.

How do I handle SMS opt-outs with Plivo webhooks?

Handle opt-outs by: (1) configuring a webhook endpoint in your Plivo number settings, (2) implementing Express.js POST route to receive inbound messages, (3) validating webhook signatures using Plivo's signature verification, (4) checking message content for opt-out keywords (STOP, UNSUBSCRIBE, CANCEL, END, QUIT), (5) updating subscriber status to 'unsubscribed' in database, and (6) sending confirmation reply. This guide includes complete webhook implementation with signature validation and automatic opt-out processing.

Can I use Plivo trial account for production?

No, trial accounts have restrictions unsuitable for production: (1) SMS only sends to numbers verified in Plivo Console > Phone Numbers > Sandbox Numbers, (2) all messages include "[Plivo Trial]" prefix, and (3) limited to 20 free messages. Purchase Plivo credits to remove restrictions and enable production use. Trial accounts work well for development and testing before deploying to production.

How do I validate phone numbers for Plivo SMS?

Validate phone numbers using E.164 format: + followed by country code and subscriber number (1-15 digits total). Basic regex validation: /^\+[1-9]\d{1,14}$/ checks format but doesn't validate country code validity or number length per country. For production, use libphonenumber-js library for comprehensive validation including country-specific rules. This guide includes Express.js validation middleware with express-validator for API endpoints.

What is BullMQ and why use it for SMS campaigns?

BullMQ is a Redis-based job queue for Node.js that handles asynchronous SMS sending. Use BullMQ to: (1) process bulk messages without blocking API responses, (2) implement retry logic with exponential backoff for failed deliveries, (3) rate limit API calls to comply with Plivo's throttling, (4) scale horizontally by adding worker processes, and (5) track job status and delivery attempts. This guide uses BullMQ v4 (bullmq@4) with complete worker implementation and error handling.

BullMQ Alternatives Comparison:

LibraryProsConsBest For
BullMQModern API, TypeScript support, Redis Cluster supportRequires Redis 6+Production apps, high-scale campaigns
AgendaMongoDB-based, no Redis neededSlower than Redis solutionsApps already using MongoDB
Bee-QueueSimple, fast, minimal featuresNo delayed jobs, basic retrySimple use cases, prototypes

How do Plivo webhooks work for delivery status?

Plivo webhooks send HTTP POST requests to your configured endpoint when message status changes. Configure webhooks in Plivo Console > Phone Numbers > select number > Message URL. Your endpoint receives delivery status (queued, sent, delivered, undelivered, failed) and metadata (MessageUUID, timestamp, error codes). Always validate webhook signatures using Plivo's signature verification to prevent unauthorized requests. This guide includes complete webhook handling with Express.js routes and signature validation.

What database should I use for SMS campaigns?

Use PostgreSQL for SMS campaigns due to: (1) robust ACID compliance for transactional data, (2) excellent support for JSON data types for campaign metadata, (3) powerful indexing for phone number lookups, (4) reliable connection pooling for high concurrency, and (5) strong ecosystem with ORMs like Prisma. This guide uses PostgreSQL with Prisma ORM for type-safe database queries, complete schema definitions for subscribers and campaigns, and proper indexing for performance.

How do I deploy Plivo SMS campaigns to production?

Deploy to production by: (1) using environment variables for all credentials (never commit .env files), (2) implementing process managers like PM2 for automatic restarts, (3) configuring SSL/TLS certificates for webhook endpoints, (4) setting up monitoring with Winston logging and health check endpoints, (5) implementing rate limiting with express-rate-limit, (6) using connection pooling for PostgreSQL and Redis, and (7) setting up automated database backups. Deploy to platforms like AWS, DigitalOcean, or Heroku with managed PostgreSQL and Redis services.

Can I schedule SMS campaigns for specific times?

Yes, schedule campaigns using BullMQ's delayed job feature. Add jobs with delay option: await smsQueue.add('send-sms', data, { delay: millisecondsUntilSend }). Calculate delay based on target timestamp minus current time. For recurring campaigns, implement CRON-based scheduling or use BullMQ's repeat option. Store campaign schedules in PostgreSQL campaigns table with sendAt timestamps and process with scheduled workers. This approach handles timezone conversions and respects user preferences.

Timezone Handling Best Practices:

  • Store all timestamps in UTC in database (scheduledAt field)
  • Accept user input with timezone identifier (e.g., "America/New_York")
  • Use date-fns-tz or luxon for timezone conversions
  • Calculate UTC offset at job creation time: targetTime - Date.now()
  • Store subscriber timezone preferences in database for personalized scheduling
  • Handle DST transitions by using timezone identifiers, not offsets

Example:

javascript
const { zonedTimeToUtc } = require('date-fns-tz');
const scheduledTime = zonedTimeToUtc('2025-10-10 09:00:00', 'America/New_York');
const delay = scheduledTime.getTime() - Date.now();
await smsQueue.add('send-sms', messageData, { delay });

Conclusion

You've built a production-ready SMS marketing campaign system using Plivo, Node.js, Express, PostgreSQL, and BullMQ. This implementation includes:

  • Subscriber Management: Complete CRUD operations with E.164 phone number validation
  • Campaign Creation: Database-backed campaign management with status tracking
  • Asynchronous Processing: BullMQ job queue with retry logic and error handling
  • Webhook Integration: Delivery status tracking and automated opt-out handling
  • Security Features: API key authentication, rate limiting, and webhook signature verification
  • TCPA Compliance: Opt-out management and consent tracking capabilities
  • Production-Ready Code: Comprehensive error handling, logging with Winston, and deployment guidelines

Note: This guide demonstrates the foundation for subscriber management. Complete implementation requires additional sections for campaign creation, BullMQ worker processes, webhook handlers, authentication middleware, validation middleware, error handling, and logging utilities. Use these patterns as building blocks to complete your SMS campaign system.

Next Steps:

  • Implement campaign management API endpoints and services
  • Set up BullMQ workers for asynchronous SMS sending
  • Create webhook handlers for delivery status and inbound messages
  • Add authentication and validation middleware
  • Implement Winston logger utility
  • Build campaign analytics and reporting dashboards
  • Add scheduled campaign functionality using BullMQ's delayed jobs
  • Integrate additional channels (email, push notifications) for omnichannel campaigns
  • Implement A/B testing for message variations
  • Add message templates and personalization variables
  • Set up monitoring and alerting with tools like Prometheus or Datadog
  • Implement comprehensive testing with Jest and Supertest
  • Deploy to production using Docker containers and orchestration platforms

Related Resources:

This foundation provides the essential building blocks for a scalable SMS marketing platform. Customize and extend these components to meet your specific business requirements while maintaining code quality and compliance standards.

Frequently Asked Questions

What are the prerequisites for building this SMS campaign application?

You need Node.js, npm (or yarn), access to a PostgreSQL database, a Redis instance, a Plivo account, and a basic understanding of Node.js, Express, REST APIs, and databases. ngrok is essential for local development and testing.

How to send bulk SMS messages using Plivo?

Utilize the Plivo Node.js SDK along with a job queue system like BullMQ and Redis. This allows asynchronous sending of bulk SMS messages directly from your Node.js backend, ensuring reliable and scalable delivery, especially for personalized campaigns. The provided example uses a job queue to handle sending in the background, improving responsiveness.

What is the role of Express.js in an SMS campaign application?

Express.js acts as the API layer, handling HTTP requests and responses. It routes incoming requests to appropriate controllers, which then interact with services for business logic and Plivo for SMS functionality. Express.js provides the framework for structuring your backend application and routes communication through clear entrypoints like controllers.

Why does this setup use PostgreSQL and Prisma?

PostgreSQL is a relational database used to store subscriber data, campaign details, and message logs, offering structured data management. Prisma simplifies database interactions by providing a modern ORM (Object-Relational Mapper), making it easier to work with the database in Node.js and providing typesafe methods for database access.

When should I use ngrok in SMS campaign development?

ngrok is crucial during *development* to expose your local server to the internet so Plivo webhooks can reach it. Plivo needs a public URL for incoming messages and status updates, hence ngrok acts as the temporary tunnel during development. Remember to replace the ngrok URL with your production URL when deploying.

Can I use an Alphanumeric Sender ID with Plivo?

Yes, for supported countries outside the US and Canada, you can use an Alphanumeric Sender ID instead of a phone number. Configure this in your Plivo account under Messaging > Sender IDs. Alphanumeric Sender IDs enable message personalization and branding but are limited by country support, so US/Canada usage generally requires a phone number instead.

How to manage subscriber opt-outs in the SMS campaign?

The `subscriberService.setOptOutStatus` function handles opt-outs by updating the `isActive` flag in the subscriber record. The system can process incoming "STOP" messages via webhooks and automatically opt-out users. Ensure E.164 format is used consistently for matching.

What is the purpose of BullMQ in this architecture?

BullMQ is a powerful job queue system that handles the asynchronous sending of SMS messages using Redis. By offloading message sending to a queue, the API remains responsive and avoids blocking on potentially long-running SMS operations, especially during bulk campaigns.

How to handle Plivo webhooks securely?

The `plivoService.validateWebhookSignature` function validates incoming webhooks using Plivo's signature. This is *crucial* to prevent unauthorized requests. The function requires access to the *raw* (unparsed) request body. The code example shows how to configure this, but it relies on the `req.rawBody` being populated correctly by middleware *before* any JSON body parsing.

What is the project setup process for building this application?

Start by creating a directory, initializing npm, and installing required packages like Express, Plivo SDK, Prisma, BullMQ, Redis, and dotenv. Then, initialize Prisma, configure environment variables in the .env file, and set up `nodemon` for development. The article provides detailed commands for this initial setup.

How to configure Plivo for receiving incoming messages?

Create a Plivo Application in the Plivo console (Messaging > Applications > XML), set the Message URL to your webhook endpoint (use ngrok during development), and link your purchased Plivo number to this application. This enables Plivo to forward incoming messages to your backend.

Why is a production webhook URL important?

A production webhook URL is essential because ngrok URLs are temporary. For your deployed application, you need a stable, publicly accessible URL (provided by your hosting platform) so Plivo can consistently reach your webhooks for incoming messages and status updates.

How does the system architecture handle message status updates?

Plivo sends message status updates (e.g., sent, failed, delivered) to the Delivery Report URL you configured in your Plivo Application settings. Your application receives these updates via webhooks, and you can then update the status of `SentMessage` records for logging and monitoring.

What's the role of the SentMessage model in the database schema?

The `SentMessage` model logs individual message attempts, associating them with a specific `Campaign` and `Subscriber`. It stores Plivo's `messageUuid`, delivery status, and timestamps, enabling detailed tracking and reporting. The article also recommends including the full callback payload for status updates from Plivo via `statusCallback`.