code examples

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

Build SMS Marketing Campaigns with Plivo, Next.js & Supabase (2025 Guide)

Complete tutorial for building production-ready SMS marketing campaigns using Plivo API, Next.js 15, Supabase, and Node.js. Includes TCPA compliance, webhooks, and bulk messaging.

Complete Guide: Sending Bulk SMS Marketing Campaigns with Plivo (Node.js, Express)

Learn how to build a production-ready SMS marketing campaign system using Plivo's SMS API, Next.js 15, Supabase, and Node.js. This comprehensive guide covers everything from initial setup through deployment, including bulk SMS sending, TCPA compliance, webhook handling, and subscriber management.

Note: This guide adapts the Express-based implementation for Next.js API routes with Supabase for authentication and data persistence. The core Plivo integration patterns remain consistent across frameworks.

By completing this tutorial, you'll have a fully functional SMS marketing automation platform that sends targeted campaigns, processes delivery reports, manages subscriber opt-outs, and scales for production workloads.

What You'll Build: SMS Marketing Platform Overview

Application Features:

A Next.js application with Supabase backend that executes SMS marketing campaigns via Plivo's API.

Core System Capabilities:

  1. Create and manage SMS marketing campaigns with scheduling
  2. Handle subscriber lists with automatic opt-out management
  3. Send bulk SMS messages reliably via Plivo
  4. Process incoming replies and delivery reports using webhooks
  5. Store campaign data and analytics in Supabase PostgreSQL
  6. Implement TCPA-compliant messaging with consent tracking

Real-World Problem This Solves:

Manual SMS marketing doesn't scale beyond small lists. This automated system handles bulk messaging, tracks engagement metrics, processes opt-outs instantly, and maintains compliance—all while integrating seamlessly with your existing Next.js application.

TCPA Compliance Is Critical:

SMS marketing is heavily regulated in the United States. Your application must comply with:

  • TCPA (Telephone Consumer Protection Act): Obtain express written consent from recipients before sending promotional messages. Consent must be specific to your business (FCC December 2023 ruling)
  • CTIA Guidelines: Follow mobile carrier best practices. Never send SHAFT content (Sex, Hate, Alcohol, Firearms, Tobacco) – violations trigger immediate account bans
  • Opt-out mechanisms: Process "STOP" keywords within seconds and confirm unsubscription
  • Time restrictions: Send messages between 8 AM and 9 PM in recipient's local timezone only
  • Disclosure requirements: Identify your business, state message frequency, and include "Msg&Data rates may apply"

Technology Stack:

  • Next.js 15: React framework with App Router for API routes and server components
  • Supabase: Backend-as-a-Service providing PostgreSQL database, authentication, and real-time subscriptions
  • Plivo Node.js SDK (v4.74.0+): Simplified integration with Plivo REST API for SMS operations
  • Plivo: Cloud communications platform offering SMS API, phone numbers, and webhook capabilities
  • TypeScript: Type-safe development for better code quality and developer experience
  • React Hook Form + Zod: Form handling with schema validation
  • TanStack Query: Data fetching, caching, and state management
  • ngrok (development): Expose local development server for testing Plivo webhooks

System Architecture Flow:

Your system operates as follows:

  1. User creates campaign via Next.js frontend with React Hook Form
  2. Next.js API route validates data and stores campaign in Supabase
  3. Background process sends SMS requests to Plivo API
  4. Plivo delivers SMS to recipient mobile phones
  5. Recipients reply via SMS, Plivo receives the message
  6. Plivo sends webhook POST request to your Next.js API route
  7. API route processes webhook, updates Supabase with delivery status or reply
  8. Frontend displays real-time campaign analytics via TanStack Query

Prerequisites for Building Your SMS Marketing System

Before starting development, ensure you have:

  • Node.js 18+ and npm (or yarn/pnpm) installed
  • A Plivo account (Sign up free)
  • A Plivo phone number with SMS capability (purchase via Plivo console)
  • A Supabase account (Sign up free)
  • Basic knowledge of JavaScript/TypeScript, Next.js, and REST APIs
  • ngrok installed for local webhook testing (Download ngrok)
  • Understanding of SMS compliance regulations (TCPA, CTIA)

Plivo Trial Account Limitations:

  • SMS only sends to verified numbers (add in Console > Phone Numbers > Sandbox)
  • All messages include "[Plivo Trial]" prefix
  • Limited to 20 free messages
  • Purchase credits to remove restrictions for production use

Step 1: Initialize Your Next.js SMS Marketing Project

