code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Plivo

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:

mermaid
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 SDK

Prerequisites:

  • 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:

bash
npx create-next-app@latest plivo-two-way-sms --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd plivo-two-way-sms

This scaffolds a Next.js 14+ project with TypeScript, Tailwind CSS, ESLint, App Router, and a src/ directory structure.

1.2 Install Required Dependencies:

bash
npm install plivo next-auth@beta prisma @prisma/client bcryptjs
npm install -D @types/bcryptjs

Package Purposes:

  • plivo: Official Plivo Node.js SDK for sending SMS
  • next-auth@beta: NextAuth.js v5 (Auth.js) for authentication (App Router compatible)
  • prisma & @prisma/client: Database ORM and client
  • bcryptjs: 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:

bash
npx prisma init

This 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:

env
# 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:

  1. Log in to console.plivo.com
  2. Auth ID & Auth Token: Copy from main dashboard (Auth ID starts with "MA")
  3. Phone Number: Navigate to Phone Numbers → Your Numbers → Copy your SMS-enabled number in E.164 format

Generate NextAuth Secret:

bash
openssl rand -base64 32

Validate Environment Variables:

bash
# 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"; fi

1.5 Create Project Structure:

bash
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.ts

2. 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:

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"

2.3 Create Prisma Client Singleton (src/lib/db.ts):

typescript
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 = db

This 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):

typescript
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:

  1. Log in to console.plivo.com
  2. Navigate to Phone NumbersYour Numbers
  3. Click on your SMS-enabled number
  4. Find Message URL field under "Application" or "SMS Configuration"
  5. Set Message URL to: https://your-ngrok-url.ngrok.io/api/sms/webhook
  6. Set HTTP Method to: POST
  7. 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.

4.1 Create Webhook Endpoint (src/app/api/sms/webhook/route.ts):

typescript
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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}

Key Implementation Details:

  • Content Type: Plivo sends application/x-www-form-urlencoded data, so use request.formData()
  • Duplicate Detection: Check MessageUUID to 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):

typescript
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/signin to prevent brute force attacks

5.2 Create API Route Handlers (src/app/api/auth/[...nextauth]/route.ts):

typescript
import { handlers } from '@/auth';

export const { GET, POST } = handlers;

5.3 Create Admin User Seed Script (prisma/seed.ts):

typescript
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:

json
{
  "prisma": {
    "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
  }
}

Run seed:

bash
npm install -D ts-node
npx prisma db 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):

typescript
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):

typescript
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):

typescript
'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):

typescript
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):

typescript
'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:

typescript
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:

bash
npm install @upstash/ratelimit @upstash/redis

Update src/app/api/sms/send/route.ts:

typescript
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.

typescript
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:

gitignore
.env
.env.local
.env.production

Security 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:

  1. Start Next.js Dev Server:

    bash
    npm run dev
  2. Start ngrok Tunnel:

    bash
    ngrok http 3000
  3. 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
  4. Send Test SMS:

    • Use your mobile phone to send SMS to your Plivo number
    • Message: "hello"
    • Expected: Receive automated reply within seconds
  5. Verify Database Storage:

    bash
    npx prisma studio
    • Check Message table for inbound and outbound entries
    • Verify Conversation table updated

Webhook Debugging Tips:

  • Check ngrok web interface at http://127.0.0.1:4040 for 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:

bash
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:

  1. Navigate to http://localhost:3000/login
  2. Log in with seed admin credentials: admin@example.com / admin123
  3. Verify redirect to /dashboard/messages
  4. Check conversation list displays correctly
  5. Test manual reply form
  6. Confirm sent message appears in database and recipient receives SMS

Unit Test Example (Jest + Prisma):

typescript
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):

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:

  1. Note your Vercel deployment URL (e.g., https://your-app.vercel.app)
  2. Update Plivo Console → Phone Numbers → Your Number
  3. Set Message URL: https://your-app.vercel.app/api/sms/webhook
  4. Set HTTP Method: POST
  5. 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:

  1. Deploy new code to staging environment
  2. Run database migrations: npx prisma migrate deploy
  3. Test webhook endpoints with curl or Postman
  4. Promote to production
  5. Update Plivo webhook URL atomically

Run migrations on production database:

bash
# Set DATABASE_URL to production
export 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:

  1. Plivo Console → Logs → Message Logs
  2. Filter by your phone number
  3. 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:

typescript
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:

typescript
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:

bash
npm install libphonenumber-js
typescript
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:

  • 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.