code examples
code examples
Build SMS Marketing Campaigns with Plivo, Next.js & BullMQ: Complete 2025 Guide
Learn how to build production-ready SMS/MMS marketing campaigns using Plivo API, Next.js 15.5, BullMQ job queues, and Prisma ORM. Includes opt-out compliance, scheduling, rate limiting, and deployment guides.
Plivo Next.js SMS Marketing Campaigns: Complete Guide with BullMQ & Prisma
This comprehensive guide shows you how to build a production-ready SMS and MMS marketing campaign system using Plivo's communication APIs with Next.js and Node.js. You'll learn to automate bulk message sending, handle opt-out compliance, schedule campaigns, and deploy a scalable marketing platform.
Whether you're building customer notifications, promotional campaigns, or automated marketing workflows, this tutorial covers everything from project setup to production deployment, complete with BullMQ job queues for reliability and Prisma ORM for database management.
What You'll Build: A web application where users can:
- Upload a list of recipients (phone numbers).
- Compose an SMS or MMS message.
- Schedule or immediately send the message campaign to the list via Plivo.
- View basic status updates on sent messages.
Problem Solved: Manually sending marketing messages is inefficient and error-prone. This system automates the process, enables personalization (though basic in this guide), handles potentially large lists, and leverages a reliable provider like Plivo for deliverability.
Technologies Used:
- Next.js: A React framework providing structure, server-side rendering, static site generation, and API routes – ideal for both frontend and backend logic in this scenario. (As of August 2025, Next.js 15.5 is the latest stable version, featuring Turbopack builds in beta, stable Node.js middleware, and TypeScript improvements.)
- Node.js: The underlying runtime for Next.js and the Plivo SDK. (Node.js 18.x and 20.x are current LTS versions as of 2025, with Node.js 22.x available.)
- Plivo: The Communications Platform as a Service (CPaaS) provider you'll use for sending SMS/MMS messages via their API. (Plivo Node.js SDK is actively maintained at plivo-node package on npm.)
- Prisma: A next-generation ORM for Node.js and TypeScript, used for database interaction (schema definition, migrations, type-safe queries). (As of 2025, Prisma 6.16.x+ features a major architecture transformation removing Rust engines, with ESM support, improved performance, and type-safe raw SQL queries.)
- PostgreSQL: A powerful, open-source relational database. (PostgreSQL 15 and 16 are current stable versions.)
- Redis: An in-memory data structure store, used here as a message queue broker with BullMQ. (Redis 7 is the current stable version.)
- BullMQ: A robust message queue system for Node.js built on Redis, essential for handling potentially long-running sending tasks reliably and scalably. (As of October 2025, BullMQ 5.60.0 is the latest version, offering job retry with exponential backoff, delayed/scheduled jobs, rate limiting, and flow control.)
- Tailwind CSS: (Optional, but recommended for styling) A utility-first CSS framework.
- Docker: (Recommended for local development) To easily run PostgreSQL and Redis instances.
System Architecture:
graph LR
A[User Browser] -- HTTP Request --> B(Next.js Frontend);
B -- API Call / Form Submit --> C(Next.js API Route);
C -- Add Job --> D(Redis / BullMQ);
C -- Write Campaign/List Data --> E(PostgreSQL DB / Prisma);
F[BullMQ Worker Process] -- Process Job --> D;
F -- Send SMS/MMS --> G(Plivo API);
F -- Update Message Status --> E;
G -- Status Callback (Optional) --> H(Next.js Webhook API Route);
H -- Update Message Status --> E;
style B fill:#f9f,stroke:#333,stroke-width:2px;
style C fill:#ccf,stroke:#333,stroke-width:2px;
style H fill:#ccf,stroke:#333,stroke-width:2px;
style D fill:#f69,stroke:#333,stroke-width:2px;
style F fill:#9cf,stroke:#333,stroke-width:2px;
style E fill:#ccc,stroke:#333,stroke-width:2px;
style G fill:#f90,stroke:#333,stroke-width:2px;(Note: Ensure your publishing platform correctly renders Mermaid syntax, or provide a static image fallback with appropriate alt text.)
Prerequisites:
- Node.js (v18 or v20 LTS recommended, v22 also available) and npm/yarn installed.
- A Plivo account (Sign up: https://console.plivo.com/accounts/register/).
- A Plivo phone number capable of sending SMS/MMS (Purchase via the Plivo console).
- Docker Desktop installed (or separate PostgreSQL and Redis instances accessible).
- Basic understanding of React, Next.js, and asynchronous JavaScript.
- Git installed.
Final Outcome: A functional web application deployed (e.g., on Vercel) capable of sending bulk SMS/MMS campaigns via Plivo, with background job processing for reliability.
1. Setting Up the Project
Let's initialize our Next.js project and configure the necessary tools.
1.1. Create Next.js Application
Open your terminal and run the following command, replacing plivo-marketing-app with your desired project name. Follow the prompts (we recommend using TypeScript and Tailwind CSS).
npx create-next-app@latest plivo-marketing-app
cd plivo-marketing-app1.2. Install Dependencies
Install the Plivo SDK, Prisma, BullMQ, and other utilities.
# Core dependencies
npm install plivo-node prisma @prisma/client bullmq zod date-fns ioredis
# Dev dependencies for Prisma/scripts
npm install -D @types/node typescript ts-node @faker-js/faker
# If using Tailwind
npm install -D tailwindcss postcss autoprefixerplivo-node: Plivo's official Node.js SDK.prisma,@prisma/client: Prisma CLI and client.bullmq: Job queue system.ioredis: Recommended Redis client for BullMQ.zod: Schema validation library (highly recommended for API inputs).date-fns: For reliable date/time manipulation (scheduling).-Dflags install development-only dependencies.
1.3. Initialize Prisma
Set up Prisma for database management.
npx prisma init --datasource-provider postgresqlThis creates a prisma directory with a schema.prisma file and a .env file for your database connection string.
1.4. Configure Environment Variables
Open the .env file created by Prisma (or create .env.local which Next.js prefers and Git ignores by default). Add your Plivo credentials and database/Redis URLs.
Important: Replace the placeholder values below (like YOUR_PLIVO_AUTH_ID) with your actual credentials and configuration details from Plivo, your database, and Redis.
# .env or .env.local
# Plivo Credentials
# Get these from your Plivo Console: https://console.plivo.com/dashboard/
PLIVO_AUTH_ID="YOUR_PLIVO_AUTH_ID"
PLIVO_AUTH_TOKEN="YOUR_PLIVO_AUTH_TOKEN"
PLIVO_SENDER_ID="YOUR_PLIVO_PHONE_NUMBER_OR_SENDER_ID" # Your Plivo sending number (e.g., +14155551212) or Alphanumeric Sender ID
# Database Connection (Prisma)
# Example for local Docker setup (see step 1.5)
DATABASE_URL="postgresql://user:password@localhost:5432/plivo_marketing?schema=public"
# Redis Connection (BullMQ)
# Example for local Docker setup (see step 1.5)
REDIS_URL="redis://localhost:6379"
# Application Settings
NEXT_PUBLIC_APP_URL="http://localhost:3000" # Base URL for callbacks, etc. (Update for production)
API_SECRET_KEY="YOUR_STRONG_RANDOM_SECRET_KEY" # For simple internal API protectionPLIVO_AUTH_ID/PLIVO_AUTH_TOKEN: Find these on your Plivo console dashboard. Treat the Auth Token like a password – keep it secret.PLIVO_SENDER_ID: The Plivo phone number (in E.164 format, e.g.,+14155551212) or approved Alphanumeric Sender ID you'll send messages from. For US/Canada, this must be a Plivo number.DATABASE_URL: Connection string for your PostgreSQL database. The example assumes a local Docker setup.REDIS_URL: Connection string for your Redis instance.NEXT_PUBLIC_APP_URL: The base URL where your app is accessible. Used for constructing callback URLs if needed later. Important: Update this for your production deployment.API_SECRET_KEY: A simple secret for basic protection of internal API routes. Generate a strong random string.
SECURITY NOTE: Never commit your .env or .env.local file containing secrets to Git. Ensure .env.local is in your .gitignore file (Create Next App usually adds it).
1.5. Set Up Local Database and Redis (Docker Recommended)
The easiest way to run PostgreSQL and Redis locally is using Docker. Create a docker-compose.yml file in your project root:
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16 # PostgreSQL 16 is current stable version as of 2025
restart: always
environment:
POSTGRES_DB: plivo_marketing
POSTGRES_USER: user
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7.4 # Redis 7.4 is current stable version as of 2025
restart: always
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:Run these services:
docker-compose up -d # Start in detached modeNow your database and Redis should be running locally, accessible via the URLs specified in .env.local.
1.6. Project Structure Overview
Your project might look something like this:
plivo-marketing-app/
├── components/ # Reusable React components (e.g., forms, UI elements)
├── jobs/ # Recommended location for BullMQ worker logic (e.g., worker.ts)
├── lib/ # Shared utility functions (Prisma client, Plivo client, queue setup, etc.)
├── pages/
│ ├── api/ # Next.js API routes (our backend)
│ │ ├── campaigns.ts
│ │ └── webhooks/ # (Optional) Plivo callbacks
│ ├── _app.tsx # Global App component (assuming TS)
│ ├── _document.tsx # Custom Document (assuming TS)
│ └── index.tsx # Main dashboard page (assuming TS)
├── prisma/
│ ├── migrations/ # Database migration files
│ └── schema.prisma # Prisma schema definition
├── public/ # Static assets
├── scripts/ # Standalone scripts (e.g., seeding)
├── styles/ # CSS files
├── .env.local # Environment variables (DO NOT COMMIT)
├── .gitignore
├── docker-compose.yml
├── next.config.js
├── package.json
└── README.md
2. Implementing Core Functionality
Now, let's build the main features: defining the database structure, setting up the Plivo client, creating the job queue, and building the API endpoint for campaign creation.
2.1. Define Database Schema
Open prisma/schema.prisma and define models for Campaigns, Recipients, and Message Logs.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Campaign {
id String @id @default(cuid())
name String
message String
mediaUrls String[] // Array of URLs for MMS
status CampaignStatus @default(PENDING)
scheduledAt DateTime? // Optional: for scheduled campaigns
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
recipients Recipient[]
messageLogs MessageLog[]
}
model Recipient {
id String @id @default(cuid())
phoneNumber String // Consider adding @@unique constraint if numbers should be unique globally or per campaign import
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
status MessageStatus @default(PENDING) // Status for this specific recipient in the campaign
plivoMessageUuid String? @unique // Store Plivo's message ID if available
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messageLog MessageLog? // Link to the specific log entry for this recipient
@@index([campaignId])
@@index([phoneNumber]) // Index phone number for opt-out checks
@@unique([campaignId, phoneNumber]) // Ensure a number appears only once per campaign
}
// Optional but recommended: Log individual message attempts/statuses
model MessageLog {
id String @id @default(cuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
recipientId String @unique // One log per recipient attempt
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
plivoMessageUuid String? @unique
status MessageStatus
errorCode String? // Plivo error code, if any
errorMessage String? // Plivo error message
sentAt DateTime?
deliveredAt DateTime? // If using delivery reports
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([campaignId])
@@index([plivoMessageUuid])
}
// Recommended for Opt-Out handling
model OptOut {
id String @id @default(cuid())
phoneNumber String @unique // Store opted-out numbers in E.164 format
source String? // Optional: 'SMS', 'USER_REQUEST', etc.
createdAt DateTime @default(now())
}
enum CampaignStatus {
PENDING // Campaign created, not yet processing
PROCESSING // Jobs are being added to the queue
QUEUED // All jobs added, waiting for workers
SENDING // Workers are actively sending messages
COMPLETED // All messages attempted (success or fail)
FAILED // Major failure during processing/setup
SCHEDULED // Waiting for scheduled time
}
enum MessageStatus {
PENDING // Not yet sent
QUEUED // In the BullMQ queue
SENT // Successfully handed off to Plivo
DELIVERED // Confirmed delivery (requires webhooks)
UNDELIVERED // Failed delivery (requires webhooks)
FAILED // Failed to send (API error, etc.)
OPTED_OUT // Recipient had opted out before send attempt
}- Campaign: Stores overall campaign details (name, message, status).
mediaUrlsallows for MMS. - Recipient: Stores individual phone numbers linked to a campaign. Status tracks sending progress for that number. Added
@@uniqueconstraint for[campaignId, phoneNumber]. - MessageLog: (Highly recommended) Tracks the outcome of each individual message send attempt, including Plivo's UUID and any errors.
- OptOut: (Recommended) A separate table to store phone numbers that have opted out of receiving messages. Crucial for compliance.
- Enums: Define possible statuses for campaigns and messages. Added
OPTED_OUTstatus.
2.2. Apply Database Migrations
Generate and apply the SQL migration based on your schema changes.
npx prisma migrate dev --name initThis command:
- Creates a new SQL migration file in
prisma/migrations/. - Applies the migration to your database.
- Generates/updates the Prisma Client (
@prisma/client).
2.3. Initialize Prisma Client
Create a reusable Prisma client instance.
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
declare global {
// allow global `var` declarations
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma =
global.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
global.prisma = prisma;
}
export default prisma;- This pattern prevents creating multiple
PrismaClientinstances during development hot-reloading in Next.js. - Enables query logging in development.
2.4. Initialize Plivo Client
Create a helper function to initialize the Plivo client using TypeScript.
// lib/plivoClient.ts
import * as plivo from 'plivo';
let client: plivo.Client | null = null;
export function getPlivoClient(): plivo.Client {
if (!client) {
const authId = process.env.PLIVO_AUTH_ID;
const authToken = process.env.PLIVO_AUTH_TOKEN;
if (!authId || !authToken) {
throw new Error("Plivo Auth ID or Auth Token not configured in environment variables.");
}
client = new plivo.Client(authId, authToken);
}
return client;
}- Uses TypeScript syntax (
import, type annotations,export).
2.5. Set Up BullMQ Job Queue
Define the queue and the connection.
// lib/queue.ts
import { Queue, Worker, Job } from 'bullmq';
import Redis from 'ioredis'; // ioredis is recommended by BullMQ
const connection = new Redis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null // Prevent Redis commands from failing after max retries
});
// Define the type for our job data
interface SmsJobData {
recipientId: string;
campaignId: string;
to: string;
from: string;
text: string;
mediaUrls?: string[];
}
const SMS_QUEUE_NAME = 'sms-campaign-queue';
// Create the queue instance
const smsQueue = new Queue<SmsJobData>(SMS_QUEUE_NAME, { connection });
// Export the queue and connection for use elsewhere
export { smsQueue, connection, SMS_QUEUE_NAME };
export type { SmsJobData };
// --- Worker Setup (Run this in a separate process or script) ---
// **Recommendation:** For better separation of concerns and easier scaling,
// it's highly recommended to move the worker logic below into a separate file,
// for example, `jobs/worker.ts` or `scripts/worker.ts`.
// You would then update the `start:worker` script in `package.json`
// (see Section 12.2) to execute that specific file (e.g., `ts-node jobs/worker.ts`).
// The code below demonstrates keeping it in the same file for simplicity in this guide.
if (require.main === module) { // Only run worker logic if this file is executed directly
console.log(`Starting SMS worker for queue: ${SMS_QUEUE_NAME}...`);
const startWorker = async () => {
// Dynamically import dependencies needed only by the worker
const { getPlivoClient } = await import('./plivoClient');
const { default: prisma } = await import('./prisma');
const worker = new Worker<SmsJobData>(
SMS_QUEUE_NAME,
async (job: Job<SmsJobData>) => {
console.log(`Processing job ${job.id} for recipient ${job.data.to}`);
const { recipientId, campaignId, to, from, text, mediaUrls } = job.data;
try {
const client = getPlivoClient();
const messageParams: plivo.MessageCreateParams = {
src: from,
dst: to,
text: text,
};
// Add media URLs for MMS
if (mediaUrls && mediaUrls.length > 0) {
messageParams.type = 'mms';
messageParams.media_urls = mediaUrls;
console.log(`Sending MMS to ${to} with media: ${mediaUrls.join(', ')}`);
} else {
console.log(`Sending SMS to ${to}`);
}
// Optional: Add status callback URL (Requires a webhook endpoint)
// messageParams.url = `${process.env.NEXT_PUBLIC_APP_URL}/api/webhooks/plivo-status`;
// messageParams.method = "POST";
const response = await client.messages.create(messageParams);
const plivoUuid = response.messageUuid && response.messageUuid.length > 0 ? response.messageUuid[0] : null;
console.log(`Message sent to ${to}, Plivo UUID: ${plivoUuid}`);
// Update Recipient and create MessageLog on success
await prisma.recipient.update({
where: { id: recipientId },
data: {
status: 'SENT',
plivoMessageUuid: plivoUuid,
},
});
await prisma.messageLog.create({
data: {
campaignId: campaignId,
recipientId: recipientId,
plivoMessageUuid: plivoUuid,
status: 'SENT',
sentAt: new Date(),
}
});
} catch (error: any) {
console.error(`Failed to send message to ${to} (Job ${job.id}):`, error);
const errorCode = error.statusCode || 'UNKNOWN';
const errorMessage = error.message || 'Failed to process job';
// Update Recipient and create MessageLog on failure
try {
await prisma.recipient.update({
where: { id: recipientId },
data: { status: 'FAILED' },
});
await prisma.messageLog.create({
data: {
campaignId: campaignId,
recipientId: recipientId,
status: 'FAILED',
errorCode: errorCode.toString(),
errorMessage: errorMessage,
}
});
} catch (dbError) {
console.error(`Failed to update database after sending failure for job ${job.id}:`, dbError);
}
// Important: Throw the error again so BullMQ knows the job failed
// This enables retry logic if configured.
throw error;
}
},
{
connection,
concurrency: 5, // Process up to 5 jobs concurrently (adjust based on resources/Plivo limits)
limiter: { // Optional: Rate limit outgoing requests to Plivo
max: 10, // Max 10 jobs
duration: 1000, // per second
},
attempts: 3, // Retry failed jobs up to 3 times
backoff: { // Exponential backoff strategy
type: 'exponential',
delay: 5000, // Initial delay 5 seconds
},
}
);
worker.on('completed', (job: Job<SmsJobData>) => {
console.log(`Job ${job.id} completed for ${job.data.to}`);
});
worker.on('failed', (job: Job<SmsJobData> | undefined, err: Error) => {
if (job) {
console.error(`Job ${job.id} failed for ${job.data.to} after ${job.attemptsMade} attempts:`, err.message);
} else {
console.error(`A job failed with error:`, err.message);
}
});
console.log("SMS Worker started successfully.");
const gracefulShutdown = async (signal: string) => {
console.log(`${signal} signal received: closing worker gracefully.`);
await worker.close();
console.log('Worker closed.');
process.exit(0);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
}
startWorker().catch(err => {
console.error("Failed to start worker:", err);
process.exit(1);
});
}- Uses dynamic
import()for worker dependencies (plivoClient,prisma). - Added explicit recommendation to move worker logic to a separate file.
- Ensured Plivo
MessageCreateParamstype is used. - Improved graceful shutdown handling.
- Handled potential
messageUuidbeing undefined or empty array from Plivo response.
2.6. Create the Campaign API Endpoint
This Next.js API route will handle campaign creation requests.
// pages/api/campaigns.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';
import { CampaignStatus, MessageStatus } from '@prisma/client';
// Consider adding 'csv-parse/sync' if handling CSV uploads: npm install csv-parse
import prisma from '../../lib/prisma';
import { smsQueue, SmsJobData } from '../../lib/queue';
import { differenceInSeconds } from 'date-fns';
// Input validation schema
// Note on API Key: Accepting in body for simplicity. Recommended practice is via headers (e.g., Authorization: Bearer <key> or X-API-Key: <key>).
const campaignSchema = z.object({
name: z.string().min(3, "Campaign name must be at least 3 characters"),
message: z.string().min(1, "Message cannot be empty"),
recipients: z.array(z.string().regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number format")).min(1, "At least one recipient is required"),
mediaUrls: z.array(z.string().url("Invalid media URL format")).optional(),
scheduledAt: z.string().datetime({ offset: true, message: "Invalid schedule date format" }).optional(),
apiKey: z.string().refine(key => key === process.env.API_SECRET_KEY, { message: "Invalid API Key" }),
});
// Helper to validate E.164 format strictly
function isValidE164(phoneNumber: string): boolean {
return /^\+[1-9]\d{1,14}$/.test(phoneNumber);
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', ['POST']);
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
}
// --- 1. Input Validation ---
const validationResult = campaignSchema.safeParse(req.body);
if (!validationResult.success) {
return res.status(400).json({ message: "Invalid input", errors: validationResult.error.format() });
}
const { name, message, recipients: rawRecipients, mediaUrls, scheduledAt, apiKey } = validationResult.data;
// --- 2. Sanitize, Deduplicate, and Validate Recipients ---
const uniqueRecipients = [...new Set(rawRecipients)]
.map(num => num.trim())
.filter(isValidE164); // Ensure E.164 format
if (uniqueRecipients.length === 0) {
return res.status(400).json({ message: "No valid E.164 recipient phone numbers provided." });
}
// --- 2.5 Check Opt-Out List (CRITICAL FOR COMPLIANCE) ---
let optedOutNumbers: Set<string>;
try {
const optOuts = await prisma.optOut.findMany({
where: { phoneNumber: { in: uniqueRecipients } },
select: { phoneNumber: true }
});
optedOutNumbers = new Set(optOuts.map(o => o.phoneNumber));
console.log(`Found ${optedOutNumbers.size} opted-out numbers in the provided list.`);
} catch (error) {
console.error("Error checking opt-out list:", error);
return res.status(500).json({ message: "Failed to verify recipient opt-out status." });
}
const finalRecipients = uniqueRecipients.filter(num => !optedOutNumbers.has(num));
if (finalRecipients.length === 0) {
return res.status(400).json({ message: "All provided recipients have opted out or are invalid." });
}
const skippedCount = uniqueRecipients.length - finalRecipients.length;
// --- 3. Check Schedule Time ---
let delay = 0;
let campaignInitialStatus = CampaignStatus.PROCESSING;
let jobOptions = {};
if (scheduledAt) {
const scheduleDate = new Date(scheduledAt);
const now = new Date();
if (scheduleDate <= now) {
return res.status(400).json({ message: "Scheduled time must be in the future." });
}
delay = differenceInSeconds(scheduleDate, now) * 1000; // Delay in milliseconds
campaignInitialStatus = CampaignStatus.SCHEDULED;
console.log(`Campaign scheduled for ${scheduleDate}. Delay: ${delay}ms`);
jobOptions = { delay };
}
// --- 4. Create Campaign in Database ---
let campaign;
try {
campaign = await prisma.campaign.create({
data: {
name,
message,
mediaUrls: mediaUrls ?? [],
status: campaignInitialStatus,
scheduledAt: scheduledAt ? new Date(scheduledAt) : null,
recipients: {
create: finalRecipients.map(phone => ({
phoneNumber: phone,
// Mark as PENDING if scheduled, QUEUED otherwise. Worker picks up QUEUED.
status: scheduledAt ? MessageStatus.PENDING : MessageStatus.QUEUED
})),
},
},
include: {
recipients: true, // Include recipients to get their IDs for jobs
},
});
} catch (error) {
console.error("Error creating campaign in DB:", error);
// Handle potential unique constraint violation if campaign name needs to be unique etc.
return res.status(500).json({ message: "Failed to save campaign data." });
}
// --- 5. Add Jobs to Queue ---
const senderId = process.env.PLIVO_SENDER_ID;
if (!senderId) {
// Optionally update campaign status to FAILED here
console.error("PLIVO_SENDER_ID is not set.");
return res.status(500).json({ message: "Server configuration error: Sender ID missing." });
}
try {
const jobs: { name: string; data: SmsJobData; opts?: any }[] = campaign.recipients.map(recipient => ({
name: `send-${campaign.id}-${recipient.id}`, // Unique job name helpful for tracking
data: {
recipientId: recipient.id,
campaignId: campaign.id,
to: recipient.phoneNumber,
from: senderId,
text: message,
mediaUrls: mediaUrls,
},
opts: jobOptions // Apply delay if scheduled
}));
if (jobs.length > 0) {
await smsQueue.addBulk(jobs); // Efficiently add multiple jobs
} else {
console.log(`No jobs to queue for campaign ${campaign.id} (all recipients might have opted out or were invalid).`);
// If no jobs were created (e.g., all opted out), mark campaign as completed/failed?
await prisma.campaign.update({
where: { id: campaign.id },
data: { status: CampaignStatus.COMPLETED } // Or FAILED? Depends on desired logic
});
}
// Update campaign status if not scheduled and jobs were added
if (!scheduledAt && jobs.length > 0) {
await prisma.campaign.update({
where: { id: campaign.id },
data: { status: CampaignStatus.QUEUED }
});
}
console.log(`Successfully added ${jobs.length} jobs to the queue for campaign ${campaign.id}. Skipped ${skippedCount} opted-out/invalid numbers.`);
return res.status(201).json({
message: scheduledAt ? "Campaign scheduled successfully!" : "Campaign queued successfully!",
campaignId: campaign.id,
jobCount: jobs.length,
skippedCount: skippedCount,
});
} catch (error) {
console.error("Error adding jobs to queue:", error);
// Attempt to mark campaign as FAILED
try {
await prisma.campaign.update({
where: { id: campaign.id },
data: { status: CampaignStatus.FAILED },
});
} catch (dbError) {
console.error(`Failed to mark campaign ${campaign.id} as FAILED after queue error:`, dbError);
}
return res.status(500).json({ message: "Failed to queue messages for sending." });
}
}- Added check against the
OptOuttable before creating recipients and queuing jobs. - Updated response to include
skippedCount. - Added note about API key best practices (headers vs. body).
- Handles case where all recipients might be opted out.
3. Building the API Layer
The previous section established our primary API endpoint (/api/campaigns). Let's refine authentication and documentation.
3.1. Authentication/Authorization
The current implementation uses a simple shared secret (API_SECRET_KEY) passed in the request body. This offers basic protection suitable for internal use or simple scenarios where the caller is trusted.
Best Practice: For production applications, especially those exposed externally, it's strongly recommended to use more robust methods:
- API Keys via Headers: Accept the API key via standard HTTP headers like
Authorization: Bearer <YOUR_API_KEY>orX-API-Key: <YOUR_API_KEY>. This is more conventional than sending secrets in the request body. Validate the key against stored (ideally hashed) keys in your database. - JWT (JSON Web Tokens): If your application includes user logins (e.g., via NextAuth.js), issue JWTs upon successful login. Protect API routes by verifying the JWT signature, expiration, and potentially user permissions associated with the token.
- OAuth: Suitable for scenarios where third-party applications need to interact with your API on behalf of users.
3.2. Request Validation
We are using zod within the API route (pages/api/campaigns.ts) for strict validation of the request body's structure, types, and formats (including E.164 phone numbers and URLs). This is a crucial security measure against invalid data and potential injection attacks.
3.3. API Endpoint Documentation
Clear documentation is essential for API consumers.
Example Documentation (Markdown):
## API Endpoints
### POST /api/campaigns
Creates and queues (or schedules) a new SMS/MMS campaign after validating recipients against the opt-out list.
**Request Body:** (`application/json`)
```json
{
"name": "Summer Promo Blast",
"message": "Hot deals for summer! \n Up to 40% off selected items. Visit example.com/summer",
"recipients": [
"+14155551212",
"+12125559876",
"+442071234567"
],
"mediaUrls": [
"https://example.com/images/summer_deal.png"
],
"scheduledAt": "2024-08-01T14:00:00.000Z",
"apiKey": "YOUR_STRONG_RANDOM_SECRET_KEY"
}Parameters:
name(string, required): Name of the campaign.message(string, required): The text content of the message. Use\nfor line breaks.recipients(array[string], required): List of recipient phone numbers in E.164 format. Duplicates are removed, invalid formats ignored, and numbers present in theOptOuttable are skipped.mediaUrls(array[string], optional): An array of URLs pointing to media files for MMS messages.scheduledAt(string, optional): An ISO 8601 formatted date-time string (UTC recommended) to schedule the campaign for future sending. If omitted, the campaign is queued immediately.apiKey(string, required): Your secret API key (from.env.local). Note: Using a header likeAuthorization: Bearer <key>orX-API-Key: <key>is preferred in production environments.
Success Response: (201 Created)
{
"message": "Campaign scheduled successfully!" / "Campaign queued successfully!",
"campaignId": "clerk123abc...",
"jobCount": 2, // Number of messages actually queued/scheduled
"skippedCount": 1 // Number of recipients skipped (opted-out or invalid)
}Error Responses:
400 Bad Request: Invalid input data (validation errors, invalid schedule date, no valid recipients). Includes anerrorsobject or detailedmessage.401 Unauthorized/403 Forbidden: (If using header-based auth) Invalid or missing API key.405 Method Not Allowed: Used a method other than POST.500 Internal Server Error: Database error, queue error, missing server configuration (likePLIVO_SENDER_ID), or other unexpected server-side issue.
Frequently Asked Questions
How do I send bulk SMS campaigns with Plivo and Next.js?
Create a Next.js API route that accepts recipient lists, validates phone numbers against an opt-out table, creates campaign records in PostgreSQL via Prisma, and adds jobs to a BullMQ queue. A separate worker process consumes jobs and calls the Plivo API to send messages. This architecture separates API requests from message sending, enabling scalability and reliability.
What is BullMQ and why use it for SMS campaigns?
BullMQ is a Redis-based job queue system for Node.js that handles asynchronous task processing. For SMS campaigns, BullMQ prevents API timeouts by processing messages in the background, enables job retries with exponential backoff (critical for transient Plivo API errors), supports scheduled campaigns with delay options, and provides rate limiting to comply with Plivo's throughput limits.
How do I schedule SMS campaigns for future sending?
Use BullMQ's delay option when adding jobs to the queue. Calculate the delay in milliseconds using differenceInSeconds() from date-fns, validate that the scheduled time is in the future, set the campaign status to SCHEDULED in the database, and pass { delay: delayInMs } as options when calling smsQueue.add(). BullMQ automatically processes jobs at the specified time.
How do I handle SMS opt-out compliance in Next.js?
Create an OptOut model in your Prisma schema with a unique phone number field. Before queuing campaign jobs, query the OptOut table to filter recipients: await prisma.optOut.findMany({ where: { phoneNumber: { in: recipients } } }). Skip opted-out numbers when creating recipient records and jobs. Implement webhook endpoints to capture STOP keywords from incoming messages and add numbers to the opt-out table immediately.
What's the difference between SMS and MMS in Plivo?
SMS sends text-only messages (160 characters GSM-7, 70 characters Unicode). MMS sends multimedia messages with images, videos, or audio by including media_urls array in the Plivo API call and setting type: 'mms'. In your Next.js API route, accept an optional mediaUrls array parameter, pass it to BullMQ job data, and the worker adds it to messageParams.media_urls when calling Plivo's client.messages.create().
How do I deploy a Next.js Plivo SMS campaign app to production?
Deploy the Next.js app to Vercel (automatically handles API routes). Run the BullMQ worker as a separate long-running process on a platform like Heroku, Railway, or AWS ECS – not on Vercel, which has 10-second function timeouts. Use managed PostgreSQL (Supabase, Neon, PlanetScale) and Redis (Upstash, Redis Cloud). Set environment variables on each platform. Configure Plivo webhooks to point to your production URL for delivery status callbacks.
Why separate the BullMQ worker from Next.js API routes?
Next.js API routes on Vercel have execution time limits (10 seconds for Hobby, 60 seconds for Pro). Processing thousands of SMS messages would timeout. A separate worker process runs indefinitely, processes jobs at its own pace, handles retries without blocking API responses, and can be scaled independently (multiple workers for higher throughput). This architecture ensures your API remains responsive while background jobs process reliably.
Frequently Asked Questions
How to send bulk SMS messages with Plivo and Next.js?
Build a Next.js application with API routes to handle campaign creation and a Node.js worker process using BullMQ to manage the message queue. This worker interacts with the Plivo API to send messages based on jobs added to the queue, ensuring reliable delivery even with large recipient lists. The provided guide details project setup, implementation, and deployment steps.
What is the purpose of BullMQ in this SMS marketing application?
BullMQ is used as a robust message queue system built upon Redis. It handles the potentially time-consuming task of sending numerous SMS/MMS messages reliably and without blocking the main application thread. This ensures scalability and prevents message loss due to server issues or API limitations.
Why does this project use Prisma for database interaction?
Prisma, a next-generation ORM (Object-Relational Mapper), simplifies database management within the Node.js/TypeScript backend. It provides type-safe database queries, schema migrations, and efficient database operations, facilitating easier interaction with the PostgreSQL database used in this project.
How to schedule SMS campaigns for future delivery?
The application's API endpoint accepts a `scheduledAt` parameter during campaign creation. This parameter expects an ISO 8601 formatted date-time string. If provided, the campaign's messages will be queued in BullMQ with a delay, processed by the worker at the specified future time.
What is the role of Redis in this architecture?
Redis functions as the in-memory data store backing the BullMQ message queue. It ensures efficient and reliable storage of queued message jobs until they are processed and sent by the worker. Its speed and persistence make it ideal for handling asynchronous tasks like sending messages.
How to handle opt-outs for compliance?
The system includes an `OptOut` model in the database to record opted-out phone numbers. Before sending any message, the application checks the recipient number against this list to ensure compliance and prevent unwanted messages. The implementation details are provided in the article's API endpoint section.
How to integrate Plivo SMS API with Next.js?
Install the `plivo-node` SDK and initialize the Plivo client in a helper function. In the BullMQ job worker, use the Plivo client to create and send SMS/MMS messages based on the queued job data which includes recipient numbers and message content. The guide provides setup and implementation code snippets.
What is the project setup process for Plivo SMS application?
Install Node.js and NPM, create a Next.js project, install dependencies like 'plivo-node', 'prisma', 'bullmq', configure environment variables with Plivo and database credentials, and set up local database instances using Docker. The provided article outlines each step in detail.
How can I view message status updates in this system?
Basic message statuses (e.g., SENT, FAILED) are tracked for each recipient in the database. The guide also includes a section on implementing webhooks to receive more detailed statuses like DELIVERED/UNDELIVERED from Plivo if you want real-time updates directly from Plivo.
When should I use a message queue like BullMQ for sending SMS?
Whenever your SMS sending volume is large enough that sending each message individually within the request cycle could lead to timeouts or performance issues, a message queue like BullMQ is highly recommended. It allows the system to process the requests asynchronously, improving performance and reliability.
Can I send MMS messages with this system?
Yes, this system supports sending MMS (multimedia) messages. You can add an array of media URLs to the request data and the Plivo client will send them as an MMS message. The `mediaUrls` field in the `Campaign` and `SmsJobData` structures enables MMS handling.
How to add phone numbers to the opt-out list?
The article provides a database schema that includes an `OptOut` table. The implementation of adding numbers to this table is not explicitly covered in the article, but typically would involve creating an API endpoint or interface to record opt-out requests from users (e.g., SMS reply with 'STOP', web form).
What technologies are used in building this system?
This project leverages Next.js for the frontend and API routes, Node.js for the runtime, Plivo for sending SMS/MMS, Prisma for database interaction with PostgreSQL, Redis with BullMQ for message queuing, and optional tools like Tailwind CSS for styling and Docker for local development.