Set up your Next.js project with required dependencies and configuration.

  1. Create Next.js Project: Initialize a new Next.js application with TypeScript and App Router.

    bash
    npx create-next-app@latest plivo-sms-campaigns
    cd plivo-sms-campaigns

    When prompted, select:

    • TypeScript: Yes
    • ESLint: Yes
    • Tailwind CSS: Yes
    • src/ directory: Yes
    • App Router: Yes
    • Customize default import alias: No
  2. Install Required Dependencies: Install Plivo SDK, Supabase client, form handling, and validation libraries.

    bash
    npm install plivo @supabase/supabase-js @supabase/ssr
    npm install @tanstack/react-query @tanstack/react-query-devtools
    npm install react-hook-form zod @hookform/resolvers
    npm install date-fns clsx

    Why these dependencies:

    • plivo: Official Plivo Node.js SDK for SMS operations
    • @supabase/supabase-js & @supabase/ssr: Supabase client libraries for Next.js
    • @tanstack/react-query: Powerful data fetching and caching
    • react-hook-form + zod: Type-safe form handling and validation
    • date-fns: Date/time manipulation for scheduling
    • clsx: Utility for constructing className strings
  3. Set Up Environment Variables: Create .env.local file in project root. This stores sensitive credentials. Never commit to version control.

    bash
    # .env.local
    
    # Plivo Credentials (Get from Plivo Console > Overview)
    PLIVO_AUTH_ID=your_plivo_auth_id
    PLIVO_AUTH_TOKEN=your_plivo_auth_token
    
    # Plivo Phone Number (E.164 format, e.g., +14155551212)
    PLIVO_SENDER_ID=your_plivo_phone_number
    
    # Supabase Configuration (Get from Supabase Dashboard > Settings > API)
    NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
    NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
    SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
    
    # Application Settings
    NEXT_PUBLIC_APP_URL=http://localhost:3000
    
    # Webhook Secret (Generate random string for security)
    PLIVO_WEBHOOK_SECRET=generate_random_secret_here

    Where to find credentials:

    • Plivo: Console dashboard shows Auth ID and Auth Token
    • Supabase: Project Settings > API displays URL and keys
    • PLIVO_SENDER_ID: Your purchased Plivo number in E.164 format (+1234567890)
    • PLIVO_WEBHOOK_SECRET: Generate with openssl rand -base64 32
  4. Configure Supabase Client: Create utility files for Supabase client initialization.

    Browser client (src/lib/supabase/client.ts):

    typescript
    // src/lib/supabase/client.ts
    import { createBrowserClient } from '@supabase/ssr'
    
    export function createClient() {
      return createBrowserClient(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
      )
    }

    Server client (src/lib/supabase/server.ts):

    typescript
    // src/lib/supabase/server.ts
    import { createServerClient, type CookieOptions } from '@supabase/ssr'
    import { cookies } from 'next/headers'
    
    export async function createClient() {
      const cookieStore = await cookies()
    
      return createServerClient(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
        {
          cookies: {
            get(name: string) {
              return cookieStore.get(name)?.value
            },
            set(name: string, value: string, options: CookieOptions) {
              try {
                cookieStore.set({ name, value, ...options })
              } catch (error) {
                // Handle cookie errors in server components
              }
            },
            remove(name: string, options: CookieOptions) {
              try {
                cookieStore.set({ name, value: '', ...options })
              } catch (error) {
                // Handle cookie errors in server components
              }
            },
          },
        }
      )
    }

    Service role client for admin operations (src/lib/supabase/admin.ts):

    typescript
    // src/lib/supabase/admin.ts
    import { createClient } from '@supabase/supabase-js'
    
    export const supabaseAdmin = createClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.SUPABASE_SERVICE_ROLE_KEY!,
      {
        auth: {
          autoRefreshToken: false,
          persistSession: false
        }
      }
    )

    Why multiple clients: Browser client for frontend, server client for API routes with cookie handling, admin client for privileged operations like bulk operations.

  5. Initialize Plivo Client: Create a configuration file for Plivo SDK initialization.

    typescript
    // src/lib/plivo/client.ts
    import plivo from 'plivo'
    
    if (!process.env.PLIVO_AUTH_ID || !process.env.PLIVO_AUTH_TOKEN) {
      throw new Error('Missing Plivo credentials in environment variables')
    }
    
    export const plivoClient = new plivo.Client(
      process.env.PLIVO_AUTH_ID,
      process.env.PLIVO_AUTH_TOKEN
    )
    
    export const PLIVO_SENDER_ID = process.env.PLIVO_SENDER_ID
    
    if (!PLIVO_SENDER_ID) {
      throw new Error('PLIVO_SENDER_ID not configured in environment variables')
    }

    Why: Centralizing Plivo client initialization ensures consistent configuration and validates required environment variables at startup.

  6. Configure TypeScript: Update tsconfig.json for better path aliases and strict typing.

    json
    {
      "compilerOptions": {
        "target": "ES2017",
        "lib": ["dom", "dom.iterable", "esnext"],
        "allowJs": true,
        "skipLibCheck": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "noEmit": true,
        "esModuleInterop": true,
        "module": "esnext",
        "moduleResolution": "bundler",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "jsx": "preserve",
        "incremental": true,
        "plugins": [
          {
            "name": "next"
          }
        ],
        "paths": {
          "@/*": ["./src/*"]
        }
      },
      "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
      "exclude": ["node_modules"]
    }
  7. Create Project Structure: Organize directories for maintainability and scalability.

    text
    plivo-sms-campaigns/
    ├── src/
    │   ├── app/
    │   │   ├── api/
    │   │   │   ├── campaigns/
    │   │   │   │   ├── route.ts
    │   │   │   │   └── [id]/
    │   │   │   │       └── route.ts
    │   │   │   ├── subscribers/
    │   │   │   │   └── route.ts
    │   │   │   └── webhooks/
    │   │   │       ├── incoming/
    │   │   │       │   └── route.ts
    │   │   │       └── status/
    │   │   │           └── route.ts
    │   │   ├── campaigns/
    │   │   │   ├── page.tsx
    │   │   │   └── [id]/
    │   │   │       └── page.tsx
    │   │   ├── subscribers/
    │   │   │   └── page.tsx
    │   │   ├── layout.tsx
    │   │   └── page.tsx
    │   ├── components/
    │   │   ├── campaigns/
    │   │   ├── subscribers/
    │   │   └── ui/
    │   ├── lib/
    │   │   ├── plivo/
    │   │   │   ├── client.ts
    │   │   │   └── utils.ts
    │   │   ├── supabase/
    │   │   │   ├── client.ts
    │   │   │   ├── server.ts
    │   │   │   └── admin.ts
    │   │   ├── validations/
    │   │   │   ├── campaign.ts
    │   │   │   └── subscriber.ts
    │   │   └── utils.ts
    │   └── types/
    │       ├── campaign.ts
    │       ├── subscriber.ts
    │       └── message.ts
    ├── supabase/
    │   └── migrations/
    ├── .env.local
    ├── .gitignore
    ├── next.config.js
    ├── package.json
    ├── tsconfig.json
    └── README.md
  8. Update .gitignore: Ensure sensitive files never get committed.

    text
    # .gitignore
    
    # Environment variables
    .env.local
    .env*.local
    
    # Dependencies
    node_modules/
    
    # Next.js
    .next/
    out/
    build/
    
    # Logs
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    
    # OS files
    .DS_Store
    Thumbs.db

You now have a solid foundation with Next.js, Supabase, and Plivo configured and ready for development.

Step 2: Set Up Supabase Database Schema

