code examples
code examples
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:
- Create and manage SMS marketing campaigns with scheduling
- Handle subscriber lists with automatic opt-out management
- Send bulk SMS messages reliably via Plivo
- Process incoming replies and delivery reports using webhooks
- Store campaign data and analytics in Supabase PostgreSQL
- 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:
- User creates campaign via Next.js frontend with React Hook Form
- Next.js API route validates data and stores campaign in Supabase
- Background process sends SMS requests to Plivo API
- Plivo delivers SMS to recipient mobile phones
- Recipients reply via SMS, Plivo receives the message
- Plivo sends webhook POST request to your Next.js API route
- API route processes webhook, updates Supabase with delivery status or reply
- 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
ngrokinstalled 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.
-
Create Next.js Project: Initialize a new Next.js application with TypeScript and App Router.
bashnpx create-next-app@latest plivo-sms-campaigns cd plivo-sms-campaignsWhen prompted, select:
- TypeScript: Yes
- ESLint: Yes
- Tailwind CSS: Yes
src/directory: Yes- App Router: Yes
- Customize default import alias: No
-
Install Required Dependencies: Install Plivo SDK, Supabase client, form handling, and validation libraries.
bashnpm 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 clsxWhy 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 cachingreact-hook-form+zod: Type-safe form handling and validationdate-fns: Date/time manipulation for schedulingclsx: Utility for constructing className strings
-
Set Up Environment Variables: Create
.env.localfile 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_hereWhere 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
-
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.
-
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.
-
Configure TypeScript: Update
tsconfig.jsonfor 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"] } -
Create Project Structure: Organize directories for maintainability and scalability.
textplivo-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 -
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.
-
Access Supabase SQL Editor:
- Log in to your Supabase dashboard
- Navigate to SQL Editor in the left sidebar
- Click "New query"
-
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_attimestamps
-
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.
-
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> -
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.
-
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.
-
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> -
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 } ) } } -
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.
-
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 3000Copy the HTTPS forwarding URL (e.g.,
https://abc123.ngrok.io). -
Update Environment Variable: Set your ngrok URL in
.env.local:bashNEXT_PUBLIC_APP_URL=https://abc123.ngrok.ioRestart your dev server after changing environment variables.
-
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
-
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}` } -
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 } ) } } -
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. -
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:
- Implement Background Jobs: Add BullMQ or Vercel Queue for asynchronous bulk sending
- Add Authentication: Implement Supabase Auth to secure admin access
- Build Frontend Dashboard: Create React components for campaign management
- Add Analytics: Track open rates, click-through rates, and conversion metrics
- Implement Scheduling: Add cron jobs or scheduled functions for timed campaigns
- Add Message Templates: Create reusable templates with variable substitution
- Set Up Monitoring: Integrate error tracking (Sentry) and logging (Winston)
- Deploy to Production: Deploy to Vercel with proper environment configuration
- Add A/B Testing: Split test message variations for optimization
- 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.localto 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.