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.
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 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).
Bash
npx create-next-app@latest plivo-marketing-app
cd plivo-marketing-app
1.2. Install Dependencies
Install the Plivo SDK, Prisma, BullMQ, and other utilities.
Bash
# 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 autoprefixer
plivo-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).
-D flags install development-only dependencies.
1.3. Initialize Prisma
Set up Prisma for database management.
Bash
npx prisma init --datasource-provider postgresql
This creates a prisma directory with a schema.prisma file and a .env file for your database connection string.
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.
Text
# .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 protection
PLIVO_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:
YAML
# docker-compose.ymlversion:'3.8'services:postgres:image:postgres:16# PostgreSQL 16 is current stable version as of 2025restart:alwaysenvironment:POSTGRES_DB:plivo_marketingPOSTGRES_USER:userPOSTGRES_PASSWORD:passwordports:-"5432:5432"volumes:-postgres_data:/var/lib/postgresql/dataredis:image:redis:7.4# Redis 7.4 is current stable version as of 2025restart:alwaysports:-"6379:6379"volumes:-redis_data:/datavolumes:postgres_data:redis_data:
Run these services:
Bash
docker-compose up -d # Start in detached mode
Now your database and Redis should be running locally, accessible via the URLs specified in .env.local.
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
// 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
}
Recipient: Stores individual phone numbers linked to a campaign. Status tracks sending progress for that number. Added @@unique constraint 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_OUT status.
2.2. Apply Database Migrations
Generate and apply the SQL migration based on your schema changes.
Bash
npx prisma migrate dev --name init
This command:
Creates a new SQL migration file in prisma/migrations/.
Applies the migration to your database.
Generates/updates the Prisma Client (@prisma/client).
This pattern prevents creating multiple PrismaClient instances 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.
TypeScript
// lib/plivoClient.tsimport * as plivo from'plivo';
letclient: plivo.Client | null = null;
exportfunctiongetPlivoClient(): plivo.Client {
if (!client) {
const authId = process.env.PLIVO_AUTH_ID;
const authToken = process.env.PLIVO_AUTH_TOKEN;
if (!authId || !authToken) {
thrownewError("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.
TypeScript
// lib/queue.tsimport { Queue, Worker, Job } from'bullmq';
importRedisfrom'ioredis'; // ioredis is recommended by BullMQconst connection = newRedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null// Prevent Redis commands from failing after max retries
});
// Define the type for our job datainterfaceSmsJobData {
recipientId: string;
campaignId: string;
to: string;
from: string;
text: string;
mediaUrls?: string[];
}
constSMS_QUEUE_NAME = 'sms-campaign-queue';
// Create the queue instanceconst smsQueue = newQueue<SmsJobData>(SMS_QUEUE_NAME, { connection });
// Export the queue and connection for use elsewhereexport { smsQueue, connection, SMS_QUEUE_NAME };
exporttype { 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 directlyconsole.log(`Starting SMS worker for queue: ${SMS_QUEUE_NAME}...`);
conststartWorker = async () => {
// Dynamically import dependencies needed only by the workerconst { getPlivoClient } = awaitimport('./plivoClient');
const { default: prisma } = awaitimport('./prisma');
const worker = newWorker<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();
constmessageParams: plivo.MessageCreateParams = {
src: from,
dst: to,
text: text,
};
// Add media URLs for MMSif (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 successawait 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: newDate(),
}
});
} 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 failuretry {
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 Plivomax: 10, // Max 10 jobsduration: 1000, // per second
},
attempts: 3, // Retry failed jobs up to 3 timesbackoff: { // Exponential backoff strategytype: '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.");
constgracefulShutdown = 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 MessageCreateParams type is used.
Improved graceful shutdown handling.
Handled potential messageUuid being undefined or empty array from Plivo response.
2.6. Create the Campaign API Endpoint
This Next.js API route will handle campaign creation requests.
TypeScript
// pages/api/campaigns.tsimporttype { 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-parseimport 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 strictlyfunctionisValidE164(phoneNumber: string): boolean {
return/^\+[1-9]\d{1,14}$/.test(phoneNumber);
}
exportdefaultasyncfunctionhandler(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 = [...newSet(rawRecipients)]
.map(num => num.trim())
.filter(isValidE164); // Ensure E.164 formatif (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) ---letoptedOutNumbers: Set<string>;
try {
const optOuts = await prisma.optOut.findMany({
where: { phoneNumber: { in: uniqueRecipients } },
select: { phoneNumber: true }
});
optedOutNumbers = newSet(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 = newDate(scheduledAt);
const now = newDate();
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 ? newDate(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 hereconsole.error("PLIVO_SENDER_ID is not set.");
return res.status(500).json({ message: "Server configuration error: Sender ID missing." });
}
try {
constjobs: { name: string; data: SmsJobData; opts?: any }[] = campaign.recipients.map(recipient => ({
name: `send-${campaign.id}-${recipient.id}`, // Unique job name helpful for trackingdata: {
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 addedif (!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 FAILEDtry {
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 OptOut table 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> or X-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):
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 \n for 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 the OptOut table 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 like Authorization: Bearer <key> or X-API-Key: <key> is preferred in production environments.
Success Response: (201 Created)
JSON
{"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 an errors object or detailed message.
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 (like PLIVO_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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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:
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:
System Architecture:
(Note: Ensure your publishing platform correctly renders Mermaid syntax, or provide a static image fallback with appropriate alt text.)
Prerequisites:
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-appwith 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.
This creates a
prismadirectory with aschema.prismafile and a.envfile for your database connection string.1.4. Configure Environment Variables
Open the
.envfile created by Prisma (or create.env.localwhich 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.PLIVO_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
.envor.env.localfile containing secrets to Git. Ensure.env.localis in your.gitignorefile (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.ymlfile 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:
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.prismaand define models for Campaigns, Recipients, and Message Logs.mediaUrlsallows for MMS.@@uniqueconstraint for[campaignId, phoneNumber].OPTED_OUTstatus.2.2. Apply Database Migrations
Generate and apply the SQL migration based on your schema changes.
This command:
prisma/migrations/.@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;PrismaClientinstances during development hot-reloading in Next.js.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; }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); }); }import()for worker dependencies (plivoClient,prisma).MessageCreateParamstype is used.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." }); } }OptOuttable before creating recipients and queuing jobs.skippedCount.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:
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.3.2. Request Validation
We are using
zodwithin 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
delayoption when adding jobs to the queue. Calculate the delay in milliseconds usingdifferenceInSeconds()from date-fns, validate that the scheduled time is in the future, set the campaign status toSCHEDULEDin the database, and pass{ delay: delayInMs }as options when callingsmsQueue.add(). BullMQ automatically processes jobs at the specified time.How do I handle SMS opt-out compliance in Next.js?
Create an
OptOutmodel in your Prisma schema with a unique phone number field. Before queuing campaign jobs, query theOptOuttable 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_urlsarray in the Plivo API call and settingtype: 'mms'. In your Next.js API route, accept an optionalmediaUrlsarray parameter, pass it to BullMQ job data, and the worker adds it tomessageParams.media_urlswhen calling Plivo'sclient.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.