Create database tables for subscribers, campaigns, and message tracking.

  1. Access Supabase SQL Editor:

    • Log in to your Supabase dashboard
    • Navigate to SQL Editor in the left sidebar
    • Click "New query"
  2. Create Database Tables: Execute this SQL to create your schema with proper indexes and constraints.

    sql
    -- Create subscribers table
    CREATE TABLE subscribers (
      id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
      phone_number TEXT UNIQUE NOT NULL,
      first_name TEXT,
      last_name TEXT,
      is_active BOOLEAN DEFAULT true,
      consent_given_at TIMESTAMPTZ,
      opted_out_at TIMESTAMPTZ,
      created_at TIMESTAMPTZ DEFAULT NOW(),
      updated_at TIMESTAMPTZ DEFAULT NOW()
    );
    
    -- Create campaigns table
    CREATE TABLE campaigns (
      id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
      name TEXT NOT NULL,
      message_body TEXT NOT NULL,
      status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'failed')),
      scheduled_at TIMESTAMPTZ,
      sent_at TIMESTAMPTZ,
      total_recipients INT DEFAULT 0,
      sent_count INT DEFAULT 0,
      delivered_count INT DEFAULT 0,
      failed_count INT DEFAULT 0,
      created_by UUID REFERENCES auth.users(id),
      created_at TIMESTAMPTZ DEFAULT NOW(),
      updated_at TIMESTAMPTZ DEFAULT NOW()
    );
    
    -- Create sent_messages table
    CREATE TABLE sent_messages (
      id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
      campaign_id UUID REFERENCES campaigns(id) ON DELETE CASCADE,
      subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE,
      message_uuid TEXT UNIQUE,
      status TEXT DEFAULT 'queued' CHECK (status IN ('queued', 'sent', 'delivered', 'undelivered', 'failed')),
      error_message TEXT,
      plivo_response JSONB,
      sent_at TIMESTAMPTZ DEFAULT NOW(),
      delivered_at TIMESTAMPTZ,
      updated_at TIMESTAMPTZ DEFAULT NOW()
    );
    
    -- Create indexes for performance
    CREATE INDEX idx_subscribers_phone ON subscribers(phone_number);
    CREATE INDEX idx_subscribers_active ON subscribers(is_active);
    CREATE INDEX idx_campaigns_status ON campaigns(status);
    CREATE INDEX idx_campaigns_scheduled ON campaigns(scheduled_at);
    CREATE INDEX idx_sent_messages_campaign ON sent_messages(campaign_id);
    CREATE INDEX idx_sent_messages_subscriber ON sent_messages(subscriber_id);
    CREATE INDEX idx_sent_messages_status ON sent_messages(status);
    CREATE INDEX idx_sent_messages_uuid ON sent_messages(message_uuid);
    
    -- Create updated_at trigger function
    CREATE OR REPLACE FUNCTION update_updated_at_column()
    RETURNS TRIGGER AS $$
    BEGIN
      NEW.updated_at = NOW();
      RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;
    
    -- Apply updated_at triggers
    CREATE TRIGGER update_subscribers_updated_at
      BEFORE UPDATE ON subscribers
      FOR EACH ROW
      EXECUTE FUNCTION update_updated_at_column();
    
    CREATE TRIGGER update_campaigns_updated_at
      BEFORE UPDATE ON campaigns
      FOR EACH ROW
      EXECUTE FUNCTION update_updated_at_column();
    
    CREATE TRIGGER update_sent_messages_updated_at
      BEFORE UPDATE ON sent_messages
      FOR EACH ROW
      EXECUTE FUNCTION update_updated_at_column();
    
    -- Enable Row Level Security (RLS)
    ALTER TABLE subscribers ENABLE ROW LEVEL SECURITY;
    ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;
    ALTER TABLE sent_messages ENABLE ROW LEVEL SECURITY;
    
    -- Create RLS policies for authenticated users
    CREATE POLICY "Enable read access for authenticated users" ON subscribers
      FOR SELECT USING (auth.role() = 'authenticated');
    
    CREATE POLICY "Enable insert for authenticated users" ON subscribers
      FOR INSERT WITH CHECK (auth.role() = 'authenticated');
    
    CREATE POLICY "Enable update for authenticated users" ON subscribers
      FOR UPDATE USING (auth.role() = 'authenticated');
    
    CREATE POLICY "Enable read access for authenticated users" ON campaigns
      FOR SELECT USING (auth.role() = 'authenticated');
    
    CREATE POLICY "Enable insert for authenticated users" ON campaigns
      FOR INSERT WITH CHECK (auth.role() = 'authenticated');
    
    CREATE POLICY "Enable update for authenticated users" ON campaigns
      FOR UPDATE USING (auth.role() = 'authenticated');
    
    CREATE POLICY "Enable read access for authenticated users" ON sent_messages
      FOR SELECT USING (auth.role() = 'authenticated');
    
    CREATE POLICY "Enable insert for authenticated users" ON sent_messages
      FOR INSERT WITH CHECK (auth.role() = 'authenticated');
    
    CREATE POLICY "Enable update for authenticated users" ON sent_messages
      FOR UPDATE USING (auth.role() = 'authenticated');

    Schema design rationale:

    • subscribers: Stores contact information with consent tracking and opt-out timestamps
    • campaigns: Manages campaign metadata, status, and aggregate statistics
    • sent_messages: Tracks individual message delivery with Plivo message UUIDs
    • Indexes: Optimize common queries (phone lookups, status filtering, campaign reports)
    • RLS policies: Secure data access to authenticated users only
    • Triggers: Automatically update updated_at timestamps
  3. Create TypeScript Types: Generate type definitions matching your database schema.

    typescript
    // src/types/database.ts
    export type Subscriber = {
      id: string
      phone_number: string
      first_name?: string
      last_name?: string
      is_active: boolean
      consent_given_at?: string
      opted_out_at?: string
      created_at: string
      updated_at: string
    }
    
    export type Campaign = {
      id: string
      name: string
      message_body: string
      status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed'
      scheduled_at?: string
      sent_at?: string
      total_recipients: number
      sent_count: number
      delivered_count: number
      failed_count: number
      created_by?: string
      created_at: string
      updated_at: string
    }
    
    export type SentMessage = {
      id: string
      campaign_id: string
      subscriber_id: string
      message_uuid?: string
      status: 'queued' | 'sent' | 'delivered' | 'undelivered' | 'failed'
      error_message?: string
      plivo_response?: Record<string, any>
      sent_at: string
      delivered_at?: string
      updated_at: string
    }
    
    export type CreateSubscriberInput = Pick<
      Subscriber,
      'phone_number' | 'first_name' | 'last_name'
    > & {
      consent_given_at?: string
    }
    
    export type CreateCampaignInput = Pick<
      Campaign,
      'name' | 'message_body' | 'scheduled_at'
    >
    
    export type UpdateCampaignStatusInput = {
      status: Campaign['status']
      sent_at?: string
      total_recipients?: number
      sent_count?: number
      delivered_count?: number
      failed_count?: number
    }

Your database schema is now configured with proper relationships, indexes, and security policies.

Step 3: Build Subscriber Management API

