code examples

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

Building Production-Ready SMS Marketing Campaigns with Fastify, Plivo & Node.js: Complete Developer Guide

Master SMS marketing campaigns with Fastify (70,000+ req/s), Plivo APIs, and Node.js. Complete guide to TCPA-compliant subscriber management, E.164 phone validation, webhook integration, bulk messaging, BullMQ job queues, and production deployment with Docker and CI/CD.

Building Production-Ready SMS Marketing Campaigns with Fastify, Plivo & Node.js: Complete Developer Guide

This comprehensive guide walks you through building a production-ready SMS marketing campaign application using Fastify (70,000+ req/s performance), Plivo's messaging APIs, and modern Node.js best practices. You'll master everything from initial project setup to TCPA-compliant deployment, monitoring, and scaling your SMS infrastructure.

By the end of this tutorial, you'll have a functional backend service capable of managing subscriber lists with Mobile Number Portability (MNP) support, creating TCPA-compliant SMS marketing campaigns, and sending bulk messages via Plivo with production-grade security, error handling, and horizontal scalability.

Project Overview and Goals

What We're Building:

We are creating a backend API service using Fastify. This service will manage:

  1. Subscribers: Storing phone numbers and consent status for receiving marketing messages.
  2. Campaigns: Defining marketing messages and targeting specific subscriber groups (though for simplicity, we'll initially target all opted-in subscribers).
  3. Sending: Triggering the dispatch of campaign messages to opted-in subscribers via the Plivo SMS API.

Problem Solved:

This application provides the core infrastructure needed for businesses to leverage SMS marketing effectively. It centralizes subscriber management, ensures consent is handled, and integrates with a reliable communication platform (Plivo) for message delivery.

Technologies Used:

  • Node.js: The JavaScript runtime environment.
  • Fastify: A high-performance, low-overhead Node.js web framework. Fastify v5 (latest as of 2024-2025) delivers 70,000-80,000 requests per second in benchmarks, significantly outperforming Express (20,000-30,000 req/s) while maintaining production reliability 1. Chosen for its speed, extensibility, and developer-friendly features (like built-in validation and logging).
  • Plivo: A cloud communications platform providing SMS API capabilities. Plivo Node.js SDK version 4.74.0 (October 2024) 2. Chosen for its reliable delivery, developer tools, and scalability.
  • PostgreSQL: A robust open-source relational database.
  • Prisma: A modern ORM for Node.js and TypeScript. Prisma 6.x (latest as of 2024-2025) has migrated from Rust to TypeScript for faster cold starts and improved developer experience 3, simplifying database interactions.
  • Docker: For containerizing the application for consistent deployment.
  • Fly.io: (Example deployment platform) A platform for deploying full-stack apps and databases.
  • GitHub Actions: For continuous integration and deployment (CI/CD).

System Architecture:

text
+-------------+       +-----------------+       +-------------+
|   Client    | ----> |  Fastify API    | ----> |  Plivo API  |
| (e.g., Web) |       | (This Project)  |       |  (for SMS)  |
+-------------+       +--------+--------+       +-------------+
                              |
                              |
                              v
                      +---------------+
                      |  PostgreSQL   |
                      |  (Database)   |
                      +---------------+

Prerequisites:

  • Node.js (LTS version recommended) and npm/yarn installed.
  • Access to a PostgreSQL database instance, including connection details (URL string with username, password, host, port, database name) for configuration.
  • A Plivo account with Auth ID, Auth Token, and a Plivo phone number capable of sending SMS.
  • Docker installed locally (for containerization and deployment).
  • Fly.io CLI installed (optional, for deployment).
  • Basic familiarity with Node.js, REST APIs, and SQL.

Final Outcome:

A containerized Fastify application exposing RESTful API endpoints for managing subscribers and campaigns, capable of sending SMS messages via Plivo. The application will include logging, basic error handling, security measures, and deployment configurations.

Important Legal Compliance Note:

SMS marketing in the United States is governed by the Telephone Consumer Protection Act (TCPA). As of 2024, key requirements include 4:

  • Prior Express Written Consent (PEWC): Obtain explicit written consent before sending marketing SMS
  • Opt-Out Processing: Honor opt-out requests within 10 business days (updated from 30 days in 2024)
  • Financial Penalties: $500-$1,500 per violation with no maximum limit on statutory damages
  • Timing Restrictions: Send messages only between 8 AM and 9 PM recipient's local time
  • National Do Not Call (DNC) Registry: Never text numbers on the DNC list

Additionally, 15 U.S. states have specific SMS marketing laws requiring express consent. This application implements opt-in/opt-out functionality, but you must ensure proper consent collection processes and compliance with all applicable regulations.


1. Setting up the Project

Let's initialize our Fastify project and set up the basic structure.

  1. Create Project Directory:

    bash
    mkdir fastify-plivo-sms
    cd fastify-plivo-sms
  2. Initialize Node.js Project:

    bash
    npm init -y
  3. Install Core Dependencies:

    bash
    npm install fastify fastify-env fastify-sensible fastify-cors pino-pretty @prisma/client plivo async-retry libphonenumber-js fastify-rate-limit @fastify/helmet @fastify/sentry @sentry/node @sentry/tracing
    # For potential async/job queue implementation (optional but recommended for production):
    # npm install bullmq ioredis
    • fastify: The core framework.
    • fastify-env: For managing environment variables.
    • fastify-sensible: Adds useful decorators like httpErrors.
    • fastify-cors: Enables Cross-Origin Resource Sharing.
    • pino-pretty: Developer-friendly logger formatting (for development).
    • @prisma/client: Prisma's database client.
    • plivo: The official Plivo Node.js SDK (v4.74.0+ recommended).
    • async-retry: For adding retry logic to Plivo calls.
    • libphonenumber-js: For robust phone number validation/formatting.
    • fastify-rate-limit: For API rate limiting.
    • @fastify/helmet: For security headers.
    • @fastify/sentry, @sentry/node, @sentry/tracing: For error tracking with Sentry.
    • bullmq, ioredis: (Optional) BullMQ is production-proven for background jobs, used by Microsoft, Vendure, and Datawrapper 5. Recommended for high-volume SMS campaigns requiring asynchronous processing with Redis-backed job queues.
  4. Install Development Dependencies:

    bash
    npm install --save-dev prisma nodemon tap
    • prisma: The Prisma CLI for migrations and generation.
    • nodemon: Automatically restarts the server during development.
    • tap: Fastify's recommended testing framework.
  5. Configure package.json Scripts:

    Update the scripts section in your package.json:

    json
    {
      "scripts": {
        "test": "tap 'test/**/*.test.js'",
        "start": "node src/server.js",
        "dev": "nodemon --watch src --exec 'node --inspect=127.0.0.1:9229' src/server.js | pino-pretty",
        "db:seed": "node scripts/seed.js",
        "prisma:migrate": "npx prisma migrate dev",
        "prisma:generate": "npx prisma generate",
        "prisma:deploy": "npx prisma migrate deploy"
      }
    }
    • test: Runs tests using tap.
    • start: Runs the application in production mode.
    • dev: Runs the application in development mode with nodemon for auto-reloads, pino-pretty for readable logs, and the Node inspector attached to 127.0.0.1 (localhost only) for security.
    • db:seed: Runs the database seeding script.
    • prisma:*: Helper scripts for Prisma operations.
  6. Initialize Prisma:

    bash
    npx prisma init --datasource-provider postgresql

    This creates a prisma directory with a schema.prisma file and a .env file for your database connection string.

  7. Configure Environment Variables (.env):

    Update the generated .env file. Add placeholders for Plivo credentials and other settings.

    dotenv
    # .env
    
    # Database Connection (Prisma uses this)
    # Example: postgresql://user:password@host:port/database?schema=public
    DATABASE_URL="postgresql://postgres:yourpassword@localhost:5432/sms_marketing?schema=public"
    
    # Application Settings
    HOST=0.0.0.0
    PORT=3000
    LOG_LEVEL=info # Use 'debug' for more verbose logging
    NODE_ENV=development # Set to 'production' in deployed environments
    
    # Plivo Credentials
    PLIVO_AUTH_ID="YOUR_PLIVO_AUTH_ID"
    PLIVO_AUTH_TOKEN="YOUR_PLIVO_AUTH_TOKEN" # Also used for webhook signature validation
    PLIVO_SENDER_NUMBER="YOUR_PLIVO_PHONE_NUMBER" # Must be SMS-enabled
    
    # Sentry DSN (Optional - for error tracking)
    SENTRY_DSN=""
    
    # Add any other environment variables as needed
    • Important: Replace placeholders with your actual database URL and Plivo credentials. Never commit your .env file with real secrets to version control. Add .env to your .gitignore file.
  8. Project Structure:

    Create the following directory structure:

    text
    fastify-plivo-sms/
    ├── prisma/
    │   └── schema.prisma
    ├── src/
    │   ├── plugins/
    │   ├── routes/
    │   ├── services/
    │   ├── app.js        # Main Fastify application setup
    │   └── server.js     # Entry point to start the server
    ├── test/
    ├── scripts/
    │   └── seed.js       # Database seeding script
    ├── .github/
    │   └── workflows/
    │       └── deploy.yml # Example CI/CD workflow
    ├── .env
    ├── .gitignore
    ├── Dockerfile
    ├── fly.toml          # Example deployment config
    └── package.json
  9. Basic Fastify App Setup (src/app.js):

    javascript
    // src/app.js
    'use strict'
    
    const path = require('path')
    const Fastify = require('fastify')
    const sensible = require('fastify-sensible')
    const env = require('fastify-env')
    const cors = require('fastify-cors')
    const autoload = require('fastify-autoload')
    const helmet = require('@fastify/helmet')
    const rateLimit = require('fastify-rate-limit')
    const Sentry = require('@sentry/node')
    
    // Define environment variable schema
    const schema = {
      type: 'object',
      required: [ 'PORT', 'HOST', 'DATABASE_URL', 'PLIVO_AUTH_ID', 'PLIVO_AUTH_TOKEN', 'PLIVO_SENDER_NUMBER' ],
      properties: {
        PORT: { type: 'string', default: 3000 },
        HOST: { type: 'string', default: '0.0.0.0' },
        DATABASE_URL: { type: 'string' },
        LOG_LEVEL: { type: 'string', default: 'info'},
        NODE_ENV: { type: 'string', default: 'development' },
        PLIVO_AUTH_ID: { type: 'string' },
        PLIVO_AUTH_TOKEN: { type: 'string' },
        PLIVO_SENDER_NUMBER: { type: 'string' },
        SENTRY_DSN: { type: 'string', default: '' } // Optional
      },
    }
    
    const envOptions = {
      confKey: 'config', // Access variables via `fastify.config`
      schema: schema,
      dotenv: true // Load .env file
    }
    
    async function build (opts = {}) {
      const fastify = Fastify({
        logger: {
          level: process.env.LOG_LEVEL || 'info', // Default log level
          // Use pino-pretty in dev, JSON in production
          ...(process.env.NODE_ENV !== 'production' && {
              transport: {
                  target: 'pino-pretty',
                  options: {
                      translateTime: 'HH:MM:ss Z',
                      ignore: 'pid,hostname',
                  },
              },
          }),
        },
        ...opts
      })
    
      // Register essential plugins FIRST
      await fastify.register(env, envOptions)
    
       // Initialize Sentry (early, using env vars from fastify.config)
       if (fastify.config.SENTRY_DSN) {
         fastify.register(require('@fastify/sentry'), {
           dsn: fastify.config.SENTRY_DSN,
           environment: fastify.config.NODE_ENV || 'development',
           // Add other Sentry config as needed (release, tracesSampleRate)
         });
         fastify.log.info('Sentry plugin registered.');
       } else {
         fastify.log.warn('SENTRY_DSN not found, Sentry integration disabled.');
       }
    
      await fastify.register(sensible) // Adds useful utilities like fastify.httpErrors
      await fastify.register(cors, {
        origin: '*', // Configure allowed origins appropriately for production
        methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
      })
      await fastify.register(helmet, {
          // Disable CSP if not needed or configured separately via reverse proxy
          contentSecurityPolicy: false
      });
      await fastify.register(rateLimit, {
          max: 100, // Max requests per windowMs per key
          timeWindow: '1 minute',
          errorResponseBuilder: (req, context) => ({
              code: 429, error: 'Too Many Requests',
              message: `Rate limit exceeded. Retry after ${context.after}`
          })
          // Consider using Redis for distributed rate limiting:
          // redis: new require('ioredis')(process.env.REDIS_URL)
      });
    
      // Autoload plugins (like database, plivo client)
      await fastify.register(autoload, {
        dir: path.join(__dirname, 'plugins'),
        options: { ...opts, config: fastify.config } // Pass config to plugins
      })
    
      // Autoload routes (including webhooks)
      await fastify.register(autoload, {
        dir: path.join(__dirname, 'routes'),
        options: { prefix: '/api/v1' } // Prefix API routes
      })
      // Load health check route without prefix
      await fastify.register(require('./routes/health'))
      // Load webhook route without prefix for easier configuration in Plivo
      await fastify.register(require('./routes/webhooks'), { prefix: '/webhooks' })
    
    
      fastify.log.info('Application plugins and routes loaded.')
    
      return fastify
    }
    
    module.exports = { build }
    • Why fastify-env? It validates required environment variables on startup, preventing runtime errors due to missing configuration.
    • Why fastify-sensible? Provides standard HTTP error objects (fastify.httpErrors.notFound()) and other utilities.
    • Why fastify-autoload? Simplifies loading plugins and routes from separate directories, keeping app.js clean.
    • Security Plugins: helmet and rateLimit added early.
    • Error Tracking: Sentry initialized early.
    • Route Loading: API routes prefixed, health and webhook routes loaded separately without prefix.
  10. Server Entry Point (src/server.js):

    javascript
    // src/server.js
    'use strict'
    
    // If using Sentry, require it early for tracing instrumentation
    if (process.env.SENTRY_DSN) {
      require('@sentry/node');
    }
    
    const { build } = require('./app')
    
    async function start () {
      let fastify
      try {
        fastify = await build()
    
        await fastify.ready() // Ensure all plugins are loaded
    
        const host = fastify.config.HOST
        const port = fastify.config.PORT
    
        await fastify.listen({ port: port, host: host }) // Use object syntax for listen
    
      } catch (err) {
        console.error('Error starting server:', err)
        if (fastify) {
          fastify.log.error(err)
        } else {
            // If Fastify didn't even initialize, log directly
            console.error(err);
        }
        process.exit(1)
      }
    }
    
    start()
  11. Add .gitignore:

    Create a .gitignore file in the root directory:

    text
    # .gitignore
    node_modules/
    .env
    dist/
    coverage/
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    *.local
    
    # Prisma generated client (usually in node_modules, but good to ignore explicitly if output changes)
    # node_modules/@prisma/client/
    
    # Prisma migration SQL files (usually not committed, schema is the source of truth)
    # If you *do* want to commit SQL for review, remove or adjust this line.
    prisma/migrations/**/*.sql
    !prisma/migrations/migration_lock.toml
    
    # Fly.io state (if applicable)
    .fly/
    • Note on prisma/migrations/*.sql: This line prevents generated SQL migration files from being committed. This is standard practice as the schema.prisma file is the source of truth, and migrations are generated from it. If your team workflow requires committing the SQL files for review, you can remove this line.

You now have a basic Fastify project structure ready for adding features. Run npm run dev to start the development server. You should see log output indicating the server is listening.


2. Implementing Core Functionality

We'll start by defining our database schema and creating services for managing subscribers and campaigns.

  1. 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 Subscriber {
      id          String   @id @default(cuid()) // Unique ID
      phoneNumber String   @unique             // Subscriber's phone number (E.164 format recommended)
      isOptedIn   Boolean  @default(true)      // Consent status for marketing
      createdAt   DateTime @default(now())
      updatedAt   DateTime @updatedAt
    
      @@index([isOptedIn]) // Index for filtering opted-in subscribers
    }
    
    model Campaign {
      id           String    @id @default(cuid())
      name         String    // Internal name for the campaign
      messageBody  String    // The SMS message content
      sentAt       DateTime? // Timestamp when the campaign was sent
      createdAt    DateTime  @default(now())
      updatedAt    DateTime  @updatedAt
    }
    • Why CUID? Provides unique, sortable identifiers.
    • Why @unique on phoneNumber? Prevents duplicate subscriber entries.
    • Why isOptedIn? Crucial for tracking consent, a legal requirement under TCPA (USA) and GDPR (EU). As of 2024, TCPA violations carry penalties of $500-$1,500 per message with no maximum limit 4.
    • Why @@index([isOptedIn])? Added an index to potentially speed up queries filtering by opt-in status, which is common.
    • Character Encoding Consideration: SMS messages use GSM-7 encoding (160 characters per message, 153 per segment when concatenated) or UCS-2 encoding (70 characters per message, 67 per segment). Non-GSM characters (emojis, curly quotes, special symbols) trigger automatic fallback to UCS-2, reducing message capacity significantly 6. Consider validating message content to avoid unexpected encoding fallback.
  2. Apply Database Migrations:

    Create the initial migration and apply it to your database.

    bash
    # Create the migration files based on schema changes
    npm run prisma:migrate -- --name init
    
    # Generate the Prisma client types
    npm run prisma:generate

    Prisma will create the Subscriber and Campaign tables in your PostgreSQL database.

  3. Create Prisma Plugin (src/plugins/prisma.js):

    This plugin initializes the Prisma client and makes it available throughout the Fastify application.

    javascript
    // src/plugins/prisma.js
    'use strict'
    
    const fp = require('fastify-plugin')
    const { PrismaClient } = require('@prisma/client')
    
    async function prismaPlugin (fastify, options) {
      const prisma = new PrismaClient({
        log: fastify.config.LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
      })
    
      try {
        await prisma.$connect()
        fastify.log.info('Prisma client connected successfully.')
      } catch (err) {
        fastify.log.error('Prisma client connection error:', err)
        throw new Error('Failed to connect to database') // Prevent server start if DB fails
      }
    
      // Make Prisma Client available through decorator
      fastify.decorate('prisma', prisma)
    
      // Graceful shutdown
      fastify.addHook('onClose', async (instance) => {
        instance.log.info('Disconnecting Prisma client...')
        await instance.prisma.$disconnect()
        instance.log.info('Prisma client disconnected.')
      })
    }
    
    module.exports = fp(prismaPlugin)
    • Why fastify-plugin? Ensures the plugin is loaded correctly and decorators (fastify.prisma) are available globally.
    • Why onClose hook? Ensures the database connection is closed cleanly when the server shuts down.
  4. Create Subscriber Service (src/services/subscriberService.js):

    This service encapsulates the logic for interacting with the Subscriber model, now using libphonenumber-js.

    javascript
    // src/services/subscriberService.js
    'use strict'
    const { parsePhoneNumberFromString } = require('libphonenumber-js');
    
    class SubscriberService {
      constructor (prisma) {
        this.prisma = prisma
      }
    
      _validateAndFormatNumber(phoneNumber) {
          if (typeof phoneNumber !== 'string') {
              throw new Error('Phone number must be a string.');
          }
          const parsed = parsePhoneNumberFromString(phoneNumber);
          if (!parsed || !parsed.isValid()) {
            throw new Error('Invalid phone number format.');
          }
          // Store consistently in E.164 format
          return parsed.format('E.164');
      }
    
      async createSubscriber (inputPhoneNumber) {
        const formattedNumber = this._validateAndFormatNumber(inputPhoneNumber);
    
        return this.prisma.subscriber.create({
          data: {
            phoneNumber: formattedNumber,
            isOptedIn: true, // Default to opted-in on creation, confirm consent via other means
          },
        })
      }
    
      async getSubscriberById (id) {
        return this.prisma.subscriber.findUnique({ where: { id } })
      }
    
      async getSubscriberByPhoneNumber (inputPhoneNumber) {
        // Validate format before querying
        const formattedNumber = this._validateAndFormatNumber(inputPhoneNumber);
        return this.prisma.subscriber.findUnique({ where: { phoneNumber: formattedNumber } })
      }
    
      async getAllSubscribers (filterOptedIn = null) {
         const whereClause = {};
         if (filterOptedIn !== null) {
           whereClause.isOptedIn = Boolean(filterOptedIn);
         }
         return this.prisma.subscriber.findMany({
             where: whereClause,
             orderBy: { createdAt: 'desc' } // Optional: order by creation date
         });
      }
    
      async updateOptInStatus (id, isOptedIn) {
        return this.prisma.subscriber.update({
          where: { id },
          data: { isOptedIn: Boolean(isOptedIn) },
        })
      }
    
       async updateOptInStatusByPhoneNumber (inputPhoneNumber, isOptedIn) {
         const formattedNumber = this._validateAndFormatNumber(inputPhoneNumber);
    
         // Use updateMany to avoid fetching first (more efficient if number is guaranteed unique)
         // Or stick to findUnique + update if you need the subscriber object returned potentially.
         const result = await this.prisma.subscriber.updateMany({
           where: { phoneNumber: formattedNumber },
           data: { isOptedIn: Boolean(isOptedIn) },
         });
    
         if (result.count === 0) {
             throw new Error('Subscriber not found.');
         }
         // Note: updateMany doesn't return the record, just the count.
         // If you need the updated record, you'd need to query again or use findUnique + update.
         return { phoneNumber: formattedNumber, isOptedIn: Boolean(isOptedIn), updatedCount: result.count };
       }
    
      async deleteSubscriber (id) {
        return this.prisma.subscriber.delete({ where: { id } })
      }
    }
    
    module.exports = SubscriberService
    • Validation: Uses libphonenumber-js for robust validation and formatting to E.164 standard.
  5. Create Campaign Service (src/services/campaignService.js):

    javascript
    // src/services/campaignService.js
    'use strict'
    
    class CampaignService {
      constructor (prisma) {
        this.prisma = prisma
      }
    
      async createCampaign (name, messageBody) {
        if (!name || !messageBody) {
          throw new Error('Campaign name and message body are required.')
        }
        return this.prisma.campaign.create({
          data: {
            name,
            messageBody,
          },
        })
      }
    
      async getCampaignById (id) {
        return this.prisma.campaign.findUnique({ where: { id } })
      }
    
      async getAllCampaigns () {
        return this.prisma.campaign.findMany({ orderBy: { createdAt: 'desc' }})
      }
    
      async markCampaignAsSent (id) {
        return this.prisma.campaign.update({
          where: { id },
          data: { sentAt: new Date() },
        })
      }
    
       async deleteCampaign (id) {
         // Consider implications: Should deleting prevent sending history?
         // Maybe add a 'deletedAt' field instead (soft delete).
         return this.prisma.campaign.delete({ where: { id } });
       }
    }
    
    module.exports = CampaignService

These services provide a clean abstraction layer over the database operations. We'll use them in our API routes next.


3. Building a Complete API Layer

Now, let's expose our services through Fastify routes with proper request validation.

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

    javascript
    // src/routes/subscribers.js
    'use strict'
    
    const SubscriberService = require('../services/subscriberService')
    
    // Validation Schemas
    const createSubscriberSchema = {
      body: {
        type: 'object',
        required: ['phoneNumber'],
        properties: {
          // Use a broader string type here; service layer handles validation/formatting
          phoneNumber: { type: 'string', description: 'Phone number, preferably in E.164 format (e.g., +15551234567)' },
        },
      },
      response: {
        201: { $ref: 'subscriber#' } // Reference shared schema
      }
    }
    
    const updateOptInSchema = {
       params: {
         type: 'object',
         required: ['id'],
         properties: { id: { type: 'string', format: 'cuid' } } // Add format hint if using CUIDs
       },
      body: {
        type: 'object',
        required: ['isOptedIn'],
        properties: {
          isOptedIn: { type: 'boolean' },
        },
      },
       response: {
         200: { $ref: 'subscriber#' }
       }
    }
    
     const subscriberParamsSchema = {
       params: {
         type: 'object',
         required: ['id'],
         properties: { id: { type: 'string', format: 'cuid' } }
       }
     }
    
    const listSubscribersSchema = {
       querystring: {
         type: 'object',
         properties: {
           optedIn: { type: 'boolean' }
         }
       },
       response: {
         200: {
           type: 'array',
           items: { $ref: 'subscriber#' }
         }
       }
     }
    
    
    module.exports = async function (fastify, opts) {
      // Instantiate service with Prisma client from fastify instance
      const subscriberService = new SubscriberService(fastify.prisma)
    
      // Shared Schema for Subscriber response
      fastify.addSchema({
         $id: 'subscriber',
         type: 'object',
         properties: {
           id: { type: 'string', format: 'cuid' },
           phoneNumber: { type: 'string', format: 'e164' }, // Indicate E.164 format
           isOptedIn: { type: 'boolean' },
           createdAt: { type: 'string', format: 'date-time' },
           updatedAt: { type: 'string', format: 'date-time' }
         }
      })
    
      // --- Routes ---
    
      // POST /api/v1/subscribers - Create a new subscriber
      fastify.post('/', { schema: createSubscriberSchema }, async (request, reply) => {
        try {
          const { phoneNumber } = request.body
          // Service layer now handles validation and checks for duplicates based on formatted number
          const subscriber = await subscriberService.createSubscriber(phoneNumber)
          return reply.code(201).send(subscriber)
        } catch (err) {
          request.log.error({ err }, 'Failed to create subscriber')
          if (err.message.includes('Invalid phone number')) {
              return reply.badRequest(err.message);
          }
          // Handle Prisma unique constraint error (P2002)
          if (err.code === 'P2002' && err.meta?.target?.includes('phoneNumber')) {
             // Extract the formatted number from the error if possible, or use input
             // This requires knowing the exact error structure or re-validating
             return reply.conflict(`Subscriber with phone number already exists.`);
          }
          throw fastify.httpErrors.internalServerError('Could not create subscriber.')
        }
      })
    
      // GET /api/v1/subscribers - List subscribers (optionally filter by optedIn status)
       fastify.get('/', { schema: listSubscribersSchema }, async (request, reply) => {
         try {
           const { optedIn } = request.query; // optedIn will be true, false, or undefined
           const filter = optedIn === undefined ? null : optedIn;
           const subscribers = await subscriberService.getAllSubscribers(filter);
           return subscribers;
         } catch (err) {
           request.log.error({ err }, 'Failed to retrieve subscribers');
           throw fastify.httpErrors.internalServerError('Could not retrieve subscribers.');
         }
       });
    
    
      // GET /api/v1/subscribers/:id - Get a specific subscriber
      fastify.get('/:id', { schema: { params: subscriberParamsSchema.params, response: { 200: { $ref: 'subscriber#'}} }}, async (request, reply) => {
         try {
           const { id } = request.params;
           const subscriber = await subscriberService.getSubscriberById(id);
           if (!subscriber) {
             throw fastify.httpErrors.notFound('Subscriber not found.');
           }
           return subscriber;
         } catch (err) {
             if (err.statusCode === 404) throw err; // Re-throw not found errors
             request.log.error({ err, subscriberId: request.params.id }, 'Failed to retrieve subscriber');
             throw fastify.httpErrors.internalServerError('Could not retrieve subscriber.');
         }
       });
    
    
      // PUT /api/v1/subscribers/:id/optin - Update opt-in status
      fastify.put('/:id/optin', { schema: updateOptInSchema }, async (request, reply) => {
        try {
          const { id } = request.params
          const { isOptedIn } = request.body
          const subscriber = await subscriberService.updateOptInStatus(id, isOptedIn)
          return subscriber
        } catch (err) {
           request.log.error({ err, subscriberId: request.params.id }, 'Failed to update subscriber opt-in status');
           if (err.code === 'P2025') { // Prisma record not found error
               throw fastify.httpErrors.notFound('Subscriber not found.');
           }
           throw fastify.httpErrors.internalServerError('Could not update subscriber.');
        }
      })
    
      // DELETE /api/v1/subscribers/:id - Delete a subscriber
      fastify.delete('/:id', { schema: { params: subscriberParamsSchema.params, response: { 204: {} } }}, async (request, reply) => {
         try {
           const { id } = request.params;
           await subscriberService.deleteSubscriber(id);
           return reply.code(204).send(); // No Content
         } catch (err) {
           request.log.error({ err, subscriberId: request.params.id }, 'Failed to delete subscriber');
            if (err.code === 'P2025') { // Prisma record not found error
                throw fastify.httpErrors.notFound('Subscriber not found.');
            }
           throw fastify.httpErrors.internalServerError('Could not delete subscriber.');
         }
       });
    
    }
    • Schema Validation: Updated schema to accept a broader string for phoneNumber at the API layer, relying on the service layer for E.164 validation and formatting. Added format hints for CUID and E.164.
    • Error Handling: Adjusted error handling for unique constraints, acknowledging that the service layer handles validation.
  2. Campaign Routes (src/routes/campaigns.js):

    javascript
    // src/routes/campaigns.js
    'use strict'
    
    const CampaignService = require('../services/campaignService')
    const SubscriberService = require('../services/subscriberService');
    const PlivoService = require('../services/plivoService');
    
    
    // Validation Schemas
    const createCampaignSchema = {
      body: {
        type: 'object',
        required: ['name', 'messageBody'],
        properties: {
          name: { type: 'string', minLength: 1 },
          messageBody: { type: 'string', minLength: 1, maxLength: 1600 }, // Consider SMS limits
        },
      },
      response: {
        201: { $ref: 'campaign#' }
      }
    }
    
    const campaignParamsSchema = {
       params: {
         type: 'object',
         required: ['id'],
         properties: { id: { type: 'string', format: 'cuid' } }
       }
     }
    
    const listCampaignsSchema = {
       response: {
         200: {
           type: 'array',
           items: { $ref: 'campaign#' }
         }
       }
     }
    
     // Updated response for /send to reflect async nature (even if underlying code is sync for demo)
     const sendCampaignSchema = {
       params: campaignParamsSchema.params,
       response: {
         202: { // Use 202 Accepted for async operations
           type: 'object',
           properties: {
             message: { type: 'string' },
             campaignId: { type: 'string' },
             status: { type: 'string', enum: ['queued', 'processing_sync'] }, // Indicate sync processing for this demo version
             estimated_subscribers: { type: 'integer' }
           }
         }
       }
     }
    
    module.exports = async function (fastify, opts) {
      const campaignService = new CampaignService(fastify.prisma)
      const subscriberService = new SubscriberService(fastify.prisma);
       // Pass Plivo client, sender number, and logger to PlivoService
    • Character Encoding Consideration: SMS messages use GSM-7 encoding (160 characters per message, 153 per segment when concatenated) or UCS-2 encoding (70 characters per message, 67 per segment). Non-GSM characters (emojis, curly quotes, special symbols) trigger automatic fallback to UCS-2, reducing message capacity significantly 6. Consider validating message content to avoid unexpected encoding fallback.

References

Frequently Asked Questions

What is Fastify and why should I use it for SMS marketing campaigns?

Fastify is a high-performance Node.js web framework that delivers 70,000-80,000 requests per second in benchmarks, significantly outperforming Express (20,000-30,000 req/s). For SMS marketing applications requiring bulk message processing, webhook handling, and real-time subscriber management, Fastify's low overhead, built-in validation, and production reliability make it ideal for handling concurrent API requests efficiently.

How do I ensure TCPA compliance for SMS marketing in the United States?

TCPA compliance requires Prior Express Written Consent (PEWC) before sending marketing SMS, processing opt-out requests within 10 business days (updated from 30 days in 2024), sending messages only between 8 AM and 9 PM recipient's local time, and never texting numbers on the National Do Not Call (DNC) Registry. Violations carry penalties of $500-$1,500 per message with no maximum limit. This tutorial implements opt-in/opt-out functionality, but you must establish proper consent collection processes and comply with the 15 U.S. states with additional specific SMS marketing laws.

What is the difference between GSM-7 and UCS-2 SMS encoding?

GSM-7 encoding supports 160 characters per SMS message (153 characters per segment when concatenated), while UCS-2 encoding supports only 70 characters (67 per segment). Non-GSM characters like emojis, curly quotes, or special symbols trigger automatic fallback to UCS-2, reducing your message capacity by more than half. For cost-effective bulk SMS campaigns, validate message content to avoid unexpected UCS-2 encoding.

How do I validate international phone numbers for SMS marketing?

Use the libphonenumber-js library to validate and format phone numbers to E.164 international standard (e.g., +15551234567). This tutorial's SubscriberService includes _validateAndFormatNumber() method that parses input numbers, validates them across 200+ countries, and consistently stores them in E.164 format for reliable Plivo API integration and duplicate prevention.

What is the Plivo SDK and how does it integrate with Fastify?

Plivo's Node.js SDK (v4.74.0 as of October 2024) provides a JavaScript interface for Plivo's cloud communications APIs, enabling SMS sending, MMS, phone calls, and carrier lookup. This tutorial integrates Plivo SDK with Fastify through a dedicated PlivoService plugin that handles authentication, message sending with retry logic, webhook signature validation, and delivery status tracking via async-retry patterns.

Should I use BullMQ for SMS campaign job queues?

Yes, for production SMS campaigns requiring asynchronous bulk message processing, BullMQ provides Redis-backed job queues with exactly-once semantics, horizontal scaling, and concurrent job processing. BullMQ is production-proven (used by Microsoft, Vendure, and Datawrapper) and essential for handling high-volume SMS campaigns without blocking your Fastify API endpoints or risking message loss during server restarts.

How do I handle SMS delivery status callbacks from Plivo?

Implement a webhook endpoint (e.g., /webhooks/plivo/status) that validates Plivo's signature using your PLIVO_AUTH_TOKEN, parses the delivery status payload, and updates your database with message states (queued, sent, delivered, failed, rejected). This tutorial includes webhook signature validation using the Plivo SDK's plivo.validate_signature() method to prevent spoofed callbacks and ensure data integrity.

What database should I use for SMS marketing subscriber management?

This tutorial uses PostgreSQL with Prisma ORM (v6.x, migrated from Rust to TypeScript for faster cold starts). PostgreSQL provides ACID compliance, robust indexing for phone number lookups, and production reliability. The schema includes Subscriber and Campaign models with unique constraints on phone numbers (E.164 format), opt-in status tracking, and created/updated timestamps for TCPA compliance auditing.

How do I deploy a Fastify SMS marketing application to production?

Containerize your application with Docker, configure environment variables for Plivo credentials and database connection strings, deploy to platforms like Fly.io or AWS ECS, and implement CI/CD pipelines using GitHub Actions. Essential production considerations include: enabling Helmet for security headers, configuring rate limiting (100 requests/minute default), setting up Sentry for error tracking, and using Redis-backed BullMQ for job queue persistence across deployments.

What are the SMS message length limits I need to know?

SMS messages are limited by character encoding: GSM-7 allows 160 characters per message (153 when concatenated into segments), while UCS-2 allows 70 characters (67 when concatenated). Messages exceeding these limits are automatically split into multiple segments, increasing costs proportionally. For optimal SMS marketing campaigns, keep messages under 160 GSM-7 characters or 70 UCS-2 characters, and avoid non-GSM characters (emojis, special symbols) unless necessary.

Summary

You now understand how to build production-ready SMS marketing campaigns using Fastify (70,000+ req/s), Plivo APIs (v4.74.0), and PostgreSQL with Prisma ORM (v6.x). You've learned to validate international phone numbers with libphonenumber-js, implement TCPA-compliant opt-in/opt-out functionality (10-day opt-out processing), handle SMS character encoding (GSM-7 vs. UCS-2), and process delivery status webhooks with signature validation.

The architecture includes subscriber management with E.164 phone number formatting, campaign creation and bulk message sending, retry logic with async-retry, security headers via Helmet, rate limiting (100 req/min), and Sentry error tracking. You've configured Docker containerization, GitHub Actions CI/CD, and deployment to platforms like Fly.io with environment-based configuration management.

Extend your implementation with:

  • Redis-backed BullMQ job queues for asynchronous bulk SMS processing with exactly-once semantics and horizontal scaling
  • Mobile Number Portability (MNP) lookup integration to verify current carrier before routing messages
  • Advanced TCPA compliance features: time zone-aware message scheduling (8 AM – 9 PM recipient's local time), National DNC Registry verification, and automated consent expiration tracking
  • Multi-tenancy support with organization-level subscriber lists and campaign isolation
  • Real-time analytics dashboards: delivery rates, opt-out rates, campaign performance metrics, and cost tracking per campaign
  • A/B testing framework for message content optimization with statistical significance calculation
  • SMS shortlink generation and click tracking for campaign ROI measurement
  • Internationalization (i18n) support for multi-language SMS campaigns with automatic character encoding detection

Footnotes

  1. Fastify Performance Benchmarks 2024-2025. Fastify v5 delivers 70,000-80,000 requests per second vs. Express 20,000-30,000 req/s. Source: Fastify Official Benchmarks and Express vs Fastify 2025 Performance Comparison.

  2. Plivo Node.js SDK. Latest version 4.74.0 published October 2024. Compatible with Node.js 5.5+. Source: Plivo NPM Package and Plivo Node.js SDK Documentation.

  3. Prisma ORM Version 6.x. Migrated from Rust to TypeScript for faster cold starts and improved developer experience. Version 6.16.3 published October 2025. Source: Prisma Official Website and Prisma GitHub Releases.

  4. TCPA SMS Marketing Compliance 2024. Requirements include Prior Express Written Consent (PEWC), opt-out processing within 10 business days (updated from 30 days), penalties of $500-$1,500 per violation, timing restrictions (8 AM - 9 PM), and DNC registry compliance. 15 U.S. states have additional specific requirements. Sources: TCPA Compliance Rule Changes 2024-2025 and ActiveProspect TCPA Text Messages Guide 2025. 2

  5. BullMQ Production Readiness. Redis-based job queue used in production by Microsoft, Vendure, and Datawrapper. Provides exactly-once queue semantics, horizontal scaling, and concurrent job processing. Source: BullMQ Official Documentation and BullMQ Going to Production Guide.

  6. SMS Character Encoding Standards. GSM-7 encoding supports 160 characters (153 per segment when concatenated). UCS-2 encoding supports 70 characters (67 per segment). Non-GSM characters trigger automatic fallback to UCS-2, reducing capacity. Sources: Twilio SMS Character Limits and GSM 03.38 Wikipedia. 2