code examples
code examples
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.
Build Two-Way SMS in Next.js with Plivo, NextAuth & Webhooks
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
Source: Plivo Blog – Receive and Respond to SMS in Node.js
Project Overview and Architecture
What You'll Build:
- Next.js API route (
/api/sms/webhook) to receive Plivo webhooks - NextAuth session management for admin dashboard access
- Prisma schema for storing messages and conversations
- Admin UI to view conversations and send replies
- Automated response logic with XML reply generation
- ngrok integration for local development testing
Technologies Used:
- Next.js 14+: React framework with App Router (14.0.0+)
- NextAuth.js: Authentication library for securing admin routes (5.0.0-beta.4+)
- Plivo Node.js SDK: Official library for Plivo API integration (4.58.0+)
- Prisma: Type-safe ORM for PostgreSQL/MySQL (5.7.0+)
- TypeScript: Type safety throughout the application (5.3.0+)
- Tailwind CSS: Utility-first CSS framework for UI styling (3.4.0+)
Compatibility Note: Next.js 14+ requires Node.js 18.17 or higher. NextAuth v5 beta is required for App Router support.
System Architecture:
sequenceDiagram
participant User Phone
participant Plivo Network
participant Next.js Webhook API
participant Database (Prisma)
participant Admin Dashboard
participant NextAuth
User Phone->>Plivo Network: Sends SMS to your Plivo number
Plivo Network->>Next.js Webhook API: POST /api/sms/webhook (From, To, Text)
Next.js Webhook API->>Database (Prisma): Store inbound message
Next.js Webhook API->>Next.js Webhook API: Process message & generate reply
Next.js Webhook API-->>Plivo Network: Return XML response (auto-reply)
Plivo Network-->>User Phone: Delivers reply SMS
Admin Dashboard->>NextAuth: Request access to /dashboard/messages
NextAuth->>NextAuth: Verify session
NextAuth-->>Admin Dashboard: Grant access if authenticated
Admin Dashboard->>Database (Prisma): Fetch conversations
Database (Prisma)-->>Admin Dashboard: Return message history
Admin Dashboard->>Next.js Webhook API: Send manual reply via POST /api/sms/send
Next.js Webhook API->>Plivo Network: Send SMS via Plivo SDKPrerequisites:
- Node.js 18.17+ and npm/pnpm/yarn installed
- Plivo account with available SMS credits ($10 minimum)
- Plivo phone number capable of receiving SMS (from Plivo Console)
- PostgreSQL or MySQL database (local or hosted)
- ngrok account (free tier sufficient) for local webhook testing
- Intermediate knowledge of Next.js App Router, React Server Components, and TypeScript
Estimated Setup Time: 45–60 minutes for first-time implementation
Final Outcome:
- Your Plivo number receives SMS messages and triggers webhooks
- Next.js API route processes webhooks and stores messages in PostgreSQL
- Automated replies send immediately via XML response (< 3 seconds)
- Admin dashboard displays conversation threads with message history
- Authenticated admins send manual replies through the UI
- All conversations persist in database and remain searchable
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:
npm install plivo next-auth@beta prisma @prisma/client bcryptjs
npm install -D @types/bcryptjsPackage 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 definitions
Peer Dependency Note: next-auth@beta requires React 18+ and Next.js 14+. Ensure package.json includes "react": "^18.0.0" and "next": "^14.0.0".
1.3 Initialize Prisma:
npx prisma initThis creates:
prisma/schema.prisma: Database schema definition.env: Environment variables file (automatically added to.gitignore)
1.4 Configure Environment Variables:
Open .env and add your configuration:
# Database Connection (PostgreSQL example)
DATABASE_URL="postgresql://username:password@localhost:5432/plivo_sms_db"
# Plivo Credentials (from console.plivo.com)
PLIVO_AUTH_ID="your_auth_id_here"
PLIVO_AUTH_TOKEN="your_auth_token_here"
PLIVO_PHONE_NUMBER="+14155551234" # Your Plivo number in E.164 format
# NextAuth Configuration
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="generate-a-random-secret-string-here" # Run: openssl rand -base64 32
# ngrok Webhook URL (update after starting ngrok)
WEBHOOK_BASE_URL="https://your-ngrok-url.ngrok.io"How to Obtain Plivo Credentials:
- Log in to console.plivo.com
- Auth ID & Auth Token: Copy from main dashboard (Auth ID starts with "MA")
- Phone Number: Navigate to Phone Numbers → Your Numbers → Copy your SMS-enabled number in E.164 format
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:
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, 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:
npx prisma migrate dev --name init
npx prisma generateIf 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:
- Log in to console.plivo.com
- Navigate to Phone Numbers → Your Numbers
- Click on your SMS-enabled number
- 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:
# 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 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.
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:
- Content Type: Plivo sends
application/x-www-form-urlencodeddata, so userequest.formData() - Duplicate Detection: Check
MessageUUIDto prevent duplicate processing during webhook retries - XML Response Format: Return XML to trigger automated reply. Plivo's
<Message>element sends SMS - Database Storage: Store both inbound and outbound messages for complete conversation history
- Conversation Tracking: Use
upsert()to automatically update conversation metadata - Error Handling: Always return 200 OK to prevent Plivo retries (unless you want retries)
Auto-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:
- Session expires after 30 days of inactivity
- Passwords hashed with bcrypt (10 rounds)
- JWT strategy for stateless authentication
- Consider adding rate limiting on
/api/auth/signinto prevent brute force attacks
5.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:
npm install -D ts-node
npx prisma db seed6. 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 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):
'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-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):
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.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):
'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:
npm install @upstash/ratelimit @upstash/redisUpdate 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:
- Customer Support: 50 messages/hour per admin user
- Marketing Campaigns: 1,000 messages/hour with burst allowance
- Automated Replies: Unlimited (webhook-triggered)
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:
.env
.env.local
.env.productionSecurity Checklist:
- ✅ Webhook signature verification enabled
- ✅ Rate limiting configured per use case
- ✅ Input validation with Zod schemas
- ✅ Environment variables in
.gitignore - ✅ HTTPS enforced on all endpoints
- ✅ CSRF protection via NextAuth
- ✅ SQL injection prevention (Prisma ORM)
- ✅ XSS protection (React escapes by default)
9. Testing Your Two-Way SMS System
Validate your implementation with comprehensive testing strategies.
9.1 Local Development Testing:
-
Start Next.js Dev Server:
bashnpm run dev -
Start ngrok Tunnel:
bashngrok http 3000 -
Update Plivo Webhook URL:
- Copy ngrok HTTPS URL (e.g.,
https://abc123.ngrok.io) - Go to Plivo Console → Phone Numbers → Your Number
- Set Message URL:
https://abc123.ngrok.io/api/sms/webhook - Save changes
- Copy ngrok HTTPS URL (e.g.,
-
Send Test SMS:
- Use your mobile phone to send SMS to your Plivo number
- Message: "hello"
- Expected: Receive automated reply within seconds
-
Verify Database Storage:
bashnpx prisma studio- Check
Messagetable for inbound and outbound entries - Verify
Conversationtable updated
- Check
Webhook Debugging Tips:
- Check ngrok web interface at
http://127.0.0.1:4040for webhook requests - Verify Plivo signature headers present in ngrok logs
- Check Next.js console for processing errors
- Use Plivo Console → Logs → Message Logs to see delivery status
9.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:
- Navigate to
http://localhost:3000/login - Log in with seed admin credentials:
admin@example.com/admin123 - Verify redirect to
/dashboard/messages - Check conversation list displays correctly
- Test manual reply form
- Confirm sent message appears in database and recipient receives SMS
Unit 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: 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:
# 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:
- 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
- LogRocket: Session replay + performance monitoring
- Datadog: APM with Plivo webhook latency tracking
- Vercel Analytics: Built-in performance metrics
Production Checklist:
- ✅ 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:
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 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:
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:
- 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:
npm install libphonenumber-jsimport { 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:
- 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
How to send SMS with Node.js and Express?
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.
What is Vonage API used for in Node.js?
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.
Why use dotenv in Node.js SMS project?
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.
When should I validate phone numbers in my SMS app?
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.
Can I receive SMS messages with this Node.js setup?
This tutorial focuses solely on sending SMS messages. Receiving messages requires setting up webhooks and is covered in separate Vonage documentation.
How to set up a Node.js Express SMS API?
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.
What is the Vonage Virtual Number?
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.
Why separate SMS logic into lib.js?
Separating the Vonage interaction (lib.js) from the server logic (index.js) improves code organization, testability, and makes swapping services or adding features easier.
How to handle Vonage API errors in Node.js?
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.
When to use a database in Node.js SMS application?
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.
How to improve security of Node.js SMS API?
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.
What is E.164 phone number format?
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.
Why does SMS sending fail with "Non White-listed Destination"?
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.
How to deploy Node.js SMS app to production?
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.