Create Next.js API routes for managing subscriber data with CRUD operations.

  1. Create Validation Schema: Define Zod schemas for type-safe validation.

    typescript
    // src/lib/validations/subscriber.ts
    import { z } from 'zod'
    
    // E.164 phone number format validation
    export const phoneNumberSchema = z
      .string()
      .regex(/^\+[1-9]\d{1,14}$/, 'Phone number must be in E.164 format (e.g., +14155551212)')
    
    export const createSubscriberSchema = z.object({
      phone_number: phoneNumberSchema,
      first_name: z.string().min(1, 'First name required').max(50).optional(),
      last_name: z.string().min(1, 'Last name required').max(50).optional(),
      consent_given_at: z.string().datetime().optional(),
    })
    
    export const updateSubscriberSchema = z.object({
      first_name: z.string().min(1).max(50).optional(),
      last_name: z.string().min(1).max(50).optional(),
      is_active: z.boolean().optional(),
    })
    
    export type CreateSubscriberInput = z.infer<typeof createSubscriberSchema>
    export type UpdateSubscriberInput = z.infer<typeof updateSubscriberSchema>
  2. Create Subscriber API Routes: Implement CRUD endpoints with proper error handling.

    typescript
    // src/app/api/subscribers/route.ts
    import { NextRequest, NextResponse } from 'next/server'
    import { supabaseAdmin } from '@/lib/supabase/admin'
    import { createSubscriberSchema } from '@/lib/validations/subscriber'
    
    export async function GET(request: NextRequest) {
      try {
        const searchParams = request.nextUrl.searchParams
        const activeOnly = searchParams.get('active_only') !== 'false'
    
        const query = supabaseAdmin
          .from('subscribers')
          .select('*')
          .order('created_at', { ascending: false })
    
        if (activeOnly) {
          query.eq('is_active', true)
        }
    
        const { data, error } = await query
    
        if (error) throw error
    
        return NextResponse.json({ subscribers: data }, { status: 200 })
      } catch (error) {
        console.error('Error fetching subscribers:', error)
        return NextResponse.json(
          { error: 'Failed to fetch subscribers' },
          { status: 500 }
        )
      }
    }
    
    export async function POST(request: NextRequest) {
      try {
        const body = await request.json()
    
        // Validate input
        const validatedData = createSubscriberSchema.parse(body)
    
        // Add consent timestamp if not provided
        if (!validatedData.consent_given_at) {
          validatedData.consent_given_at = new Date().toISOString()
        }
    
        // Insert into database
        const { data, error } = await supabaseAdmin
          .from('subscribers')
          .insert([validatedData])
          .select()
          .single()
    
        if (error) {
          // Handle duplicate phone number
          if (error.code === '23505') {
            return NextResponse.json(
              { error: 'Phone number already exists' },
              { status: 409 }
            )
          }
          throw error
        }
    
        return NextResponse.json({ subscriber: data }, { status: 201 })
      } catch (error) {
        console.error('Error creating subscriber:', error)
    
        if (error instanceof z.ZodError) {
          return NextResponse.json(
            { error: 'Validation failed', details: error.errors },
            { status: 400 }
          )
        }
    
        return NextResponse.json(
          { error: 'Failed to create subscriber' },
          { status: 500 }
        )
      }
    }

    Individual subscriber operations (src/app/api/subscribers/[id]/route.ts):

    typescript
    // src/app/api/subscribers/[id]/route.ts
    import { NextRequest, NextResponse } from 'next/server'
    import { supabaseAdmin } from '@/lib/supabase/admin'
    import { updateSubscriberSchema } from '@/lib/validations/subscriber'
    
    export async function GET(
      request: NextRequest,
      { params }: { params: { id: string } }
    ) {
      try {
        const { data, error } = await supabaseAdmin
          .from('subscribers')
          .select('*')
          .eq('id', params.id)
          .single()
    
        if (error) {
          if (error.code === 'PGRST116') {
            return NextResponse.json(
              { error: 'Subscriber not found' },
              { status: 404 }
            )
          }
          throw error
        }
    
        return NextResponse.json({ subscriber: data }, { status: 200 })
      } catch (error) {
        console.error('Error fetching subscriber:', error)
        return NextResponse.json(
          { error: 'Failed to fetch subscriber' },
          { status: 500 }
        )
      }
    }
    
    export async function PATCH(
      request: NextRequest,
      { params }: { params: { id: string } }
    ) {
      try {
        const body = await request.json()
        const validatedData = updateSubscriberSchema.parse(body)
    
        // Handle opt-out specifically
        if (validatedData.is_active === false) {
          validatedData.opted_out_at = new Date().toISOString()
        }
    
        const { data, error } = await supabaseAdmin
          .from('subscribers')
          .update(validatedData)
          .eq('id', params.id)
          .select()
          .single()
    
        if (error) {
          if (error.code === 'PGRST116') {
            return NextResponse.json(
              { error: 'Subscriber not found' },
              { status: 404 }
            )
          }
          throw error
        }
    
        return NextResponse.json({ subscriber: data }, { status: 200 })
      } catch (error) {
        console.error('Error updating subscriber:', error)
    
        if (error instanceof z.ZodError) {
          return NextResponse.json(
            { error: 'Validation failed', details: error.errors },
            { status: 400 }
          )
        }
    
        return NextResponse.json(
          { error: 'Failed to update subscriber' },
          { status: 500 }
        )
      }
    }
    
    export async function DELETE(
      request: NextRequest,
      { params }: { params: { id: string } }
    ) {
      try {
        const { error } = await supabaseAdmin
          .from('subscribers')
          .delete()
          .eq('id', params.id)
    
        if (error) throw error
    
        return NextResponse.json({ success: true }, { status: 200 })
      } catch (error) {
        console.error('Error deleting subscriber:', error)
        return NextResponse.json(
          { error: 'Failed to delete subscriber' },
          { status: 500 }
        )
      }
    }

    Why: These API routes provide complete subscriber management with validation, error handling, and proper HTTP status codes. Phone number uniqueness is enforced at the database level.

  3. Test Subscriber API: Use curl or Postman to verify endpoints.

    bash
    # Create subscriber
    curl -X POST http://localhost:3000/api/subscribers \
      -H "Content-Type: application/json" \
      -d '{
        "phone_number": "+14155551212",
        "first_name": "John",
        "last_name": "Doe"
      }'
    
    # Get all active subscribers
    curl http://localhost:3000/api/subscribers
    
    # Get specific subscriber
    curl http://localhost:3000/api/subscribers/{subscriber-id}
    
    # Update subscriber (opt-out)
    curl -X PATCH http://localhost:3000/api/subscribers/{subscriber-id} \
      -H "Content-Type: application/json" \
      -d '{"is_active": false}'
    
    # Delete subscriber
    curl -X DELETE http://localhost:3000/api/subscribers/{subscriber-id}

Your subscriber management API is now fully functional with validation and error handling.

Step 4: Build Campaign Management API

