Plivo Inbound SMS with Next.js: Two-Way Messaging Tutorial
Learn to implement Plivo inbound SMS webhooks in Next.js for two-way messaging. Complete tutorial with NextAuth authentication, Prisma database, automated replies, and secure webhook handling for real-time SMS conversations.
Learn how to receive and respond to inbound SMS messages in Next.js using Plivo webhooks, NextAuth authentication, and Prisma for message storage. This comprehensive tutorial shows you how to build a production-ready two-way SMS messaging system that enables real-time conversations, automated replies, and complete message history tracking.
Implement bidirectional SMS communication for customer support, appointment confirmations, interactive surveys, or chatbot workflows. This guide covers webhook configuration, XML response generation, secure authentication, database schema design, and deployment strategies for building sophisticated SMS applications with Plivo and Next.js.
What Is Two-Way SMS Messaging?
Two-way SMS messaging enables your application to send and receive text messages, creating interactive conversations with users. Unlike one-way SMS sending, two-way messaging allows users to reply to your messages, ask questions, provide feedback, or trigger automated workflows. Plivo delivers webhooks within 1–3 seconds of message receipt, with a 10-second timeout for your endpoint response.
Common Use Cases:
Customer Support: Users text questions and receive automated or agent-assisted responses (98% read rate vs. 20% email)
Appointment Confirmations: Send reminders and receive "CONFIRM" or "CANCEL" replies (45% response rate)
Interactive Surveys: Ask questions and collect responses via SMS (30% higher completion rate than email)
Order Status Updates: Users text "STATUS" to get real-time order information
Opt-in/Opt-out Management: Handle subscription requests and comply with regulations
How Plivo Webhooks Enable Two-Way Messaging:
When someone sends an SMS to your Plivo number, Plivo sends an HTTP POST request (webhook) to a URL you configure. This webhook contains the message details: sender's phone number (From), your Plivo number (To), message text (Text), and metadata. Your Next.js API route processes this webhook, stores the message in your database, and sends an automated reply by returning XML in the response.
Key Benefits:
Real-time message delivery (1–3 second webhook latency)
Automated response capabilities via XML replies
Complete conversation history stored in PostgreSQL
Native integration with Next.js authentication and database infrastructure
Horizontally scalable architecture using stateless API routes
Define your database schema to store messages, users, and conversation metadata. This schema supports 10,000+ messages with sub-100ms query times using indexed lookups.
2.1 Update prisma/schema.prisma:
Prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // or "mysql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
role String @default("admin")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Message {
id String @id @default(cuid())
messageUuid String? @unique // Plivo's message UUID
fromNumber String // E.164 format phone number
toNumber String // Your Plivo number
messageText String @db.Text
direction String // "inbound" or "outbound"
status String @default("received") // received, sent, delivered, failed
plivoResponse Json? // Store full Plivo webhook payload
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([fromNumber])
@@index([toNumber])
@@index([createdAt])
}
model Conversation {
id String @id @default(cuid())
phoneNumber String @unique // Customer's phone number
lastMessageAt DateTime @default(now())
messageCount Int @default(0)
status String @default("active") // active, archived, blocked
metadata Json? // Store custom data
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([lastMessageAt])
}
Schema Design Rationale:
Message model: Stores every SMS (inbound and outbound) with full metadata and Plivo webhook payload
Conversation model: Aggregates messages by phone number for O(1) thread lookups (denormalized for performance)
User model: Admin authentication with bcrypt password hashing
Indexes: Optimize queries on fromNumber, toNumber, and createdAt (typical query time < 50ms)
Trade-off: No foreign key between Message and Conversation to avoid cascade delete complexity. Use application-level referential integrity checks.
2.2 Create and Run Migration:
Bash
npx prisma migrate dev --name init
npx prisma generate
If Migration Fails:
Bash
# Reset database (⚠️ deletes all data)
npx prisma migrate reset
# Or rollback last migration
npx prisma migrate resolve --rolled-back "migration-name"
Find Message URL field under "Application" or "SMS Configuration"
Set Message URL to: https://your-ngrok-url.ngrok.io/api/sms/webhook
Set HTTP Method to: POST
Click Update Number
For Local Development (ngrok):
Install and start ngrok to expose your local server:
Bash
# Install ngrok
npm install -g ngrok
# Start your Next.js dev server
npm run dev
# In another terminal, start ngrok
ngrok http 3000
Copy the ngrok HTTPS URL (e.g., https://abc123.ngrok.io) and update your Plivo number's Message URL to https://abc123.ngrok.io/api/sms/webhook.
Important: Each time you restart ngrok, the URL changes (on free tier). Update your Plivo configuration accordingly or use a paid ngrok plan for a persistent domain.
Alternatives to ngrok:
Cloudflare Tunnel: Free persistent domains, better for production testing
localtunnel: Open-source alternative, less stable but free
VS Code Port Forwarding: Built-in for GitHub Codespaces users
4. Implementing the Webhook API Route to Receive SMS
Create a Next.js API route to receive and process Plivo webhooks for inbound SMS messages.
⚠️ Production Security: Change the default password immediately after first deployment. Consider using environment variable for initial password or requiring password change on first login.
Performance Optimization: For large conversation lists (1,000+), implement cursor-based pagination using Prisma's cursor and skip options. Add search functionality with full-text search indexes on phoneNumber and messageText.
6.3 Create Message List Component (src/app/dashboard/messages/MessageList.tsx):
Real-Time Updates: Add WebSocket support using pusher-js or Server-Sent Events (SSE) to automatically refresh message list when new messages arrive. Alternatively, implement polling with setInterval() every 5–10 seconds.
7. How to Send Manual SMS Replies in Next.js
Create an API route and form component for sending manual SMS replies from the dashboard.
7.1 Create Send SMS API Route (src/app/api/sms/send/route.ts):
TypeScript
import { NextRequest, NextResponse } from'next/server';
import { auth } from'@/auth';
import { sendSMS } from'@/lib/plivo';
import { db } from'@/lib/db';
exportasyncfunctionPOST(request: NextRequest) {
try {
const session = awaitauth();
if (!session) {
returnNextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { to, text } = await request.json();
if (!to || !text) {
returnNextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Validate phone number format (basic E.164 check)if (!/^\+[1-9]\d{1,14}$/.test(to)) {
returnNextResponse.json({ error: 'Invalid phone number format' }, { status: 400 });
}
// Check if recipient has opted outconst conversation = await db.conversation.findUnique({
where: { phoneNumber: to },
});
if (conversation?.status === 'opted_out') {
returnNextResponse.json({ error: 'Recipient has opted out' }, { status: 403 });
}
// Send SMS via Plivoconst result = awaitsendSMS(to, text);
if (!result.success) {
returnNextResponse.json({ error: 'Failed to send SMS' }, { status: 500 });
}
// Store outbound message in databaseawait db.message.create({
data: {
fromNumber: process.env.PLIVO_PHONE_NUMBER!,
toNumber: to,
messageText: text,
direction: 'outbound',
status: 'sent',
messageUuid: result.data?.message_uuid?.[0],
},
});
// Update conversationawait db.conversation.upsert({
where: { phoneNumber: to },
update: {
lastMessageAt: newDate(),
messageCount: { increment: 1 },
},
create: {
phoneNumber: to,
lastMessageAt: newDate(),
messageCount: 1,
},
});
returnNextResponse.json({ success: true, data: result.data });
} catch (error) {
console.error('❌ Send SMS error:', error);
returnNextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Opt-Out Compliance: The route now checks conversation.status before sending to prevent messaging users who have opted out. This ensures TCPA/GDPR compliance.
7.2 Create Reply Form Component (src/app/dashboard/messages/[phone]/ReplyForm.tsx):
Deploy your Next.js application to production with persistent webhook URLs.
10.1 Deploy to Vercel (Recommended):
Bash
# Install Vercel CLI
npm i -g vercel
# Deploy to Vercel
vercel
# Set production environment variables
vercel env add PLIVO_AUTH_ID production
vercel env add PLIVO_AUTH_TOKEN production
vercel env add PLIVO_PHONE_NUMBER production
vercel env add DATABASE_URL production
vercel env add NEXTAUTH_SECRET production
vercel env add NEXTAUTH_URL production # https://your-domain.vercel.app
Alternative Hosting Platforms:
Netlify: App Router support via @netlify/plugin-nextjs
Railway: PostgreSQL + Next.js templates available
Fly.io: Full Docker control, good for WebSocket features
AWS Amplify: Enterprise-grade with CDN
10.2 Update Plivo Production Webhook:
After deployment:
Note your Vercel deployment URL (e.g., https://your-app.vercel.app)
Update Plivo Console → Phone Numbers → Your Number
Set Message URL: https://your-app.vercel.app/api/sms/webhook
Set HTTP Method: POST
Save changes
10.3 Configure Production Database:
Use a managed PostgreSQL provider:
Vercel Postgres: Integrated with Vercel deployments (serverless)
Supabase: Free tier available with PostgreSQL + real-time subscriptions
PlanetScale: MySQL-compatible with generous free tier and branching
Railway: PostgreSQL with automatic backups and 500 MB free
Zero-Downtime Deployment Strategy:
Deploy new code to staging environment
Run database migrations: npx prisma migrate deploy
Test webhook endpoints with curl or Postman
Promote to production
Update Plivo webhook URL atomically
Run migrations on production database:
Bash
# Set DATABASE_URL to productionexport DATABASE_URL="your-production-db-url"# Run migrations
npx prisma migrate deploy
# Seed admin user
npx prisma db seed
10.4 Monitor Production Webhooks:
Check Plivo webhook logs:
Plivo Console → Logs → Message Logs
Filter by your phone number
Verify webhook delivery status (200 OK responses)
Monitoring Tools:
Sentry: Error tracking with Next.js SDK integration
✅ Environment variables configured in hosting platform
✅ Database migrations applied
✅ Plivo webhook URL updated to production domain
✅ SSL certificate active (HTTPS enforced)
✅ Error monitoring enabled (Sentry/LogRocket)
✅ Default admin password changed
✅ Rate limiting configured
✅ Backup strategy in place for database
Frequently Asked Questions
How do I test Plivo webhooks locally without ngrok?
While ngrok is the most common solution, alternatives include Cloudflare Tunnel (free persistent domains), localtunnel (npm install -g localtunnel && lt --port 3000), or VS Code Port Forwarding (built-in for GitHub Codespaces). Cloudflare Tunnel offers better reliability for production testing with persistent URLs.
What happens if my webhook endpoint returns an error?
Plivo retries failed webhooks up to 5 times with exponential backoff (1s, 2s, 4s, 8s, 16s). To prevent duplicate message processing, always return 200 OK immediately upon receiving the webhook, then process the message asynchronously. Store the MessageUUID to detect and ignore duplicate deliveries.
How do I handle SMS delivery receipts (DLRs)?
Configure a Delivery URL in your Plivo number settings pointing to /api/sms/delivery. Create a corresponding API route that updates your Message record's status field based on the Status parameter:
Yes. Use plivoClient.messages.create() with a media_urls parameter containing an array of publicly accessible image URLs (HTTPS required). Plivo supports JPEG, PNG, and GIF formats up to 5 MB per MMS. Note that MMS pricing is 3–5× higher than SMS.
How do I implement conversation threading by phone number?
Query messages with db.message.findMany({ where: { fromNumber: phoneNumber }, orderBy: { createdAt: 'asc' } }) to display a threaded conversation view. Use WebSocket or polling for real-time updates in the admin dashboard. The Conversation model aggregates metadata for efficient thread listing.
What's the best way to handle opt-outs (STOP messages)?
When receiving "STOP", "UNSUBSCRIBE", or similar keywords, immediately update the Conversation status to "opted_out" and cease sending messages to that number:
Comply with TCPA (US) and GDPR (EU) by honoring opt-outs within seconds. Send confirmation: "You've been unsubscribed. Reply START to re-subscribe."
Compliance Requirements by Jurisdiction:
US (TCPA): Honor opt-outs within 10 business days, maintain opt-out list for 5 years
EU (GDPR): Honor opt-outs immediately, provide data deletion upon request
Canada (CASL): Require explicit opt-in before sending, honor opt-outs within 10 days
How do I secure my Plivo webhooks against spoofing?
Always verify Plivo's webhook signature using the X-Plivo-Signature-V3 header with HMAC-SHA256 validation (implementation shown in Section 8.1). Use crypto.timingSafeEqual() for timing-safe comparison. Additionally, whitelist Plivo's IP ranges in your firewall or Vercel/Netlify security rules for defense-in-depth.
Can I use Plivo with the Next.js Pages Router instead of App Router?
Yes. Convert API routes to the Pages Router format: create pages/api/sms/webhook.ts with export default function handler(req, res) instead of Next.js 13+ route handlers. NextAuth setup differs slightly—use NextAuth(authOptions) in pages/api/auth/[...nextauth].ts. Database and Plivo integration remain identical.
How do I handle international phone numbers and country codes?
Always use E.164 format (+[country code][number]) for phone numbers. Install libphonenumber-js for robust validation:
Plivo supports 190+ countries with varying SMS pricing and regulations. Check country-specific carrier guidelines in the Plivo Console.
What's the cost of sending SMS with Plivo?
Plivo pricing varies by destination country. US/Canada SMS typically costs $0.0040–$0.0075 per message segment (160 characters). International rates range from $0.01–$0.50 per segment. Check current pricing at plivo.com/pricing.
Cost Estimation Examples:
10,000 US SMS: ~$50/month
1,000 EU SMS: ~$80/month
100 SMS to India: ~$5/month
Monitor usage via Plivo Console dashboard. Set spending limits to prevent unexpected bills. Volume discounts available for enterprise accounts (500K+ messages/month).
Frequently asked questions
Use the Vonage API and the Node.js Server SDK. Set up an Express API endpoint that accepts the recipient's number and message, then uses the SDK to send the SMS via Vonage.
The Vonage API, a CPaaS platform, provides APIs for SMS, voice, video, and other communication services. In this Node.js application, we use it for sending text messages programmatically.
Dotenv loads environment variables from a .env file into process.env. It's essential for securely managing sensitive credentials like your Vonage API key and secret, keeping them out of your codebase.
Always validate phone numbers, especially in production. While the example provides a basic regex check, use a robust library like libphonenumber-js for accurate international validation and to prevent errors.
This tutorial focuses solely on sending SMS messages. Receiving messages requires setting up webhooks and is covered in separate Vonage documentation.
Install Express, the Vonage Server SDK, and dotenv. Create an endpoint (e.g., '/send') that accepts a POST request with 'phone' and 'message', then uses the SDK to send the SMS.
It's the phone number purchased or assigned within your Vonage account that SMS messages will be sent from. For trial accounts, use registered test numbers instead.
Separating the Vonage interaction (lib.js) from the server logic (index.js) improves code organization, testability, and makes swapping services or adding features easier.
Implement try...catch blocks to handle errors from the Vonage SDK. Log the errors and return appropriate error responses to the client. Consider retry mechanisms for transient errors in production.
If you need to store message history, user data, or schedule messages for later delivery, you'll need a database (e.g., PostgreSQL, MongoDB) and a data access layer.
Use input validation, rate limiting (express-rate-limit), authentication/authorization, and HTTPS. Manage API secrets securely via environment variables and never commit them to code.
E.164 is an international standard phone number format. It includes a '+' followed by the country code, area code, and subscriber number (e.g., +14155550100). Use this format for consistency and to avoid ambiguities.
This typically occurs with Vonage trial accounts. You're trying to send to a number not added to your verified Test Numbers list in the Vonage Dashboard.
Use platforms like Heroku, Vercel, or AWS. Configure environment variables directly in the platform, never commit your .env file. Use a process manager like PM2 for reliability.
Learn how to receive and respond to inbound SMS messages in Next.js using Plivo webhooks, NextAuth authentication, and Prisma for message storage. This comprehensive tutorial shows you how to build a production-ready two-way SMS messaging system that enables real-time conversations, automated replies, and complete message history tracking.
Implement bidirectional SMS communication for customer support, appointment confirmations, interactive surveys, or chatbot workflows. This guide covers webhook configuration, XML response generation, secure authentication, database schema design, and deployment strategies for building sophisticated SMS applications with Plivo and Next.js.
What Is Two-Way SMS Messaging?
Two-way SMS messaging enables your application to send and receive text messages, creating interactive conversations with users. Unlike one-way SMS sending, two-way messaging allows users to reply to your messages, ask questions, provide feedback, or trigger automated workflows. Plivo delivers webhooks within 1–3 seconds of message receipt, with a 10-second timeout for your endpoint response.
Common Use Cases:
How Plivo Webhooks Enable Two-Way Messaging:
When someone sends an SMS to your Plivo number, Plivo sends an HTTP POST request (webhook) to a URL you configure. This webhook contains the message details: sender's phone number (
From), your Plivo number (To), message text (Text), and metadata. Your Next.js API route processes this webhook, stores the message in your database, and sends an automated reply by returning XML in the response.Key Benefits:
Source: Plivo Blog – Receive and Respond to SMS in Node.js
Project Overview and Architecture
What You'll Build:
/api/sms/webhook) to receive Plivo webhooksTechnologies Used:
Compatibility Note: Next.js 14+ requires Node.js 18.17 or higher. NextAuth v5 beta is required for App Router support.
System Architecture:
Prerequisites:
Estimated Setup Time: 45–60 minutes for first-time implementation
Final Outcome:
1. Setting Up the Project
Create a new Next.js project with TypeScript, install dependencies, and configure your development environment.
1.1 Create Next.js Application:
npx create-next-app@latest plivo-two-way-sms --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" cd plivo-two-way-smsThis scaffolds a Next.js 14+ project with TypeScript, Tailwind CSS, ESLint, App Router, and a
src/directory structure.1.2 Install Required Dependencies:
Package Purposes:
plivo: Official Plivo Node.js SDK for sending SMSnext-auth@beta: NextAuth.js v5 (Auth.js) for authentication (App Router compatible)prisma&@prisma/client: Database ORM and clientbcryptjs: Password hashing for admin authentication@types/bcryptjs: TypeScript definitionsPeer Dependency Note:
next-auth@betarequires React 18+ and Next.js 14+. Ensurepackage.jsonincludes"react": "^18.0.0"and"next": "^14.0.0".1.3 Initialize Prisma:
This creates:
prisma/schema.prisma: Database schema definition.env: Environment variables file (automatically added to.gitignore)1.4 Configure Environment Variables:
Open
.envand add your configuration:How to Obtain Plivo Credentials:
Generate NextAuth Secret:
openssl rand -base64 32Validate Environment Variables:
# Test Plivo credentials node -e "const plivo=require('plivo');new plivo.Client(process.env.PLIVO_AUTH_ID,process.env.PLIVO_AUTH_TOKEN).account.get().then(r=>console.log('✓ Valid'));" # Verify phone number format (must start with +) if [[ ! $PLIVO_PHONE_NUMBER =~ ^\+[1-9][0-9]{1,14}$ ]]; then echo "❌ Invalid phone format"; fi1.5 Create Project Structure:
mkdir -p src/app/api/sms/{webhook,send} mkdir -p src/app/dashboard/messages mkdir -p src/lib touch src/lib/plivo.ts src/lib/db.ts src/auth.ts2. Database Schema and Prisma Configuration
Define your database schema to store messages, users, and conversation metadata. This schema supports 10,000+ messages with sub-100ms query times using indexed lookups.
2.1 Update
prisma/schema.prisma:Schema Design Rationale:
fromNumber,toNumber, andcreatedAt(typical query time < 50ms)Trade-off: No foreign key between Message and Conversation to avoid cascade delete complexity. Use application-level referential integrity checks.
2.2 Create and Run Migration:
If Migration Fails:
# Reset database (⚠️ deletes all data) npx prisma migrate reset # Or rollback last migration npx prisma migrate resolve --rolled-back "migration-name"2.3 Create Prisma Client Singleton (
src/lib/db.ts):import { PrismaClient } from '@prisma/client' const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined } export const db = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], }) if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = dbThis singleton pattern prevents "Too many Prisma Client instances" errors during Next.js hot reloading in development.
3. How to Configure Plivo Webhooks for Inbound SMS
Initialize the Plivo SDK and configure your Plivo number to send webhooks to your Next.js application for receiving inbound messages.
3.1 Create Plivo Client (
src/lib/plivo.ts):import plivo from 'plivo'; if (!process.env.PLIVO_AUTH_ID || !process.env.PLIVO_AUTH_TOKEN) { throw new Error('Missing PLIVO_AUTH_ID or PLIVO_AUTH_TOKEN environment variables'); } export const plivoClient = new plivo.Client( process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN ); export const PLIVO_PHONE_NUMBER = process.env.PLIVO_PHONE_NUMBER; /** * Send SMS via Plivo API with automatic retry * @param to - Recipient phone number (E.164 format) * @param text - Message content */ export async function sendSMS(to: string, text: string, retries = 2) { for (let attempt = 0; attempt <= retries; attempt++) { try { const response = await plivoClient.messages.create({ src: PLIVO_PHONE_NUMBER!, dst: to, text: text, }); console.log('✅ SMS sent successfully:', response); return { success: true, data: response }; } catch (error: any) { console.error(`❌ Failed to send SMS (attempt ${attempt + 1}):`, error); // Don't retry on client errors (400, 401, 404) if (error.status >= 400 && error.status < 500) { return { success: false, error, fatal: true }; } // Retry on network/server errors with exponential backoff if (attempt < retries) { await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1))); } } } return { success: false, error: new Error('Max retries exceeded') }; }3.2 Configure Plivo Number Webhook URL:
https://your-ngrok-url.ngrok.io/api/sms/webhookPOSTFor Local Development (ngrok):
Install and start ngrok to expose your local server:
# Install ngrok npm install -g ngrok # Start your Next.js dev server npm run dev # In another terminal, start ngrok ngrok http 3000Copy the ngrok HTTPS URL (e.g.,
https://abc123.ngrok.io) and update your Plivo number's Message URL tohttps://abc123.ngrok.io/api/sms/webhook.Important: Each time you restart ngrok, the URL changes (on free tier). Update your Plivo configuration accordingly or use a paid ngrok plan for a persistent domain.
Alternatives to ngrok:
4. Implementing the Webhook API Route to Receive SMS
Create a Next.js API route to receive and process Plivo webhooks for inbound SMS messages.
4.1 Create Webhook Endpoint (
src/app/api/sms/webhook/route.ts):import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; /** * Plivo Webhook Handler for Inbound SMS * * Plivo sends POST requests with these parameters: * - From: Sender's phone number * - To: Your Plivo number * - Text: Message content * - MessageUUID: Unique message identifier * - Type: Message type (usually "sms") */ export async function POST(request: NextRequest) { try { // Parse form data (Plivo sends application/x-www-form-urlencoded) const formData = await request.formData(); const fromNumber = formData.get('From') as string; const toNumber = formData.get('To') as string; const messageText = formData.get('Text') as string; const messageUuid = formData.get('MessageUUID') as string; console.log('📨 Inbound SMS received:', { fromNumber, toNumber, messageText, messageUuid }); // Validate required fields if (!fromNumber || !toNumber || !messageText) { console.error('❌ Missing required webhook parameters'); return new NextResponse('Bad Request', { status: 400 }); } // Check for duplicate messages using MessageUUID if (messageUuid) { const existing = await db.message.findUnique({ where: { messageUuid }, }); if (existing) { console.log('⚠️ Duplicate message detected, skipping'); return new NextResponse('OK', { status: 200 }); } } // Store message in database const message = await db.message.create({ data: { messageUuid, fromNumber, toNumber, messageText, direction: 'inbound', status: 'received', plivoResponse: Object.fromEntries(formData.entries()), }, }); console.log('✅ Message stored:', message.id); // Update or create conversation await db.conversation.upsert({ where: { phoneNumber: fromNumber }, update: { lastMessageAt: new Date(), messageCount: { increment: 1 }, }, create: { phoneNumber: fromNumber, lastMessageAt: new Date(), messageCount: 1, status: 'active', }, }); // Generate automated reply based on message content const replyText = generateAutoReply(messageText); // If automated reply needed, store outbound message if (replyText) { await db.message.create({ data: { fromNumber: toNumber, toNumber: fromNumber, messageText: replyText, direction: 'outbound', status: 'sent', }, }); // Return XML response to send automated reply const xmlResponse = `<?xml version="1.0" encoding="UTF-8"?> <Response> <Message src="${toNumber}" dst="${fromNumber}">${escapeXml(replyText)}</Message> </Response>`; return new NextResponse(xmlResponse, { status: 200, headers: { 'Content-Type': 'application/xml' }, }); } // No automated reply – just acknowledge receipt return new NextResponse('OK', { status: 200 }); } catch (error) { console.error('❌ Webhook processing error:', error); return new NextResponse('Internal Server Error', { status: 500 }); } } /** * Generate automated reply based on inbound message content */ function generateAutoReply(messageText: string): string | null { const text = messageText.toLowerCase().trim(); // Keyword-based automated responses if (text === 'hello' || text === 'hi') { return "Hello! Thanks for contacting us. How can we help you today?"; } if (text === 'status') { return "Your account status is active. Reply HELP for more options."; } if (text === 'help') { return "Available commands: STATUS, HOURS, STOP. Or describe your question and we'll respond shortly."; } if (text === 'hours') { return "We're open Monday–Friday, 9 AM–5 PM EST. We'll respond to your message during business hours."; } if (text === 'stop' || text === 'unsubscribe') { // Mark conversation as opt-out in database (implement separately) return "You've been unsubscribed from our messages. Reply START to re-subscribe."; } // No automated reply for other messages (manual response needed) return null; } /** * Escape XML special characters for safe inclusion in XML responses */ function escapeXml(unsafe: string): string { return unsafe .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }Key Implementation Details:
application/x-www-form-urlencodeddata, so userequest.formData()MessageUUIDto prevent duplicate processing during webhook retries<Message>element sends SMSupsert()to automatically update conversation metadataAuto-Reply Configuration: Extract
generateAutoReply()to a configuration file or database table for runtime updates without code deployment.Source: Plivo Blog – Receive SMS Node.js
5. Setting Up NextAuth for Admin Authentication
Configure NextAuth.js to secure your admin dashboard with session-based authentication.
5.1 Create Auth Configuration (
src/auth.ts):import NextAuth from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; import bcrypt from 'bcryptjs'; import { db } from '@/lib/db'; export const { handlers, signIn, signOut, auth } = NextAuth({ providers: [ Credentials({ credentials: { email: { label: 'Email', type: 'email' }, password: { label: 'Password', type: 'password' }, }, async authorize(credentials) { if (!credentials?.email || !credentials?.password) { return null; } const user = await db.user.findUnique({ where: { email: credentials.email as string }, }); if (!user) { return null; } const passwordValid = await bcrypt.compare( credentials.password as string, user.passwordHash ); if (!passwordValid) { return null; } return { id: user.id, email: user.email, name: user.name, }; }, }), ], pages: { signIn: '/login', }, session: { strategy: 'jwt', maxAge: 30 * 24 * 60 * 60, // 30 days }, callbacks: { async jwt({ token, user }) { if (user) { token.id = user.id; } return token; }, async session({ session, token }) { if (session.user) { session.user.id = token.id as string; } return session; }, }, });Security Hardening:
/api/auth/signinto prevent brute force attacks5.2 Create API Route Handlers (
src/app/api/auth/[...nextauth]/route.ts):import { handlers } from '@/auth'; export const { GET, POST } = handlers;5.3 Create Admin User Seed Script (
prisma/seed.ts):import { PrismaClient } from '@prisma/client'; import bcrypt from 'bcryptjs'; const prisma = new PrismaClient(); async function main() { const passwordHash = await bcrypt.hash('admin123', 10); const admin = await prisma.user.upsert({ where: { email: 'admin@example.com' }, update: {}, create: { email: 'admin@example.com', passwordHash, name: 'Admin User', role: 'admin', }, }); console.log('✅ Admin user created:', admin.email); console.log('⚠️ IMPORTANT: Change password immediately in production'); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); });⚠️ Production Security: Change the default password immediately after first deployment. Consider using environment variable for initial password or requiring password change on first login.
Update
package.json:{ "prisma": { "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" } }Run seed:
6. Building the Admin Dashboard for SMS Management
Create a protected admin interface to view conversations and send manual replies.
6.1 Create Middleware (
src/middleware.ts):import { auth } from '@/auth'; import { NextResponse } from 'next/server'; export default auth((req) => { const isLoggedIn = !!req.auth; const isAuthPage = req.nextUrl.pathname.startsWith('/login'); const isDashboard = req.nextUrl.pathname.startsWith('/dashboard'); if (isDashboard && !isLoggedIn) { return NextResponse.redirect(new URL('/login', req.url)); } if (isAuthPage && isLoggedIn) { return NextResponse.redirect(new URL('/dashboard/messages', req.url)); } return NextResponse.next(); }); export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], };Security Enhancement: Add CSRF protection using NextAuth's built-in CSRF tokens and configure security headers (CSP, X-Frame-Options, etc.) via
next.config.js.6.2 Create Messages Dashboard (
src/app/dashboard/messages/page.tsx):import { auth } from '@/auth'; import { db } from '@/lib/db'; import { redirect } from 'next/navigation'; import MessageList from './MessageList'; export default async function MessagesPage() { const session = await auth(); if (!session) { redirect('/login'); } // Fetch recent conversations with message counts const conversations = await db.conversation.findMany({ where: { status: 'active' }, orderBy: { lastMessageAt: 'desc' }, take: 50, }); // Fetch all messages for display const messages = await db.message.findMany({ orderBy: { createdAt: 'desc' }, take: 100, }); return ( <div className="container mx-auto p-6"> <h1 className="text-3xl font-bold mb-6">SMS Conversations</h1> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> {/* Conversation List */} <div className="lg:col-span-1 bg-white rounded-lg shadow p-4"> <h2 className="text-xl font-semibold mb-4">Active Conversations ({conversations.length})</h2> <div className="space-y-2"> {conversations.map((conv) => ( <a key={conv.id} href={`/dashboard/messages/${conv.phoneNumber}`} className="block p-3 border rounded hover:bg-gray-50" > <p className="font-medium">{conv.phoneNumber}</p> <p className="text-sm text-gray-600"> {conv.messageCount} messages • {new Date(conv.lastMessageAt).toLocaleDateString()} </p> </a> ))} </div> </div> {/* Message Thread */} <div className="lg:col-span-2 bg-white rounded-lg shadow p-4"> <h2 className="text-xl font-semibold mb-4">Recent Messages</h2> <MessageList messages={messages} /> </div> </div> </div> ); }Performance Optimization: For large conversation lists (1,000+), implement cursor-based pagination using Prisma's
cursorandskipoptions. Add search functionality with full-text search indexes onphoneNumberandmessageText.6.3 Create Message List Component (
src/app/dashboard/messages/MessageList.tsx):'use client'; import { Message } from '@prisma/client'; interface MessageListProps { messages: Message[]; } export default function MessageList({ messages }: MessageListProps) { return ( <div className="space-y-3 max-h-[600px] overflow-y-auto"> {messages.map((msg) => ( <div key={msg.id} className={`p-4 rounded-lg ${ msg.direction === 'inbound' ? 'bg-blue-50 ml-8' : 'bg-green-50 mr-8' }`} > <div className="flex justify-between text-sm text-gray-600 mb-1"> <span className="font-medium"> {msg.direction === 'inbound' ? msg.fromNumber : 'You'} </span> <span>{new Date(msg.createdAt).toLocaleString()}</span> </div> <p className="text-gray-900">{msg.messageText}</p> <span className={`text-xs ${ msg.status === 'received' || msg.status === 'sent' ? 'text-green-600' : 'text-gray-500' }`}> {msg.status} </span> </div> ))} </div> ); }Real-Time Updates: Add WebSocket support using
pusher-jsor Server-Sent Events (SSE) to automatically refresh message list when new messages arrive. Alternatively, implement polling withsetInterval()every 5–10 seconds.7. How to Send Manual SMS Replies in Next.js
Create an API route and form component for sending manual SMS replies from the dashboard.
7.1 Create Send SMS API Route (
src/app/api/sms/send/route.ts):import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/auth'; import { sendSMS } from '@/lib/plivo'; import { db } from '@/lib/db'; export async function POST(request: NextRequest) { try { const session = await auth(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const { to, text } = await request.json(); if (!to || !text) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } // Validate phone number format (basic E.164 check) if (!/^\+[1-9]\d{1,14}$/.test(to)) { return NextResponse.json({ error: 'Invalid phone number format' }, { status: 400 }); } // Check if recipient has opted out const conversation = await db.conversation.findUnique({ where: { phoneNumber: to }, }); if (conversation?.status === 'opted_out') { return NextResponse.json({ error: 'Recipient has opted out' }, { status: 403 }); } // Send SMS via Plivo const result = await sendSMS(to, text); if (!result.success) { return NextResponse.json({ error: 'Failed to send SMS' }, { status: 500 }); } // Store outbound message in database await db.message.create({ data: { fromNumber: process.env.PLIVO_PHONE_NUMBER!, toNumber: to, messageText: text, direction: 'outbound', status: 'sent', messageUuid: result.data?.message_uuid?.[0], }, }); // Update conversation await db.conversation.upsert({ where: { phoneNumber: to }, update: { lastMessageAt: new Date(), messageCount: { increment: 1 }, }, create: { phoneNumber: to, lastMessageAt: new Date(), messageCount: 1, }, }); return NextResponse.json({ success: true, data: result.data }); } catch (error) { console.error('❌ Send SMS error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } }Opt-Out Compliance: The route now checks
conversation.statusbefore sending to prevent messaging users who have opted out. This ensures TCPA/GDPR compliance.7.2 Create Reply Form Component (
src/app/dashboard/messages/[phone]/ReplyForm.tsx):'use client'; import { useState } from 'react'; interface ReplyFormProps { phoneNumber: string; } export default function ReplyForm({ phoneNumber }: ReplyFormProps) { const [message, setMessage] = useState(''); const [sending, setSending] = useState(false); const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle'); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setSending(true); setStatus('idle'); try { const response = await fetch('/api/sms/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to: phoneNumber, text: message }), }); if (response.ok) { setStatus('success'); setMessage(''); setTimeout(() => window.location.reload(), 1000); } else { setStatus('error'); } } catch (error) { setStatus('error'); } finally { setSending(false); } } return ( <form onSubmit={handleSubmit} className="mt-6"> <label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2"> Send Reply to {phoneNumber} </label> <textarea id="message" value={message} onChange={(e) => setMessage(e.target.value)} className="w-full border rounded-lg p-3 min-h-[100px]" placeholder="Type your message…" required disabled={sending} /> <div className="flex justify-between items-center mt-3"> <button type="submit" disabled={sending || !message.trim()} className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400" > {sending ? 'Sending…' : 'Send Reply'} </button> {status === 'success' && <span className="text-green-600">✓ Message sent</span>} {status === 'error' && <span className="text-red-600">✗ Failed to send</span>} </div> <p className="text-sm text-gray-500 mt-2"> Character count: {message.length} / 1600 </p> </form> ); }Enhancement Ideas: Add message templates dropdown, emoji picker, contact tagging, and scheduled sending for business hours.
8. Implementing Security Best Practices
Secure your two-way SMS system against common vulnerabilities and abuse.
8.1 Webhook Signature Verification:
Plivo signs webhook requests using HMAC-SHA256. Verify signatures to ensure webhooks come from Plivo.
Update
src/app/api/sms/webhook/route.ts:import crypto from 'crypto'; function verifyPlivoSignature( url: string, nonce: string, signature: string, authToken: string ): boolean { const uri = url.replace(/\/$/, ''); // Remove trailing slash const message = uri + nonce; const expectedSignature = crypto .createHmac('sha256', authToken) .update(message) .digest('base64'); // Use timing-safe comparison to prevent timing attacks return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) ); } export async function POST(request: NextRequest) { // Verify Plivo signature const signature = request.headers.get('X-Plivo-Signature-V3'); const nonce = request.headers.get('X-Plivo-Signature-V3-Nonce'); const url = request.url; if (signature && nonce) { const isValid = verifyPlivoSignature( url, nonce, signature, process.env.PLIVO_AUTH_TOKEN! ); if (!isValid) { console.error('❌ Invalid Plivo signature'); return new NextResponse('Forbidden', { status: 403 }); } } // ... rest of webhook handler }Source: Plivo Webhook Security – ngrok Documentation
8.2 Rate Limiting:
Implement rate limiting to prevent abuse of your SMS sending endpoints.
Install
@upstash/ratelimit:Update
src/app/api/sms/send/route.ts:import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(10, '1 h'), // 10 requests per hour analytics: true, }); export async function POST(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const identifier = session.user.id; const { success } = await ratelimit.limit(identifier); if (!success) { return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 }); } // ... rest of send logic }Configuration Guidelines:
8.3 Input Validation & Sanitization:
Always validate and sanitize user inputs, especially phone numbers and message text.
import { z } from 'zod'; const sendSmsSchema = z.object({ to: z.string().regex(/^\+[1-9]\d{1,14}$/, 'Invalid E.164 phone number'), text: z.string().min(1).max(1600, 'Message too long'), }); export async function POST(request: NextRequest) { // ... auth check const body = await request.json(); const validation = sendSmsSchema.safeParse(body); if (!validation.success) { return NextResponse.json( { error: 'Invalid input', details: validation.error.errors }, { status: 400 } ); } const { to, text } = validation.data; // ... send SMS }8.4 Environment Variable Protection:
Never expose sensitive credentials in client-side code or commit them to version control.
Update
.gitignore:Security Checklist:
.gitignore9. Testing Your Two-Way SMS System
Validate your implementation with comprehensive testing strategies.
9.1 Local Development Testing:
Start Next.js Dev Server:
Start ngrok Tunnel:
Update Plivo Webhook URL:
https://abc123.ngrok.io)https://abc123.ngrok.io/api/sms/webhookSend Test SMS:
Verify Database Storage:
Messagetable for inbound and outbound entriesConversationtable updatedWebhook Debugging Tips:
http://127.0.0.1:4040for webhook requests9.2 Webhook Payload Testing:
Use curl to simulate Plivo webhooks locally:
curl -X POST http://localhost:3000/api/sms/webhook \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "From=+14155551234" \ -d "To=+14155556789" \ -d "Text=status" \ -d "MessageUUID=test-uuid-123"9.3 Admin Dashboard Testing:
http://localhost:3000/loginadmin@example.com/admin123/dashboard/messagesUnit Test Example (Jest + Prisma):
import { generateAutoReply } from '@/app/api/sms/webhook/route'; describe('Auto-reply logic', () => { it('responds to HELP keyword', () => { expect(generateAutoReply('help')).toContain('Available commands'); }); it('returns null for unknown keywords', () => { expect(generateAutoReply('random text')).toBeNull(); }); });10. How to Deploy Your Two-Way SMS Application
Deploy your Next.js application to production with persistent webhook URLs.
10.1 Deploy to Vercel (Recommended):
# Install Vercel CLI npm i -g vercel # Deploy to Vercel vercel # Set production environment variables vercel env add PLIVO_AUTH_ID production vercel env add PLIVO_AUTH_TOKEN production vercel env add PLIVO_PHONE_NUMBER production vercel env add DATABASE_URL production vercel env add NEXTAUTH_SECRET production vercel env add NEXTAUTH_URL production # https://your-domain.vercel.appAlternative Hosting Platforms:
@netlify/plugin-nextjs10.2 Update Plivo Production Webhook:
After deployment:
https://your-app.vercel.app)https://your-app.vercel.app/api/sms/webhook10.3 Configure Production Database:
Use a managed PostgreSQL provider:
Zero-Downtime Deployment Strategy:
npx prisma migrate deployRun migrations on production database:
# Set DATABASE_URL to production export DATABASE_URL="your-production-db-url" # Run migrations npx prisma migrate deploy # Seed admin user npx prisma db seed10.4 Monitor Production Webhooks:
Check Plivo webhook logs:
Monitoring Tools:
Production Checklist:
Frequently Asked Questions
How do I test Plivo webhooks locally without ngrok?
While ngrok is the most common solution, alternatives include Cloudflare Tunnel (free persistent domains), localtunnel (
npm install -g localtunnel && lt --port 3000), or VS Code Port Forwarding (built-in for GitHub Codespaces). Cloudflare Tunnel offers better reliability for production testing with persistent URLs.What happens if my webhook endpoint returns an error?
Plivo retries failed webhooks up to 5 times with exponential backoff (1s, 2s, 4s, 8s, 16s). To prevent duplicate message processing, always return
200 OKimmediately upon receiving the webhook, then process the message asynchronously. Store theMessageUUIDto detect and ignore duplicate deliveries.How do I handle SMS delivery receipts (DLRs)?
Configure a Delivery URL in your Plivo number settings pointing to
/api/sms/delivery. Create a corresponding API route that updates yourMessagerecord's status field based on theStatusparameter:export async function POST(request: NextRequest) { const formData = await request.formData(); const messageUuid = formData.get('MessageUUID') as string; const status = formData.get('Status') as string; // delivered, failed, undelivered await db.message.update({ where: { messageUuid }, data: { status: status.toLowerCase() }, }); return new NextResponse('OK', { status: 200 }); }Can I send MMS (images) with Plivo in Next.js?
Yes. Use
plivoClient.messages.create()with amedia_urlsparameter containing an array of publicly accessible image URLs (HTTPS required). Plivo supports JPEG, PNG, and GIF formats up to 5 MB per MMS. Note that MMS pricing is 3–5× higher than SMS.How do I implement conversation threading by phone number?
Query messages with
db.message.findMany({ where: { fromNumber: phoneNumber }, orderBy: { createdAt: 'asc' } })to display a threaded conversation view. Use WebSocket or polling for real-time updates in the admin dashboard. TheConversationmodel aggregates metadata for efficient thread listing.What's the best way to handle opt-outs (STOP messages)?
When receiving "STOP", "UNSUBSCRIBE", or similar keywords, immediately update the
Conversationstatus to"opted_out"and cease sending messages to that number:await db.conversation.update({ where: { phoneNumber: fromNumber }, data: { status: 'opted_out' }, });Comply with TCPA (US) and GDPR (EU) by honoring opt-outs within seconds. Send confirmation: "You've been unsubscribed. Reply START to re-subscribe."
Compliance Requirements by Jurisdiction:
How do I secure my Plivo webhooks against spoofing?
Always verify Plivo's webhook signature using the
X-Plivo-Signature-V3header with HMAC-SHA256 validation (implementation shown in Section 8.1). Usecrypto.timingSafeEqual()for timing-safe comparison. Additionally, whitelist Plivo's IP ranges in your firewall or Vercel/Netlify security rules for defense-in-depth.Can I use Plivo with the Next.js Pages Router instead of App Router?
Yes. Convert API routes to the Pages Router format: create
pages/api/sms/webhook.tswithexport default function handler(req, res)instead of Next.js 13+ route handlers. NextAuth setup differs slightly—useNextAuth(authOptions)inpages/api/auth/[...nextauth].ts. Database and Plivo integration remain identical.How do I handle international phone numbers and country codes?
Always use E.164 format (
+[country code][number]) for phone numbers. Installlibphonenumber-jsfor robust validation:import { parsePhoneNumber } from 'libphonenumber-js'; const phone = parsePhoneNumber(userInput, 'US'); const e164 = phone.format('E.164'); // "+14155551234"Plivo supports 190+ countries with varying SMS pricing and regulations. Check country-specific carrier guidelines in the Plivo Console.
What's the cost of sending SMS with Plivo?
Plivo pricing varies by destination country. US/Canada SMS typically costs $0.0040–$0.0075 per message segment (160 characters). International rates range from $0.01–$0.50 per segment. Check current pricing at plivo.com/pricing.
Cost Estimation Examples:
Monitor usage via Plivo Console dashboard. Set spending limits to prevent unexpected bills. Volume discounts available for enterprise accounts (500K+ messages/month).