Create endpoints for managing SMS marketing campaigns.

  1. Create Campaign Validation: Define schemas for campaign creation and updates.

    typescript
    // src/lib/validations/campaign.ts
    import { z } from 'zod'
    
    export const createCampaignSchema = z.object({
      name: z.string().min(1, 'Campaign name required').max(100),
      message_body: z
        .string()
        .min(1, 'Message body required')
        .max(1600, 'Message exceeds maximum length'),
      scheduled_at: z.string().datetime().optional(),
    })
    
    export const updateCampaignStatusSchema = z.object({
      status: z.enum(['draft', 'scheduled', 'sending', 'sent', 'failed']),
      sent_at: z.string().datetime().optional(),
      total_recipients: z.number().int().min(0).optional(),
      sent_count: z.number().int().min(0).optional(),
      delivered_count: z.number().int().min(0).optional(),
      failed_count: z.number().int().min(0).optional(),
    })
    
    export type CreateCampaignInput = z.infer<typeof createCampaignSchema>
    export type UpdateCampaignStatusInput = z.infer<typeof updateCampaignStatusSchema>
  2. Create Campaign API Routes: Implement campaign CRUD operations.

    typescript
    // src/app/api/campaigns/route.ts
    import { NextRequest, NextResponse } from 'next/server'
    import { supabaseAdmin } from '@/lib/supabase/admin'
    import { createCampaignSchema } from '@/lib/validations/campaign'
    
    export async function GET(request: NextRequest) {
      try {
        const searchParams = request.nextUrl.searchParams
        const status = searchParams.get('status')
    
        const query = supabaseAdmin
          .from('campaigns')
          .select('*')
          .order('created_at', { ascending: false })
    
        if (status) {
          query.eq('status', status)
        }
    
        const { data, error } = await query
    
        if (error) throw error
    
        return NextResponse.json({ campaigns: data }, { status: 200 })
      } catch (error) {
        console.error('Error fetching campaigns:', error)
        return NextResponse.json(
          { error: 'Failed to fetch campaigns' },
          { status: 500 }
        )
      }
    }
    
    export async function POST(request: NextRequest) {
      try {
        const body = await request.json()
        const validatedData = createCampaignSchema.parse(body)
    
        // Determine initial status
        const initialStatus = validatedData.scheduled_at ? 'scheduled' : 'draft'
    
        const { data, error } = await supabaseAdmin
          .from('campaigns')
          .insert([{ ...validatedData, status: initialStatus }])
          .select()
          .single()
    
        if (error) throw error
    
        return NextResponse.json({ campaign: data }, { status: 201 })
      } catch (error) {
        console.error('Error creating campaign:', error)
    
        if (error instanceof z.ZodError) {
          return NextResponse.json(
            { error: 'Validation failed', details: error.errors },
            { status: 400 }
          )
        }
    
        return NextResponse.json(
          { error: 'Failed to create campaign' },
          { status: 500 }
        )
      }
    }

    Individual campaign operations (src/app/api/campaigns/[id]/route.ts):

    typescript
    // src/app/api/campaigns/[id]/route.ts
    import { NextRequest, NextResponse } from 'next/server'
    import { supabaseAdmin } from '@/lib/supabase/admin'
    import { updateCampaignStatusSchema } from '@/lib/validations/campaign'
    
    export async function GET(
      request: NextRequest,
      { params }: { params: { id: string } }
    ) {
      try {
        const { data, error } = await supabaseAdmin
          .from('campaigns')
          .select(`
            *,
            sent_messages (
              id,
              status,
              message_uuid,
              sent_at,
              delivered_at,
              subscriber:subscribers (
                phone_number,
                first_name,
                last_name
              )
            )
          `)
          .eq('id', params.id)
          .single()
    
        if (error) {
          if (error.code === 'PGRST116') {
            return NextResponse.json(
              { error: 'Campaign not found' },
              { status: 404 }
            )
          }
          throw error
        }
    
        return NextResponse.json({ campaign: data }, { status: 200 })
      } catch (error) {
        console.error('Error fetching campaign:', error)
        return NextResponse.json(
          { error: 'Failed to fetch campaign' },
          { status: 500 }
        )
      }
    }
    
    export async function PATCH(
      request: NextRequest,
      { params }: { params: { id: string } }
    ) {
      try {
        const body = await request.json()
        const validatedData = updateCampaignStatusSchema.parse(body)
    
        const { data, error } = await supabaseAdmin
          .from('campaigns')
          .update(validatedData)
          .eq('id', params.id)
          .select()
          .single()
    
        if (error) {
          if (error.code === 'PGRST116') {
            return NextResponse.json(
              { error: 'Campaign not found' },
              { status: 404 }
            )
          }
          throw error
        }
    
        return NextResponse.json({ campaign: data }, { status: 200 })
      } catch (error) {
        console.error('Error updating campaign:', error)
    
        if (error instanceof z.ZodError) {
          return NextResponse.json(
            { error: 'Validation failed', details: error.errors },
            { status: 400 }
          )
        }
    
        return NextResponse.json(
          { error: 'Failed to update campaign' },
          { status: 500 }
        )
      }
    }
    
    export async function DELETE(
      request: NextRequest,
      { params }: { params: { id: string } }
    ) {
      try {
        // Only allow deleting draft campaigns
        const { data: campaign, error: fetchError } = await supabaseAdmin
          .from('campaigns')
          .select('status')
          .eq('id', params.id)
          .single()
    
        if (fetchError) {
          if (fetchError.code === 'PGRST116') {
            return NextResponse.json(
              { error: 'Campaign not found' },
              { status: 404 }
            )
          }
          throw fetchError
        }
    
        if (campaign.status !== 'draft') {
          return NextResponse.json(
            { error: 'Only draft campaigns can be deleted' },
            { status: 400 }
          )
        }
    
        const { error } = await supabaseAdmin
          .from('campaigns')
          .delete()
          .eq('id', params.id)
    
        if (error) throw error
    
        return NextResponse.json({ success: true }, { status: 200 })
      } catch (error) {
        console.error('Error deleting campaign:', error)
        return NextResponse.json(
          { error: 'Failed to delete campaign' },
          { status: 500 }
        )
      }
    }
  3. Create Campaign Send Endpoint: Build API route to trigger campaign sending.

    typescript
    // src/app/api/campaigns/[id]/send/route.ts
    import { NextRequest, NextResponse } from 'next/server'
    import { supabaseAdmin } from '@/lib/supabase/admin'
    import { plivoClient, PLIVO_SENDER_ID } from '@/lib/plivo/client'
    
    export async function POST(
      request: NextRequest,
      { params }: { params: { id: string } }
    ) {
      try {
        // Get campaign details
        const { data: campaign, error: campaignError } = await supabaseAdmin
          .from('campaigns')
          .select('*')
          .eq('id', params.id)
          .single()
    
        if (campaignError || !campaign) {
          return NextResponse.json(
            { error: 'Campaign not found' },
            { status: 404 }
          )
        }
    
        // Validate campaign status
        if (campaign.status === 'sending' || campaign.status === 'sent') {
          return NextResponse.json(
            { error: 'Campaign already sent or in progress' },
            { status: 400 }
          )
        }
    
        // Get active subscribers
        const { data: subscribers, error: subscribersError } = await supabaseAdmin
          .from('subscribers')
          .select('id, phone_number, first_name')
          .eq('is_active', true)
    
        if (subscribersError) throw subscribersError
    
        if (!subscribers || subscribers.length === 0) {
          return NextResponse.json(
            { error: 'No active subscribers found' },
            { status: 400 }
          )
        }
    
        // Update campaign status to sending
        await supabaseAdmin
          .from('campaigns')
          .update({
            status: 'sending',
            sent_at: new Date().toISOString(),
            total_recipients: subscribers.length,
          })
          .eq('id', params.id)
    
        // Send messages (simple synchronous approach - see Section 5 for queue-based)
        let sentCount = 0
        let failedCount = 0
    
        for (const subscriber of subscribers) {
          try {
            // Personalize message if needed
            const personalizedMessage = campaign.message_body.replace(
              '{{first_name}}',
              subscriber.first_name || 'there'
            )
    
            // Send via Plivo
            const response = await plivoClient.messages.create(
              PLIVO_SENDER_ID,
              subscriber.phone_number,
              personalizedMessage,
              {
                url: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhooks/status`,
                method: 'POST',
              }
            )
    
            // Record sent message
            await supabaseAdmin.from('sent_messages').insert([
              {
                campaign_id: campaign.id,
                subscriber_id: subscriber.id,
                message_uuid: response.messageUuid[0],
                status: 'sent',
                plivo_response: response,
              },
            ])
    
            sentCount++
    
            // Rate limiting: wait 100ms between messages
            await new Promise((resolve) => setTimeout(resolve, 100))
          } catch (error) {
            console.error(`Failed to send to ${subscriber.phone_number}:`, error)
    
            // Record failed message
            await supabaseAdmin.from('sent_messages').insert([
              {
                campaign_id: campaign.id,
                subscriber_id: subscriber.id,
                status: 'failed',
                error_message: error.message || 'Unknown error',
              },
            ])
    
            failedCount++
          }
        }
    
        // Update campaign final status
        await supabaseAdmin
          .from('campaigns')
          .update({
            status: 'sent',
            sent_count: sentCount,
            failed_count: failedCount,
          })
          .eq('id', params.id)
    
        return NextResponse.json(
          {
            success: true,
            sent_count: sentCount,
            failed_count: failedCount,
            total: subscribers.length,
          },
          { status: 200 }
        )
      } catch (error) {
        console.error('Error sending campaign:', error)
    
        // Update campaign status to failed
        await supabaseAdmin
          .from('campaigns')
          .update({ status: 'failed' })
          .eq('id', params.id)
    
        return NextResponse.json(
          { error: 'Failed to send campaign' },
          { status: 500 }
        )
      }
    }

    Production Note: This synchronous approach works for small lists but doesn't scale. Section 5 covers implementing a background job queue for production-grade bulk sending.

Your campaign management system is now operational with sending capabilities.

Step 5: Implement Plivo Webhooks for Delivery Reports

Set up webhook endpoints to receive and process Plivo callbacks.

  1. Configure ngrok for Local Testing: Expose your local development server to receive webhooks.

    bash
    # Start Next.js dev server
    npm run dev
    
    # In another terminal, start ngrok
    ngrok http 3000

    Copy the HTTPS forwarding URL (e.g., https://abc123.ngrok.io).

  2. Update Environment Variable: Set your ngrok URL in .env.local:

    bash
    NEXT_PUBLIC_APP_URL=https://abc123.ngrok.io

    Restart your dev server after changing environment variables.

  3. Configure Plivo Webhook URL:

    • Log in to Plivo Console
    • Go to Messaging > Applications
    • Create or edit your application
    • Set Message URL: https://abc123.ngrok.io/api/webhooks/incoming
    • Set Delivery Report URL: https://abc123.ngrok.io/api/webhooks/status
    • Method: POST for both
    • Link your Plivo phone number to this application
  4. Create Webhook Signature Validation: Implement security verification for incoming webhooks.

    typescript
    // src/lib/plivo/utils.ts
    import { plivoClient } from './client'
    
    export function validatePlivoSignature(
      signature: string | null,
      nonce: string | null,
      url: string,
      method: string,
      authToken: string,
      postParams: Record<string, any> = {}
    ): boolean {
      if (!signature || !nonce) {
        console.warn('Missing Plivo signature headers')
        return false
      }
    
      try {
        // Plivo SDK v4 signature validation
        const isValid = plivoClient.validateSignature(
          url,
          nonce,
          signature,
          authToken
        )
    
        if (!isValid) {
          console.warn('Invalid Plivo webhook signature', {
            url,
            method,
            nonce,
            signature,
          })
        }
    
        return isValid
      } catch (error) {
        console.error('Error validating Plivo signature:', error)
        return false
      }
    }
    
    export function extractPhoneNumber(phoneNumber: string): string {
      // Ensure E.164 format
      return phoneNumber.startsWith('+') ? phoneNumber : `+${phoneNumber}`
    }
  5. Create Delivery Status Webhook: Process delivery reports from Plivo.

    typescript
    // src/app/api/webhooks/status/route.ts
    import { NextRequest, NextResponse } from 'next/server'
    import { supabaseAdmin } from '@/lib/supabase/admin'
    import { validatePlivoSignature } from '@/lib/plivo/utils'
    
    export async function POST(request: NextRequest) {
      try {
        // Get webhook signature headers
        const signature = request.headers.get('x-plivo-signature-v3')
        const nonce = request.headers.get('x-plivo-signature-v3-nonce')
    
        // Parse webhook payload
        const body = await request.json()
    
        // Validate signature (optional in development, required in production)
        if (process.env.NODE_ENV === 'production') {
          const url = `${process.env.NEXT_PUBLIC_APP_URL}${request.nextUrl.pathname}`
          const isValid = validatePlivoSignature(
            signature,
            nonce,
            url,
            'POST',
            process.env.PLIVO_AUTH_TOKEN!,
            body
          )
    
          if (!isValid) {
            return NextResponse.json(
              { error: 'Invalid signature' },
              { status: 401 }
            )
          }
        }
    
        // Extract delivery information
        const {
          MessageUUID,
          Status,
          To,
          From,
          ErrorCode,
          TotalAmount,
          TotalRate,
          Units,
        } = body
    
        console.log('Delivery status webhook received:', {
          MessageUUID,
          Status,
          To,
        })
    
        // Map Plivo status to our status values
        const statusMap: Record<string, string> = {
          queued: 'queued',
          sent: 'sent',
          delivered: 'delivered',
          undelivered: 'undelivered',
          failed: 'failed',
          rejected: 'failed',
        }
    
        const mappedStatus = statusMap[Status] || Status
    
        // Update sent_messages record
        const { data: message, error: updateError } = await supabaseAdmin
          .from('sent_messages')
          .update({
            status: mappedStatus,
            delivered_at:
              mappedStatus === 'delivered' ? new Date().toISOString() : null,
            plivo_response: body,
            error_message: ErrorCode ? `Error ${ErrorCode}` : null,
          })
          .eq('message_uuid', MessageUUID)
          .select('campaign_id')
          .single()
    
        if (updateError) {
          console.error('Error updating message status:', updateError)
        }
    
        // Update campaign statistics
        if (message?.campaign_id) {
          const { data: stats } = await supabaseAdmin
            .from('sent_messages')
            .select('status')
            .eq('campaign_id', message.campaign_id)
    
          if (stats) {
            const delivered = stats.filter((m) => m.status === 'delivered').length
            const failed = stats.filter((m) => m.status === 'failed').length
    
            await supabaseAdmin
              .from('campaigns')
              .update({
                delivered_count: delivered,
                failed_count: failed,
              })
              .eq('id', message.campaign_id)
          }
        }
    
        return NextResponse.json({ success: true }, { status: 200 })
      } catch (error) {
        console.error('Error processing status webhook:', error)
        return NextResponse.json(
          { error: 'Webhook processing failed' },
          { status: 500 }
        )
      }
    }
  6. Create Incoming SMS Webhook: Handle replies and process opt-out keywords.

    typescript
    // src/app/api/webhooks/incoming/route.ts
    import { NextRequest, NextResponse } from 'next/server'
    import { supabaseAdmin } from '@/lib/supabase/admin'
    import { validatePlivoSignature, extractPhoneNumber } from '@/lib/plivo/utils'
    import plivo from 'plivo'
    
    // Keywords that trigger opt-out
    const OPT_OUT_KEYWORDS = ['STOP', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT']
    const OPT_IN_KEYWORDS = ['START', 'UNSTOP']
    
    export async function POST(request: NextRequest) {
      try {
        // Get webhook signature headers
        const signature = request.headers.get('x-plivo-signature-v3')
        const nonce = request.headers.get('x-plivo-signature-v3-nonce')
    
        // Parse webhook payload
        const body = await request.json()
    
        // Validate signature in production
        if (process.env.NODE_ENV === 'production') {
          const url = `${process.env.NEXT_PUBLIC_APP_URL}${request.nextUrl.pathname}`
          const isValid = validatePlivoSignature(
            signature,
            nonce,
            url,
            'POST',
            process.env.PLIVO_AUTH_TOKEN!,
            body
          )
    
          if (!isValid) {
            return NextResponse.json(
              { error: 'Invalid signature' },
              { status: 401 }
            )
          }
        }
    
        // Extract message details
        const { From, To, Text, MessageUUID } = body
        const fromNumber = extractPhoneNumber(From)
        const messageText = Text?.trim()?.toUpperCase() || ''
    
        console.log('Incoming SMS received:', {
          from: fromNumber,
          to: To,
          text: messageText,
        })
    
        // Check for opt-out keywords
        if (OPT_OUT_KEYWORDS.some((keyword) => messageText.includes(keyword))) {
          // Update subscriber status
          const { error: updateError } = await supabaseAdmin
            .from('subscribers')
            .update({
              is_active: false,
              opted_out_at: new Date().toISOString(),
            })
            .eq('phone_number', fromNumber)
    
          if (updateError) {
            console.error('Error updating subscriber opt-out status:', updateError)
          }
    
          // Send confirmation using Plivo XML response
          const response = new plivo.Response()
          response.addMessage(
            'You have been unsubscribed. Reply START to opt back in.',
            {
              src: To,
              dst: From,
            }
          )
    
          return new NextResponse(response.toXML(), {
            status: 200,
            headers: {
              'Content-Type': 'text/xml',
            },
          })
        }
    
        // Check for opt-in keywords
        if (OPT_IN_KEYWORDS.some((keyword) => messageText.includes(keyword))) {
          // Update subscriber status
          const { error: updateError } = await supabaseAdmin
            .from('subscribers')
            .update({
              is_active: true,
              opted_out_at: null,
            })
            .eq('phone_number', fromNumber)
    
          if (updateError) {
            console.error('Error updating subscriber opt-in status:', updateError)
          }
    
          // Send confirmation
          const response = new plivo.Response()
          response.addMessage('You have been resubscribed. Reply STOP to opt out.', {
            src: To,
            dst: From,
          })
    
          return new NextResponse(response.toXML(), {
            status: 200,
            headers: {
              'Content-Type': 'text/xml',
            },
          })
        }
    
        // For other messages, just acknowledge receipt
        // You could implement additional logic here (e.g., storing replies, triggering workflows)
    
        return NextResponse.json({ success: true }, { status: 200 })
      } catch (error) {
        console.error('Error processing incoming SMS webhook:', error)
        return NextResponse.json(
          { error: 'Webhook processing failed' },
          { status: 500 }
        )
      }
    }

    Why Plivo XML: Plivo expects XML responses for immediate replies. The plivo.Response() object generates proper XML for auto-reply functionality.

  7. Test Webhooks: Send test messages to verify webhook processing.

    bash
    # Send SMS to your Plivo number from your phone
    # Message: "STOP"
    
    # Check logs in your terminal
    # Check Supabase database - subscriber should be marked inactive

Your webhook system now processes delivery reports and handles opt-outs automatically.

Frequently Asked Questions About Plivo SMS Marketing

How do I get started with Plivo SMS marketing in Next.js?

Start by creating a Plivo account, purchasing an SMS-enabled phone number ($0.80–$2.00/month), and setting up a Next.js 15 project with Supabase. Install the Plivo Node.js SDK v4 with npm install plivo, configure environment variables with your Auth ID and Auth Token, and create API routes for sending messages. This guide provides complete code examples for production implementation.

What is TCPA compliance and why does it matter for SMS marketing?

TCPA (Telephone Consumer Protection Act) compliance is legally required when sending marketing SMS to US phone numbers. Key requirements include: obtaining express written consent before sending messages, providing clear opt-out instructions ("Reply STOP to unsubscribe"), honoring opt-outs immediately, and maintaining consent records. Violations carry penalties up to $1,500 per message. Similar regulations exist globally (GDPR in EU, CASL in Canada).

How much does Plivo SMS cost for marketing campaigns?

Plivo charges approximately $0.0035–$0.0075 per SMS segment for US/Canada destinations. International rates vary by country ($0.005–$0.20 per segment). Phone numbers cost $0.80–$2.00/month. A 160-character message equals 1 segment; longer messages use multiple segments. Trial accounts include 20 free messages but have restrictions (verified numbers only, "[Plivo Trial]" prefix). Check Plivo's pricing page for current rates.

Can I use Next.js instead of Express for Plivo integration?

Yes, Plivo works seamlessly with Next.js API routes. Replace Express endpoints with Next.js route handlers in the app/api directory. The Plivo SDK code remains identical—only the framework wrapper changes. This guide demonstrates complete Next.js implementation with App Router, server components, and API routes.

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

Configure an incoming message webhook in your Plivo application settings. Create a Next.js API route (/api/webhooks/incoming) that receives POST requests from Plivo. Parse the message text for opt-out keywords (STOP, UNSUBSCRIBE, CANCEL, END, QUIT), update the subscriber's is_active status in Supabase, and send a confirmation message. Process opt-outs in real-time to maintain TCPA compliance.

What is Supabase and why use it for SMS campaigns?

Supabase is an open-source Backend-as-a-Service providing PostgreSQL database, authentication, real-time subscriptions, and RESTful API. Use Supabase for SMS campaigns to get: managed PostgreSQL with automatic backups, Row Level Security for data protection, real-time updates for campaign dashboards, and built-in authentication. It eliminates backend infrastructure setup and scales automatically.

How do Plivo webhooks work for delivery status?

Plivo sends HTTP POST requests to your configured endpoint when message status changes. Configure the webhook URL in Plivo Console > Messaging > Applications > Delivery Report URL. Your API route receives delivery status (queued, sent, delivered, undelivered, failed), message UUID, timestamp, and error codes. Always validate webhook signatures to prevent unauthorized requests.

What's the difference between synchronous and queue-based SMS sending?

Synchronous sending processes messages sequentially within the API request, limiting scalability to small lists. Queue-based sending (using BullMQ or Vercel Queue) offloads message processing to background workers, allowing API routes to return immediately. Use queues for production to handle bulk campaigns, implement retry logic, enforce rate limits, and scale horizontally.

How do I validate phone numbers for Plivo SMS?

Use E.164 format validation: + followed by country code and subscriber number (1-15 digits). Basic regex: /^\+[1-9]\d{1,14}$/. For production, use libphonenumber-js for comprehensive validation including country-specific rules. Implement validation in Zod schemas before database insertion. This prevents invalid numbers and reduces Plivo API errors.

Can I schedule SMS campaigns for specific times?

Yes, store the desired send time in the campaign's scheduled_at field. Implement a scheduled job using Vercel Cron Jobs (for Vercel deployments) or Next.js middleware that checks for pending campaigns. Query campaigns where scheduled_at <= now() and status = 'scheduled', then trigger the send process. For complex scheduling, integrate a job queue like BullMQ with delayed jobs.

How do I test Plivo webhooks during local development?

Use ngrok to expose your local Next.js server to the internet. Run ngrok http 3000, copy the HTTPS URL (e.g., https://abc123.ngrok.io), and configure it as your webhook URL in Plivo Console. Update NEXT_PUBLIC_APP_URL in .env.local to the ngrok URL. Ngrok tunnels incoming webhook requests to your local server, enabling real-time testing.

What database schema do I need for SMS marketing campaigns?

Create three core tables: subscribers (phone numbers, consent, opt-out status), campaigns (name, message body, status, statistics), and sent_messages (delivery tracking with Plivo message UUIDs). Add indexes on phone numbers, campaign status, and message UUIDs for query performance. Use foreign keys with CASCADE deletes for referential integrity. Enable Supabase Row Level Security for data protection.

Conclusion: Your Production-Ready SMS Marketing Platform

You've built a complete SMS marketing campaign system using Plivo, Next.js 15, and Supabase. Your application now includes:

Core Features Implemented:

  • Subscriber Management: CRUD operations with E.164 phone validation and consent tracking
  • Campaign Creation: Full campaign lifecycle management with status tracking
  • Bulk SMS Sending: Plivo API integration with personalization and rate limiting
  • Webhook Processing: Automatic delivery report updates and opt-out handling
  • TCPA Compliance: Consent tracking, opt-out mechanisms, and keyword processing
  • Database Integration: Supabase PostgreSQL with proper indexes and RLS policies

Next Steps for Production:

  1. Implement Background Jobs: Add BullMQ or Vercel Queue for asynchronous bulk sending
  2. Add Authentication: Implement Supabase Auth to secure admin access
  3. Build Frontend Dashboard: Create React components for campaign management
  4. Add Analytics: Track open rates, click-through rates, and conversion metrics
  5. Implement Scheduling: Add cron jobs or scheduled functions for timed campaigns
  6. Add Message Templates: Create reusable templates with variable substitution
  7. Set Up Monitoring: Integrate error tracking (Sentry) and logging (Winston)
  8. Deploy to Production: Deploy to Vercel with proper environment configuration
  9. Add A/B Testing: Split test message variations for optimization
  10. Implement Rate Limiting: Add API rate limiting to prevent abuse

Production Deployment Checklist:

  • ✅ Replace ngrok URL with production webhook URL
  • ✅ Enable webhook signature validation in production
  • ✅ Set up database backups in Supabase
  • ✅ Configure environment variables in deployment platform
  • ✅ Implement proper error logging and monitoring
  • ✅ Add API rate limiting with next-rate-limit
  • ✅ Enable HTTPS for all webhook endpoints
  • ✅ Test opt-out flows with real phone numbers
  • ✅ Review TCPA compliance requirements
  • ✅ Set up alerts for campaign failures

Security Best Practices:

  • Never commit .env.local to version control
  • Always validate webhook signatures in production
  • Use Supabase RLS policies to restrict data access
  • Implement API key authentication for admin routes
  • Sanitize user inputs to prevent SQL injection
  • Rate limit API endpoints to prevent abuse
  • Use HTTPS for all webhook communications
  • Regularly audit subscriber consent records

This foundation provides everything needed for a scalable SMS marketing platform. Customize these components to match your specific business requirements while maintaining code quality, security, and compliance standards.

For questions about Plivo integration, consult the Plivo Node.js SDK documentation and Next.js API routes guide.

Frequently Asked Questions

How to send SMS marketing campaigns with Node.js?

Use Node.js with Express.js and the Plivo Node.js SDK to build an application that interacts with the Plivo API for sending SMS messages. The application can accept campaign details, send messages, handle replies, and store campaign data. This guide provides a walkthrough for setting up this system.

What is Plivo used for in this Node.js application?

Plivo is a cloud communication platform that provides the necessary infrastructure for sending SMS messages, managing phone numbers, and receiving webhooks for incoming replies and delivery reports. It acts as the SMS gateway for the Node.js application.

Why use Node.js for SMS marketing campaigns?

Node.js is chosen for its asynchronous, event-driven architecture. This makes it highly efficient for I/O-heavy tasks like handling API interactions and webhook requests, which are central to sending and managing SMS campaigns effectively.

What is the role of Express.js in this setup?

Express.js simplifies the creation of the web server and API endpoints needed for managing campaign requests and Plivo webhooks. It provides a minimal and flexible framework for handling HTTP requests and responses.

How to install necessary dependencies for the Plivo SMS project?

Use npm (or yarn) to install the required packages: `npm install express plivo dotenv body-parser`. For development, install `nodemon` with: `npm install --save-dev nodemon` to automatically restart the server on code changes.

What is the purpose of ngrok in Plivo webhook development?

ngrok creates a public tunnel to your locally running development server. This allows Plivo's webhooks to reach your application during development, as Plivo needs a publicly accessible URL to send webhook requests to.

How to set up environment variables for Plivo credentials?

Create a `.env` file in the project root and store your Plivo Auth ID, Auth Token, Plivo phone number, server port, and ngrok URL (during development) in this file. This is crucial for security best practices.

How to handle incoming SMS replies in my application?

Configure a Plivo application with a webhook URL pointing to your `/webhooks/inbound-sms` endpoint. When a user replies to a campaign message, Plivo will send a request to this URL. You can then process the reply within your application.

How to set up a webhook for Plivo delivery reports?

In your Plivo Application settings, configure a Delivery Report URL pointing to an endpoint in your app, such as `/webhooks/status`, and select the `POST` method. Plivo will then send delivery status updates (e.g., sent, delivered, failed) to this URL.

What is the best way to handle rate limiting with Plivo?

While the tutorial provides a simplified example, for production systems, avoid sending many messages in a tight loop. Implement rate limiting, such as sending one message per second, or use a message queue like RabbitMQ or Redis Queue for better performance and reliability.

Where can I find my Plivo Auth ID and Auth Token?

Your Plivo Auth ID and Auth Token can be found on your Plivo Console dashboard under "API -> Keys & Tokens". These credentials are necessary to authenticate with the Plivo API.

How to test SMS sending during development with a trial Plivo account?

Trial Plivo accounts have limitations. You can send test messages to verified numbers within your Plivo Sandbox. Ensure the recipient numbers you use for testing are added and verified in your sandbox environment.

What database integration options are recommended for storing campaign data?

The tutorial uses an in-memory store for simplicity, but this isn't suitable for production. Integrate a database (e.g., PostgreSQL, MySQL, MongoDB) to persist campaign details, recipient information, and message statuses reliably.

Why is input validation important for the campaign creation API?

Robust input validation prevents unexpected errors and potential security issues. The tutorial demonstrates basic checks, but use libraries like express-validator to thoroughly sanitize and validate user-provided data in a production environment.