code examples

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

Build WhatsApp and SMS Integration with Plivo, Next.js, and Supabase

Learn how to send and receive SMS and WhatsApp messages using Plivo API with Next.js and Supabase. Complete guide with webhooks, security, database integration, and production deployment.

Build WhatsApp and SMS Integration with Plivo, Next.js, and Supabase

Build a production-ready Next.js application with Supabase database integration to send and receive SMS and WhatsApp messages via the Plivo API. This guide covers project setup, Next.js API routes, Supabase database configuration, core messaging functionality, webhook handling, security best practices, error management, and deployment.

What Will You Build with This Guide?

Your Application's Capabilities:

Build a Next.js application with Supabase backend capable of:

  1. Sending outbound SMS and WhatsApp messages: Programmatically send messages to users via the Plivo API.
  2. Receiving inbound SMS and WhatsApp messages: Handle incoming messages sent to your Plivo number via webhooks.
  3. Tracking message status: Process delivery receipts and status updates from Plivo via webhooks.
  4. Persisting message data: Store all messages, statuses, and conversation history in Supabase PostgreSQL database.
  5. Real-time updates: Use Supabase real-time subscriptions for live message updates in the UI.

Problem Solved:

Build a robust foundation for integrating two-way SMS and WhatsApp communication into your Next.js applications with full database persistence, enabling customer notifications, alerts, two-factor authentication (2FA), customer support interactions, and complete message history tracking.

Technologies Used:

  • Next.js 14+: React framework with App Router for building the full-stack application with API routes and server/client components.
  • Supabase: Open-source Firebase alternative providing PostgreSQL database, authentication, real-time subscriptions, and storage.
  • Plivo API: SMS and voice API platform supporting SMS, MMS, and WhatsApp messaging channels.
  • Plivo Node.js SDK (plivo): Official SDK for simplified interaction with Plivo APIs.
  • TypeScript: Type-safe development environment for Next.js and API integrations.
  • Supabase JavaScript Client (@supabase/supabase-js): Client library for database operations and real-time features.
  • ngrok (for development): Exposes local development servers to the internet for webhook testing.

System Architecture:

┌─────────────────┐ Next.js Request ┌──────────────────────┐ Plivo API Call ┌─────────────────┐ │ Next.js Client │ ───────────────────────> │ Next.js API Routes │ ──────────────────────> │ Plivo Cloud │ │ (React UI) │ (Send Message Action) │ (/app/api/*) │ (Send SMS/WhatsApp) │ (Messages API) │ └─────────────────┘ └──────────────────────┘ └─────────────────┘ │ │ │ │ Real-time Subscription │ Database Write │ Delivery Status ↓ ↓ ↓ ┌─────────────────┐ Real-time Event ┌──────────────────────┐ Webhook POST ┌─────────────────┐ │ Supabase DB │ <─────────────────────── │ Next.js API Routes │ <───────────────────── │ User's Phone │ │ (PostgreSQL) │ (Message Updates) │ (Webhook Handlers) │ (Inbound Messages) │ (SMS/WhatsApp) │ └─────────────────┘ └──────────────────────┘ └─────────────────┘

Prerequisites:

  • Node.js 18+ and npm/yarn/pnpm: Installed on your system (Download Node.js)
  • Plivo Account: Sign up for trial credit (Plivo Signup). You'll need your Auth ID and Auth Token.
  • Plivo Phone Number: Purchase an SMS-enabled number from the Plivo Console. For WhatsApp Business API, contact Plivo for setup.
  • Supabase Account: Free account (Supabase Signup). Create a new project.
  • ngrok (for development): Installed and authenticated (Download ngrok)
  • Basic understanding of: Next.js 14+ App Router, React, TypeScript, API routes, SQL/PostgreSQL, and asynchronous JavaScript.

Version Requirements:

  • Node.js: 18.17.0 or higher (Next.js 14 requirement)
  • Next.js: 14.0.0 or higher (for App Router)
  • Plivo SDK: Latest version (5.x as of January 2025, verified at Plivo SDK Documentation)
  • Supabase JS: 2.39.0 or higher
  • TypeScript: 5.0 or higher

Final Outcome:

A fully functional Next.js application with Supabase database that can send and receive SMS/WhatsApp messages via Plivo API, persist all message data, provide real-time UI updates, handle webhooks securely, and is ready for production deployment on Vercel or similar platforms.


1. How Do You Set Up Your Next.js Project with Supabase?

Initialize a Next.js 14+ project with TypeScript, install Plivo and Supabase dependencies, and configure the project structure for API routes and database integration.

Step 1: Create Next.js Project

Create a new Next.js project with TypeScript and App Router:

bash
npx create-next-app@latest plivo-nextjs-supabase-messaging
# When prompted, select:
# ✓ Would you like to use TypeScript? Yes
# ✓ Would you like to use ESLint? Yes
# ✓ Would you like to use Tailwind CSS? Yes (recommended)
# ✓ Would you like to use `src/` directory? No
# ✓ Would you like to use App Router? Yes (recommended)
# ✓ Would you like to customize the default import alias? No

cd plivo-nextjs-supabase-messaging

Step 2: Install Dependencies

Install Plivo SDK, Supabase client, and additional utilities:

bash
npm install plivo @supabase/supabase-js
npm install -D @types/node
  • plivo: Official Plivo Node.js SDK for sending messages (NPM Package)
  • @supabase/supabase-js: Supabase JavaScript client for database operations and real-time features
  • @types/node: TypeScript definitions for Node.js APIs

Step 3: Create Project Structure

Create necessary directories and files for API routes and utilities:

bash
# Create API route directories
mkdir -p app/api/webhooks/plivo
mkdir -p app/api/messages

# Create utility directories
mkdir -p lib/plivo
mkdir -p lib/supabase
mkdir -p types

# Create utility files
touch lib/plivo/client.ts
touch lib/supabase/client.ts
touch lib/supabase/server.ts
touch types/messages.ts
touch .env.local

Your project structure should look like:

plivo-nextjs-supabase-messaging/ ├── app/ │ ├── api/ │ │ ├── messages/ │ │ │ └── send/ │ │ │ └── route.ts │ │ └── webhooks/ │ │ └── plivo/ │ │ ├── inbound/ │ │ │ └── route.ts │ │ └── status/ │ │ └── route.ts │ ├── layout.tsx │ └── page.tsx ├── lib/ │ ├── plivo/ │ │ └── client.ts │ └── supabase/ │ ├── client.ts │ └── server.ts ├── types/ │ └── messages.ts ├── .env.local ├── .gitignore ├── next.config.js ├── package.json └── tsconfig.json

Step 4: Configure .gitignore

Update .gitignore to exclude sensitive files (Next.js template includes most of these):

text
# .gitignore
.env*.local
.env
node_modules/
.next/
out/
*.log
.DS_Store

Next.js creates .env*.local pattern automatically. Ensure .env.local is never committed.

Step 5: Set Up Environment Variables

Open .env.local and add your Plivo and Supabase credentials:

bash
# .env.local

# Plivo Credentials (from https://console.plivo.com/dashboard/)
PLIVO_AUTH_ID=your_plivo_auth_id
PLIVO_AUTH_TOKEN=your_plivo_auth_token

# Plivo Phone Numbers
PLIVO_SMS_FROM_NUMBER=+12015550123  # Your purchased Plivo number (E.164 format)
PLIVO_WHATSAPP_NUMBER=+14155550100  # Your Plivo WhatsApp Business number (if enabled)

# Supabase Credentials (from https://supabase.com/dashboard/project/_/settings/api)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_public_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_secret_key

# Application Settings
NEXT_PUBLIC_APP_URL=http://localhost:3000  # Update for production
NODE_ENV=development

Where to find these values:

  • Plivo Auth ID & Token: Login to Plivo Console → Dashboard displays them prominently
  • Plivo Phone Number: Console → Phone Numbers → Buy a number or use existing
  • Supabase URL & Keys: Supabase Dashboard → Project Settings → API
    • NEXT_PUBLIC_SUPABASE_URL: Project URL
    • NEXT_PUBLIC_SUPABASE_ANON_KEY: anon public key (safe for client-side)
    • SUPABASE_SERVICE_ROLE_KEY: service_role secret key (server-only, bypasses RLS)

Security Note:

  • NEXT_PUBLIC_* variables are exposed to the browser. Only use for public keys.
  • SUPABASE_SERVICE_ROLE_KEY must NEVER be exposed to the client. Only use in API routes.
  • Store production credentials in your hosting platform's environment variables (Vercel, Railway, etc.).

Step 6: Create .env.example for Team Collaboration

Create a template file for other developers:

bash
# .env.example
PLIVO_AUTH_ID=
PLIVO_AUTH_TOKEN=
PLIVO_SMS_FROM_NUMBER=
PLIVO_WHATSAPP_NUMBER=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_APP_URL=http://localhost:3000
NODE_ENV=development

2. How Do You Configure Supabase Database Schema?

Create the database tables to store messages, conversations, and delivery statuses using Supabase SQL Editor.

Step 1: Design Database Schema

Create three main tables:

  1. conversations: Track conversation threads
  2. messages: Store all SMS/WhatsApp messages
  3. message_status: Track delivery status updates

Step 2: Execute SQL in Supabase

  1. Open your Supabase project dashboard
  2. Navigate to SQL Editor in the left sidebar
  3. Click New Query
  4. Copy and paste the following SQL:
sql
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- Conversations table
CREATE TABLE IF NOT EXISTS conversations (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    phone_number TEXT NOT NULL UNIQUE,
    channel TEXT NOT NULL CHECK (channel IN ('sms', 'whatsapp')),
    display_name TEXT,
    last_message_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    metadata JSONB DEFAULT '{}'::jsonb
);

-- Messages table
CREATE TABLE IF NOT EXISTS messages (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
    plivo_message_uuid TEXT UNIQUE,
    direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
    channel TEXT NOT NULL CHECK (channel IN ('sms', 'whatsapp')),
    from_number TEXT NOT NULL,
    to_number TEXT NOT NULL,
    message_text TEXT,
    media_urls JSONB DEFAULT '[]'::jsonb,
    status TEXT DEFAULT 'queued',
    error_message TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    delivered_at TIMESTAMPTZ,
    read_at TIMESTAMPTZ,
    metadata JSONB DEFAULT '{}'::jsonb
);

-- Message status history table
CREATE TABLE IF NOT EXISTS message_status (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
    plivo_message_uuid TEXT NOT NULL,
    status TEXT NOT NULL,
    error_code TEXT,
    error_message TEXT,
    timestamp TIMESTAMPTZ DEFAULT NOW(),
    raw_payload JSONB
);

-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_conversations_phone ON conversations(phone_number);
CREATE INDEX IF NOT EXISTS idx_conversations_updated ON conversations(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_messages_plivo_uuid ON messages(plivo_message_uuid);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_message_status_message ON message_status(message_id);
CREATE INDEX IF NOT EXISTS idx_message_status_uuid ON message_status(plivo_message_uuid);

-- Function to update conversation timestamp
CREATE OR REPLACE FUNCTION update_conversation_timestamp()
RETURNS TRIGGER AS $$
BEGIN
    UPDATE conversations
    SET
        last_message_at = NEW.created_at,
        updated_at = NOW()
    WHERE id = NEW.conversation_id;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Trigger to auto-update conversation on new message
CREATE TRIGGER trigger_update_conversation_timestamp
AFTER INSERT ON messages
FOR EACH ROW
EXECUTE FUNCTION update_conversation_timestamp();

-- Row Level Security (RLS) policies
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE message_status ENABLE ROW LEVEL SECURITY;

-- Allow service role to bypass RLS (for API routes)
-- For authenticated users, add appropriate policies based on your auth setup

-- Example: Allow service role full access
CREATE POLICY "Service role can do everything on conversations"
ON conversations FOR ALL
TO service_role
USING (true)
WITH CHECK (true);

CREATE POLICY "Service role can do everything on messages"
ON messages FOR ALL
TO service_role
USING (true)
WITH CHECK (true);

CREATE POLICY "Service role can do everything on message_status"
ON message_status FOR ALL
TO service_role
USING (true)
WITH CHECK (true);
  1. Click Run to execute the schema

Step 3: Verify Schema Creation

  1. Navigate to Table Editor in Supabase Dashboard
  2. Verify you see three tables: conversations, messages, message_status
  3. Check the Indexes tab to confirm indexes were created
  4. Test the trigger by inserting a test row (you can delete it after)

Schema Explanation:

  • conversations: Groups messages by phone number and channel
  • messages: Stores complete message content and metadata
  • message_status: Tracks status changes (queued → sent → delivered → failed)
  • Trigger: Automatically updates conversations.last_message_at when new messages arrive
  • RLS: Row Level Security enabled for production safety (currently allows service_role full access)

References:


3. How Do You Initialize Plivo and Supabase Clients?

Create reusable client instances for Plivo API and Supabase database access.

Step 1: Create Plivo Client (lib/plivo/client.ts)

typescript
// lib/plivo/client.ts
import plivo from 'plivo';

if (!process.env.PLIVO_AUTH_ID || !process.env.PLIVO_AUTH_TOKEN) {
  throw new Error(
    'Missing Plivo credentials. Set PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN in .env.local'
  );
}

// Initialize Plivo client with auth credentials
export const plivoClient = new plivo.Client(
  process.env.PLIVO_AUTH_ID,
  process.env.PLIVO_AUTH_TOKEN
);

// Export configuration constants
export const PLIVO_SMS_FROM = process.env.PLIVO_SMS_FROM_NUMBER || '';
export const PLIVO_WHATSAPP_FROM = process.env.PLIVO_WHATSAPP_NUMBER || '';

// Validate phone numbers are configured
if (!PLIVO_SMS_FROM) {
  console.warn('Warning: PLIVO_SMS_FROM_NUMBER not configured');
}

Plivo SDK Authentication: The Plivo Node.js SDK uses HTTP Basic Authentication with Auth ID and Auth Token (Plivo Authentication Docs). These credentials are available in your Plivo Console dashboard.

Step 2: Create Supabase Server Client (lib/supabase/server.ts)

typescript
// lib/supabase/server.ts
import { createClient } from '@supabase/supabase-js';
import type { Database } from '@/types/database';

if (!process.env.NEXT_PUBLIC_SUPABASE_URL) {
  throw new Error('Missing NEXT_PUBLIC_SUPABASE_URL');
}

if (!process.env.SUPABASE_SERVICE_ROLE_KEY) {
  throw new Error('Missing SUPABASE_SERVICE_ROLE_KEY');
}

// Server-side client with service_role key (bypasses RLS)
// Use this ONLY in API routes, never expose to client
export const supabaseServer = createClient<Database>(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY,
  {
    auth: {
      autoRefreshToken: false,
      persistSession: false
    }
  }
);

Service Role Key Usage: The service_role key bypasses Row Level Security (RLS) and should only be used in API routes where you control access logic. Never expose this key to the browser (Supabase Service Key Docs).

Step 3: Create Supabase Browser Client (lib/supabase/client.ts)

typescript
// lib/supabase/client.ts
import { createClient } from '@supabase/supabase-js';
import type { Database } from '@/types/database';

if (!process.env.NEXT_PUBLIC_SUPABASE_URL) {
  throw new Error('Missing NEXT_PUBLIC_SUPABASE_URL');
}

if (!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
  throw new Error('Missing NEXT_PUBLIC_SUPABASE_ANON_KEY');
}

// Client-side Supabase instance (safe for browser)
// Uses anon key which respects RLS policies
export const supabaseBrowser = createClient<Database>(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
  {
    auth: {
      persistSession: true,
      autoRefreshToken: true
    }
  }
);

Step 4: Create TypeScript Types (types/messages.ts)

typescript
// types/messages.ts

export type MessageDirection = 'inbound' | 'outbound';
export type MessageChannel = 'sms' | 'whatsapp';
export type MessageStatus = 'queued' | 'sent' | 'delivered' | 'failed' | 'undelivered';

export interface SendMessageRequest {
  to: string;
  text: string;
  channel: MessageChannel;
  mediaUrls?: string[];
}

export interface SendMessageResponse {
  success: boolean;
  messageUuid?: string;
  error?: string;
}

export interface PlivoInboundWebhook {
  MessageUUID: string;
  From: string;
  To: string;
  Text?: string;
  Type: 'sms' | 'whatsapp';
  Media?: string;
}

export interface PlivoStatusWebhook {
  MessageUUID: string;
  From: string;
  To: string;
  Status: string;
  ErrorCode?: string;
  ErrorReason?: string;
}

export interface DBMessage {
  id: string;
  conversation_id: string;
  plivo_message_uuid: string | null;
  direction: MessageDirection;
  channel: MessageChannel;
  from_number: string;
  to_number: string;
  message_text: string | null;
  media_urls: string[];
  status: string;
  error_message: string | null;
  created_at: string;
  delivered_at: string | null;
  read_at: string | null;
  metadata: Record<string, any>;
}

Step 5: Generate Supabase Types (Optional but Recommended)

Generate TypeScript types from your Supabase schema:

bash
npx supabase gen types typescript --project-id your-project-ref > types/database.ts

Replace your-project-ref with your project reference ID from the Supabase dashboard URL.

This creates type-safe database queries (Supabase Type Generation Docs).


4. How Do You Send SMS and WhatsApp Messages?

Implement a Next.js API route to send messages via Plivo and persist them to Supabase.

Step 1: Create Send Message API Route (app/api/messages/send/route.ts)

typescript
// app/api/messages/send/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { plivoClient, PLIVO_SMS_FROM, PLIVO_WHATSAPP_FROM } from '@/lib/plivo/client';
import { supabaseServer } from '@/lib/supabase/server';
import type { SendMessageRequest, SendMessageResponse } from '@/types/messages';

export async function POST(request: NextRequest) {
  try {
    const body: SendMessageRequest = await request.json();
    const { to, text, channel, mediaUrls = [] } = body;

    // Validate input
    if (!to || !text) {
      return NextResponse.json(
        { success: false, error: 'Missing required fields: to, text' },
        { status: 400 }
      );
    }

    // Validate phone number format (basic E.164 check)
    if (!/^\+[1-9]\d{1,14}$/.test(to)) {
      return NextResponse.json(
        { success: false, error: 'Invalid phone number format. Use E.164 format (e.g., +12015550123)' },
        { status: 400 }
      );
    }

    // Select appropriate from number based on channel
    const fromNumber = channel === 'whatsapp' ? PLIVO_WHATSAPP_FROM : PLIVO_SMS_FROM;

    if (!fromNumber) {
      return NextResponse.json(
        { success: false, error: `No ${channel} number configured` },
        { status: 500 }
      );
    }

    // Find or create conversation
    const { data: existingConversation } = await supabaseServer
      .from('conversations')
      .select('id')
      .eq('phone_number', to)
      .eq('channel', channel)
      .single();

    let conversationId: string;

    if (existingConversation) {
      conversationId = existingConversation.id;
    } else {
      const { data: newConversation, error: convError } = await supabaseServer
        .from('conversations')
        .insert({
          phone_number: to,
          channel,
          last_message_at: new Date().toISOString()
        })
        .select('id')
        .single();

      if (convError || !newConversation) {
        console.error('Failed to create conversation:', convError);
        return NextResponse.json(
          { success: false, error: 'Database error creating conversation' },
          { status: 500 }
        );
      }

      conversationId = newConversation.id;
    }

    // Send message via Plivo
    let plivoResponse;

    if (channel === 'sms') {
      plivoResponse = await plivoClient.messages.create({
        src: fromNumber,
        dst: to,
        text: text,
        type: 'sms',
        ...(mediaUrls.length > 0 && { media_urls: mediaUrls, type: 'mms' })
      });
    } else if (channel === 'whatsapp') {
      plivoResponse = await plivoClient.messages.create({
        src: fromNumber,
        dst: to,
        text: text,
        type: 'whatsapp',
        ...(mediaUrls.length > 0 && { media_urls: mediaUrls })
      });
    } else {
      return NextResponse.json(
        { success: false, error: 'Invalid channel. Must be "sms" or "whatsapp"' },
        { status: 400 }
      );
    }

    // Check Plivo response
    if (!plivoResponse || plivoResponse.apiId === undefined) {
      console.error('Invalid Plivo response:', plivoResponse);
      return NextResponse.json(
        { success: false, error: 'Failed to send message via Plivo' },
        { status: 500 }
      );
    }

    const messageUuid = plivoResponse.messageUuid[0];

    // Store message in database
    const { error: messageError } = await supabaseServer
      .from('messages')
      .insert({
        conversation_id: conversationId,
        plivo_message_uuid: messageUuid,
        direction: 'outbound',
        channel,
        from_number: fromNumber,
        to_number: to,
        message_text: text,
        media_urls: mediaUrls,
        status: 'queued',
        metadata: {
          plivo_api_id: plivoResponse.apiId,
          units: plivoResponse.units
        }
      });

    if (messageError) {
      console.error('Failed to store message:', messageError);
      // Message was sent but not stored – log this critical issue
      // Consider implementing a retry mechanism or dead letter queue
    }

    return NextResponse.json({
      success: true,
      messageUuid
    } as SendMessageResponse);

  } catch (error: any) {
    console.error('Error sending message:', error);

    // Parse Plivo API errors
    if (error.response?.data) {
      return NextResponse.json(
        {
          success: false,
          error: error.response.data.error || 'Plivo API error',
          details: error.response.data
        },
        { status: error.response.status || 500 }
      );
    }

    return NextResponse.json(
      { success: false, error: 'Internal server error' },
      { status: 500 }
    );
  }
}

API Endpoint Details:

  • Endpoint: POST /api/messages/send
  • Authentication: Currently open – add your auth middleware as needed
  • Rate Limiting: Consider adding rate limiting for production (see Plivo rate limits: Plivo API Rate Limits)

Plivo Message Object: The SDK returns messageUuid array and apiId for tracking (Plivo Send Message API).

E.164 Format: International phone number format required by Plivo: +[country code][number] (e.g., +12015550123 for US). No spaces, dashes, or parentheses (E.164 Wikipedia).

Step 2: Test the Send Message API

Create a simple test script or use the Next.js API route tester:

bash
curl -X POST http://localhost:3000/api/messages/send \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+12015550199",
    "text": "Hello from Plivo + Next.js!",
    "channel": "sms"
  }'

Expected response:

json
{
  "success": true,
  "messageUuid": "5b40daa0-7629-4e2e-b93c-c9b3e6f3f3f3"
}

5. How Do You Handle Inbound Messages?

Create webhook endpoints to receive incoming SMS and WhatsApp messages from Plivo and store them in Supabase.

Step 1: Create Inbound Webhook Route (app/api/webhooks/plivo/inbound/route.ts)

typescript
// app/api/webhooks/plivo/inbound/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { supabaseServer } from '@/lib/supabase/server';
import type { PlivoInboundWebhook } from '@/types/messages';

export async function POST(request: NextRequest) {
  try {
    // Plivo sends webhooks as application/x-www-form-urlencoded
    const formData = await request.formData();

    // Extract webhook data
    const webhookData: PlivoInboundWebhook = {
      MessageUUID: formData.get('MessageUUID') as string,
      From: formData.get('From') as string,
      To: formData.get('To') as string,
      Text: formData.get('Text') as string || '',
      Type: (formData.get('Type') as 'sms' | 'whatsapp') || 'sms',
      Media: formData.get('Media') as string
    };

    console.log('[Inbound Webhook] Received:', {
      uuid: webhookData.MessageUUID,
      from: webhookData.From,
      type: webhookData.Type
    });

    // Validate required fields
    if (!webhookData.MessageUUID || !webhookData.From || !webhookData.To) {
      console.error('[Inbound Webhook] Missing required fields');
      return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
    }

    // Parse media URLs if present
    const mediaUrls = webhookData.Media
      ? webhookData.Media.split(',').map(url => url.trim())
      : [];

    // Determine channel
    const channel = webhookData.Type === 'whatsapp' ? 'whatsapp' : 'sms';

    // Find or create conversation
    const { data: existingConversation } = await supabaseServer
      .from('conversations')
      .select('id')
      .eq('phone_number', webhookData.From)
      .eq('channel', channel)
      .single();

    let conversationId: string;

    if (existingConversation) {
      conversationId = existingConversation.id;
    } else {
      const { data: newConversation, error: convError } = await supabaseServer
        .from('conversations')
        .insert({
          phone_number: webhookData.From,
          channel,
          last_message_at: new Date().toISOString()
        })
        .select('id')
        .single();

      if (convError || !newConversation) {
        console.error('[Inbound Webhook] Failed to create conversation:', convError);
        return NextResponse.json({ error: 'Database error' }, { status: 500 });
      }

      conversationId = newConversation.id;
    }

    // Check for duplicate message (idempotency)
    const { data: existingMessage } = await supabaseServer
      .from('messages')
      .select('id')
      .eq('plivo_message_uuid', webhookData.MessageUUID)
      .single();

    if (existingMessage) {
      console.log('[Inbound Webhook] Duplicate message ignored:', webhookData.MessageUUID);
      return NextResponse.json({ status: 'duplicate' }, { status: 200 });
    }

    // Store inbound message
    const { error: messageError } = await supabaseServer
      .from('messages')
      .insert({
        conversation_id: conversationId,
        plivo_message_uuid: webhookData.MessageUUID,
        direction: 'inbound',
        channel,
        from_number: webhookData.From,
        to_number: webhookData.To,
        message_text: webhookData.Text,
        media_urls: mediaUrls,
        status: 'received',
        metadata: {
          webhook_received_at: new Date().toISOString()
        }
      });

    if (messageError) {
      console.error('[Inbound Webhook] Failed to store message:', messageError);
      return NextResponse.json({ error: 'Database error' }, { status: 500 });
    }

    console.log('[Inbound Webhook] Message stored successfully');

    // Return 200 OK to acknowledge receipt to Plivo
    return NextResponse.json({ status: 'received' }, { status: 200 });

  } catch (error) {
    console.error('[Inbound Webhook] Error:', error);
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}

Webhook Payload Format: Plivo sends inbound webhooks as application/x-www-form-urlencoded POST requests (Plivo Inbound Message Webhook). Key fields include:

  • MessageUUID: Unique message identifier
  • From: Sender's phone number (E.164)
  • To: Your Plivo number that received the message
  • Text: Message content
  • Type: Message type (sms or whatsapp)

Idempotency: Plivo may retry webhooks if they don't receive a 200 response within 5 seconds. The code checks for duplicate MessageUUID to prevent double-processing (Plivo Webhook Retry Policy).

Step 2: Create Status Update Webhook Route (app/api/webhooks/plivo/status/route.ts)

typescript
// app/api/webhooks/plivo/status/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { supabaseServer } from '@/lib/supabase/server';
import type { PlivoStatusWebhook } from '@/types/messages';

export async function POST(request: NextRequest) {
  try {
    // Plivo sends status updates as application/x-www-form-urlencoded
    const formData = await request.formData();

    const webhookData: PlivoStatusWebhook = {
      MessageUUID: formData.get('MessageUUID') as string,
      From: formData.get('From') as string,
      To: formData.get('To') as string,
      Status: formData.get('Status') as string,
      ErrorCode: formData.get('ErrorCode') as string,
      ErrorReason: formData.get('ErrorReason') as string
    };

    console.log('[Status Webhook] Received:', {
      uuid: webhookData.MessageUUID,
      status: webhookData.Status
    });

    if (!webhookData.MessageUUID || !webhookData.Status) {
      console.error('[Status Webhook] Missing required fields');
      return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
    }

    // Map Plivo status to our status enum
    const statusMap: Record<string, string> = {
      'queued': 'queued',
      'sent': 'sent',
      'delivered': 'delivered',
      'undelivered': 'undelivered',
      'failed': 'failed',
      'rejected': 'failed'
    };

    const mappedStatus = statusMap[webhookData.Status.toLowerCase()] || webhookData.Status;

    // Update message status
    const { error: updateError } = await supabaseServer
      .from('messages')
      .update({
        status: mappedStatus,
        ...(webhookData.Status === 'delivered' && { delivered_at: new Date().toISOString() }),
        ...(webhookData.ErrorReason && { error_message: webhookData.ErrorReason })
      })
      .eq('plivo_message_uuid', webhookData.MessageUUID);

    if (updateError) {
      console.error('[Status Webhook] Failed to update message:', updateError);
      return NextResponse.json({ error: 'Database error' }, { status: 500 });
    }

    // Store status history
    const { error: historyError } = await supabaseServer
      .from('message_status')
      .insert({
        plivo_message_uuid: webhookData.MessageUUID,
        status: mappedStatus,
        error_code: webhookData.ErrorCode || null,
        error_message: webhookData.ErrorReason || null,
        raw_payload: Object.fromEntries(formData.entries())
      });

    if (historyError) {
      console.error('[Status Webhook] Failed to store status history:', historyError);
      // Non-critical – message was updated successfully
    }

    console.log('[Status Webhook] Status updated successfully');

    return NextResponse.json({ status: 'processed' }, { status: 200 });

  } catch (error) {
    console.error('[Status Webhook] Error:', error);
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}

Plivo Status Values: Possible status values in webhooks (Plivo Message Status):

  • queued: Message queued for delivery
  • sent: Message sent to carrier
  • delivered: Message delivered to recipient
  • undelivered: Delivery failed after retries
  • failed: Immediate failure (invalid number, etc.)
  • rejected: Rejected by carrier

Step 3: Configure Webhook URLs in Plivo Console

  1. Login to Plivo Console
  2. Navigate to MessagingApplications
  3. Create a new application or edit existing one
  4. Set webhook URLs:
    • Message URL: https://your-domain.com/api/webhooks/plivo/inbound (POST)
    • Default Number URL: Same as above (for catch-all)
    • Message Method: POST
  5. For status updates, go to AccountMessaging Settings
  6. Set Message Status Callback URL: https://your-domain.com/api/webhooks/plivo/status (POST)

Development Testing with ngrok:

bash
# In a separate terminal, expose your Next.js dev server
ngrok http 3000

# Copy the HTTPS URL (e.g., https://abcd1234.ngrok-free.app)
# Use this URL in Plivo Console:
# - Inbound: https://abcd1234.ngrok-free.app/api/webhooks/plivo/inbound
# - Status: https://abcd1234.ngrok-free.app/api/webhooks/plivo/status

Webhook Security: For production, implement signature verification using Plivo's auth token to validate webhooks are genuinely from Plivo (Plivo Webhook Signature Validation).


6. How Do You Implement Webhook Security?

Validate that webhook requests genuinely originate from Plivo using signature verification.

Step 1: Install Crypto Utilities

Next.js includes Node.js crypto module by default. No additional installation needed.

Step 2: Create Signature Verification Utility

typescript
// lib/plivo/verifySignature.ts
import crypto from 'crypto';

/**
 * Verify Plivo webhook signature
 * Documentation: https://www.plivo.com/docs/sms/api/message#validating-signatures
 *
 * @param authToken - Your Plivo Auth Token from environment variables
 * @param url - Full webhook URL including protocol, domain, and path
 * @param params - Object containing all webhook parameters
 * @param signature - X-Plivo-Signature header value
 * @returns true if signature is valid
 */
export function verifyPlivoSignature(
  authToken: string,
  url: string,
  params: Record<string, string>,
  signature: string
): boolean {
  try {
    // Sort parameters alphabetically by key
    const sortedKeys = Object.keys(params).sort();

    // Concatenate URL with sorted parameters
    const baseString = url + sortedKeys.map(key => `${key}${params[key]}`).join('');

    // Create HMAC-SHA256 hash
    const hmac = crypto.createHmac('sha256', authToken);
    hmac.update(baseString);
    const calculatedSignature = hmac.digest('base64');

    // Compare signatures (constant-time comparison to prevent timing attacks)
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(calculatedSignature)
    );
  } catch (error) {
    console.error('[Signature Verification] Error:', error);
    return false;
  }
}

Signature Algorithm: Plivo uses HMAC-SHA256 with your Auth Token as the secret key. The signature is computed from the webhook URL concatenated with sorted parameters (Plivo Signature Validation).

Step 3: Add Verification Middleware

Create a middleware function to verify signatures on webhook routes:

typescript
// lib/plivo/webhookMiddleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyPlivoSignature } from './verifySignature';

export function withPlivoSignature(
  handler: (request: NextRequest) => Promise<NextResponse>
) {
  return async (request: NextRequest) => {
    // Skip verification in development (optional)
    if (process.env.NODE_ENV === 'development' && process.env.SKIP_WEBHOOK_VERIFICATION === 'true') {
      console.warn('[Webhook] Skipping signature verification (development mode)');
      return handler(request);
    }

    // Get signature from header
    const signature = request.headers.get('X-Plivo-Signature-V2') ||
                     request.headers.get('X-Plivo-Signature');

    if (!signature) {
      console.error('[Webhook] Missing Plivo signature header');
      return NextResponse.json(
        { error: 'Unauthorized: Missing signature' },
        { status: 401 }
      );
    }

    // Get full URL
    const url = new URL(request.url);
    const fullUrl = `${url.protocol}//${url.host}${url.pathname}`;

    // Parse form data
    const formData = await request.formData();
    const params: Record<string, string> = {};
    formData.forEach((value, key) => {
      params[key] = value.toString();
    });

    // Verify signature
    const authToken = process.env.PLIVO_AUTH_TOKEN;
    if (!authToken) {
      console.error('[Webhook] Missing PLIVO_AUTH_TOKEN');
      return NextResponse.json(
        { error: 'Server configuration error' },
        { status: 500 }
      );
    }

    const isValid = verifyPlivoSignature(authToken, fullUrl, params, signature);

    if (!isValid) {
      console.error('[Webhook] Invalid signature');
      return NextResponse.json(
        { error: 'Unauthorized: Invalid signature' },
        { status: 401 }
      );
    }

    // Signature valid – proceed to handler
    return handler(request);
  };
}

Step 4: Apply Middleware to Webhook Routes

Update your webhook routes to use the middleware:

typescript
// app/api/webhooks/plivo/inbound/route.ts
import { withPlivoSignature } from '@/lib/plivo/webhookMiddleware';
// ... rest of imports

async function handleInbound(request: NextRequest) {
  // Your existing webhook handler code
  // ... (same as before)
}

export const POST = withPlivoSignature(handleInbound);
typescript
// app/api/webhooks/plivo/status/route.ts
import { withPlivoSignature } from '@/lib/plivo/webhookMiddleware';
// ... rest of imports

async function handleStatus(request: NextRequest) {
  // Your existing webhook handler code
  // ... (same as before)
}

export const POST = withPlivoSignature(handleStatus);

Security Best Practices:

  1. Always verify signatures in production – prevents spoofed webhooks
  2. Use HTTPS only – Plivo requires HTTPS for webhooks in production
  3. Implement idempotency – check for duplicate MessageUUID to handle retries
  4. Rate limiting – add rate limiting to prevent abuse (consider using Vercel's built-in rate limiting or Upstash Redis)
  5. IP whitelisting (optional) – Plivo publishes their webhook IP ranges (Plivo IP Addresses)

Step 5: Environment Variable for Development

Add to .env.local for easier development:

bash
# .env.local
SKIP_WEBHOOK_VERIFICATION=true  # Only for development! Remove in production

7. How Do You Build a Real-Time Messaging UI?

Create React components with Supabase real-time subscriptions to display messages.

Step 1: Create Message List Component

typescript
// app/components/MessageList.tsx
'use client';

import { useEffect, useState } from 'react';
import { supabaseBrowser } from '@/lib/supabase/client';
import type { DBMessage } from '@/types/messages';

interface MessageListProps {
  conversationId: string;
}

export default function MessageList({ conversationId }: MessageListProps) {
  const [messages, setMessages] = useState<DBMessage[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Load initial messages
    loadMessages();

    // Subscribe to real-time updates
    const channel = supabaseBrowser
      .channel(`messages:${conversationId}`)
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'messages',
          filter: `conversation_id=eq.${conversationId}`
        },
        (payload) => {
          console.log('[Real-time] New message:', payload.new);
          setMessages(prev => [...prev, payload.new as DBMessage]);
        }
      )
      .on(
        'postgres_changes',
        {
          event: 'UPDATE',
          schema: 'public',
          table: 'messages',
          filter: `conversation_id=eq.${conversationId}`
        },
        (payload) => {
          console.log('[Real-time] Message updated:', payload.new);
          setMessages(prev =>
            prev.map(msg =>
              msg.id === payload.new.id ? (payload.new as DBMessage) : msg
            )
          );
        }
      )
      .subscribe();

    return () => {
      supabaseBrowser.removeChannel(channel);
    };
  }, [conversationId]);

  async function loadMessages() {
    setLoading(true);
    const { data, error } = await supabaseBrowser
      .from('messages')
      .select('*')
      .eq('conversation_id', conversationId)
      .order('created_at', { ascending: true });

    if (error) {
      console.error('Error loading messages:', error);
    } else {
      setMessages(data || []);
    }
    setLoading(false);
  }

  if (loading) {
    return <div className="p-4 text-center">Loading messages...</div>;
  }

  return (
    <div className="flex flex-col space-y-2 p-4">
      {messages.map(msg => (
        <div
          key={msg.id}
          className={`flex ${msg.direction === 'outbound' ? 'justify-end' : 'justify-start'}`}
        >
          <div
            className={`max-w-xs rounded-lg px-4 py-2 ${
              msg.direction === 'outbound'
                ? 'bg-blue-500 text-white'
                : 'bg-gray-200 text-gray-900'
            }`}
          >
            <p className="text-sm">{msg.message_text}</p>
            <div className="mt-1 flex items-center justify-between text-xs opacity-75">
              <span>{new Date(msg.created_at).toLocaleTimeString()}</span>
              {msg.direction === 'outbound' && (
                <span className="ml-2">{getStatusIcon(msg.status)}</span>
              )}
            </div>
          </div>
        </div>
      ))}
    </div>
  );
}

function getStatusIcon(status: string): string {
  switch (status) {
    case 'delivered':
      return '✓✓';
    case 'sent':
      return '✓';
    case 'failed':
      return '✗';
    default:
      return '○';
  }
}

Supabase Real-time: This component uses Supabase's real-time subscriptions to listen for database changes (Supabase Realtime Documentation). When a new message is inserted or updated, the UI automatically updates without polling.

Real-time Filters: The filter parameter uses PostgREST syntax to only receive events for the specific conversation (PostgREST Filtering).

Step 2: Create Send Message Form Component

typescript
// app/components/SendMessageForm.tsx
'use client';

import { useState } from 'react';
import type { MessageChannel } from '@/types/messages';

interface SendMessageFormProps {
  phoneNumber: string;
  channel: MessageChannel;
  onMessageSent?: () => void;
}

export default function SendMessageForm({ phoneNumber, channel, onMessageSent }: SendMessageFormProps) {
  const [message, setMessage] = useState('');
  const [sending, setSending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    if (!message.trim()) return;

    setSending(true);
    setError(null);

    try {
      const response = await fetch('/api/messages/send', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          to: phoneNumber,
          text: message,
          channel
        })
      });

      const data = await response.json();

      if (!response.ok || !data.success) {
        throw new Error(data.error || 'Failed to send message');
      }

      setMessage('');
      onMessageSent?.();
    } catch (err: any) {
      console.error('Error sending message:', err);
      setError(err.message);
    } finally {
      setSending(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="border-t p-4">
      {error && (
        <div className="mb-2 rounded bg-red-100 p-2 text-sm text-red-700">
          {error}
        </div>
      )}
      <div className="flex space-x-2">
        <input
          type="text"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          placeholder={`Send ${channel === 'whatsapp' ? 'WhatsApp' : 'SMS'} message...`}
          className="flex-1 rounded-lg border px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
          disabled={sending}
        />
        <button
          type="submit"
          disabled={sending || !message.trim()}
          className="rounded-lg bg-blue-500 px-6 py-2 text-white hover:bg-blue-600 disabled:opacity-50"
        >
          {sending ? 'Sending...' : 'Send'}
        </button>
      </div>
    </form>
  );
}

Step 3: Create Conversation Page

typescript
// app/messages/[phone]/page.tsx
import { supabaseServer } from '@/lib/supabase/server';
import MessageList from '@/app/components/MessageList';
import SendMessageForm from '@/app/components/SendMessageForm';
import { notFound } from 'next/navigation';

export default async function ConversationPage({
  params
}: {
  params: { phone: string }
}) {
  // Decode phone number from URL
  const phoneNumber = decodeURIComponent(params.phone);

  // Fetch or create conversation
  const { data: conversation } = await supabaseServer
    .from('conversations')
    .select('*')
    .eq('phone_number', phoneNumber)
    .single();

  if (!conversation) {
    notFound();
  }

  return (
    <div className="flex h-screen flex-col">
      <div className="border-b p-4">
        <h1 className="text-lg font-semibold">{conversation.display_name || phoneNumber}</h1>
        <p className="text-sm text-gray-500">
          {conversation.channel === 'whatsapp' ? 'WhatsApp' : 'SMS'}
        </p>
      </div>

      <div className="flex-1 overflow-y-auto">
        <MessageList conversationId={conversation.id} />
      </div>

      <SendMessageForm
        phoneNumber={phoneNumber}
        channel={conversation.channel}
      />
    </div>
  );
}

Server Components: This page uses Next.js Server Components to fetch initial conversation data server-side, then client components (MessageList, SendMessageForm) handle interactivity (Next.js Server Components).


8. How Do You Test the Complete Flow?

Test the end-to-end messaging system locally before production deployment.

Step 1: Start Development Environment

bash
# Terminal 1: Start Next.js dev server
npm run dev

# Terminal 2: Start ngrok tunnel
ngrok http 3000

Step 2: Configure Plivo Webhooks

  1. Copy your ngrok HTTPS URL (e.g., https://abc123.ngrok-free.app)
  2. Login to Plivo Console
  3. Navigate to MessagingApplications
  4. Set webhook URLs:
    • Message URL: https://abc123.ngrok-free.app/api/webhooks/plivo/inbound
    • Status Callback: Set in Account → Messaging Settings to https://abc123.ngrok-free.app/api/webhooks/plivo/status

Step 3: Test Outbound Message

Using curl or Postman:

bash
curl -X POST http://localhost:3000/api/messages/send \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+1YOUR_TEST_NUMBER",
    "text": "Test message from Plivo + Next.js + Supabase!",
    "channel": "sms"
  }'

Expected Flow:

  1. API route sends message via Plivo
  2. Message stored in Supabase messages table
  3. Plivo delivers message to recipient's phone
  4. Status webhook updates message status to "delivered"
  5. Real-time subscription updates UI (if viewing conversation)

Step 4: Test Inbound Message

  1. Send an SMS to your Plivo number from your phone
  2. Check Next.js console for webhook logs
  3. Verify message appears in Supabase messages table
  4. Check UI updates in real-time (if viewing conversation)

Step 5: Verify Database

Open Supabase Dashboard → Table Editor:

sql
-- Check recent messages
SELECT * FROM messages ORDER BY created_at DESC LIMIT 10;

-- Check message status history
SELECT * FROM message_status ORDER BY timestamp DESC LIMIT 10;

-- Check conversations
SELECT * FROM conversations ORDER BY last_message_at DESC;

Common Issues:

  1. Webhook not received: Check ngrok is running, URL configured correctly in Plivo Console
  2. Signature verification fails: Ensure PLIVO_AUTH_TOKEN matches your Plivo account
  3. Message not in database: Check Supabase service role key, check console logs for errors
  4. Real-time not updating: Verify Supabase real-time is enabled in Dashboard → Settings → API

9. How Do You Deploy to Production?

Deploy your Next.js application to Vercel with production-ready configurations.

Step 1: Prepare for Deployment

Update environment variables for production in .env.local (don't commit this file):

bash
# Production URLs
NEXT_PUBLIC_APP_URL=https://your-app.vercel.app

Step 2: Deploy to Vercel

bash
# Install Vercel CLI
npm i -g vercel

# Login to Vercel
vercel login

# Deploy
vercel

Follow prompts to link your GitHub/GitLab repository for automatic deployments.

Step 3: Configure Environment Variables in Vercel

  1. Go to Vercel Dashboard → Your Project → Settings → Environment Variables

  2. Add all variables from .env.local:

    • PLIVO_AUTH_ID
    • PLIVO_AUTH_TOKEN
    • PLIVO_SMS_FROM_NUMBER
    • PLIVO_WHATSAPP_NUMBER
    • NEXT_PUBLIC_SUPABASE_URL
    • NEXT_PUBLIC_SUPABASE_ANON_KEY
    • SUPABASE_SERVICE_ROLE_KEY
    • NEXT_PUBLIC_APP_URL (your Vercel URL)
    • NODE_ENV=production
  3. Important: Do NOT set SKIP_WEBHOOK_VERIFICATION in production

Step 4: Update Plivo Webhook URLs

After deployment, update Plivo Console with production URLs:

  • Inbound: https://your-app.vercel.app/api/webhooks/plivo/inbound
  • Status: https://your-app.vercel.app/api/webhooks/plivo/status

Step 5: Production Checklist

  • Environment variables configured in Vercel
  • Webhook signature verification enabled (remove SKIP_WEBHOOK_VERIFICATION)
  • Plivo webhook URLs updated with production domain
  • Supabase RLS policies configured for your authentication setup
  • Rate limiting implemented (consider Upstash Rate Limiting)
  • Error monitoring configured (Sentry, LogRocket, etc.)
  • SSL/HTTPS enabled (automatic with Vercel)
  • Custom domain configured (optional)

Production Monitoring:

  • Vercel Logs: Dashboard → Your Project → Logs
  • Supabase Logs: Dashboard → Logs
  • Plivo Logs: Console → Logs → Message Logs

Cost Considerations:

  • Plivo: Pay-per-message pricing varies by country (Plivo Pricing)
    • US SMS: ~$0.0075/message
    • WhatsApp: ~$0.005-0.02/message depending on region
  • Supabase: Free tier includes 500 MB database, 2 GB bandwidth (Supabase Pricing)
  • Vercel: Free tier includes 100 GB bandwidth, commercial projects require Pro (Vercel Pricing)

10. How Do You Implement Advanced Features?

10.1 Message Templates for WhatsApp

WhatsApp requires pre-approved templates for outbound messages outside 24-hour window (WhatsApp Business Templates):

typescript
// lib/plivo/templates.ts
export async function sendWhatsAppTemplate(
  to: string,
  templateName: string,
  params: string[]
) {
  const response = await plivoClient.messages.create({
    src: PLIVO_WHATSAPP_FROM,
    dst: to,
    type: 'whatsapp',
    template: {
      name: templateName,
      language: 'en_US',
      components: [
        {
          type: 'body',
          parameters: params.map(p => ({ type: 'text', text: p }))
        }
      ]
    }
  });
  return response;
}

10.2 Media Messaging (MMS)

Send images, videos, or documents:

typescript
// In your send message API route
if (mediaUrls.length > 0) {
  await plivoClient.messages.create({
    src: PLIVO_SMS_FROM,
    dst: to,
    text: text || '', // Required even for media-only
    type: 'mms',
    media_urls: mediaUrls, // Array of public URLs
    media_ids: [] // Or use uploaded media IDs
  });
}

Media file requirements (Plivo MMS Documentation):

  • Max size: 5 MB
  • Supported formats: JPEG, PNG, GIF, MP4, PDF
  • URLs must be publicly accessible

10.3 Rate Limiting

Implement rate limiting to prevent abuse:

bash
npm install @upstash/ratelimit @upstash/redis
typescript
// lib/rateLimit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_URL!,
  token: process.env.UPSTASH_REDIS_TOKEN!
});

// 10 requests per 60 seconds per IP
export const rateLimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '60 s'),
  analytics: true
});

10.4 Message Queuing for Bulk Sends

For high-volume messaging, implement a queue system:

bash
npm install bullmq ioredis
typescript
// lib/queue/messageQueue.ts
import { Queue, Worker } from 'bullmq';
import { Redis } from 'ioredis';

const connection = new Redis(process.env.REDIS_URL!);

export const messageQueue = new Queue('messages', { connection });

// Worker to process queue
new Worker('messages', async job => {
  const { to, text, channel } = job.data;
  // Send message via Plivo
  await plivoClient.messages.create({ ... });
}, { connection });

10.5 Analytics and Reporting

Track message metrics:

sql
-- Message delivery rate
SELECT
  channel,
  COUNT(*) as total_sent,
  SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) as delivered,
  ROUND(SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END)::numeric / COUNT(*) * 100, 2) as delivery_rate
FROM messages
WHERE direction = 'outbound'
GROUP BY channel;

-- Average delivery time
SELECT
  AVG(EXTRACT(EPOCH FROM (delivered_at - created_at))) as avg_delivery_seconds
FROM messages
WHERE delivered_at IS NOT NULL;

11. What Are Common Issues and Solutions?

Issue 1: Messages Not Delivering

Symptoms: Messages stuck in "queued" or "sent" status

Solutions:

  • Verify phone number format (E.164: +12015550123)
  • Check Plivo Console → Logs for error details
  • Ensure sufficient account balance
  • Verify carrier-specific restrictions (some carriers block short codes)

Issue 2: Webhooks Not Received

Symptoms: Inbound messages or status updates not appearing in database

Solutions:

  • Verify webhook URL in Plivo Console matches your deployment
  • Check webhook endpoint is publicly accessible (test with curl)
  • Ensure endpoint returns 200 status within 5 seconds (Plivo timeout)
  • Check signature verification isn't failing (temporarily disable in dev)
  • Verify ngrok tunnel is active for local development

Issue 3: Real-Time Updates Not Working

Symptoms: UI doesn't update when new messages arrive

Solutions:

  • Enable Supabase Realtime: Dashboard → Settings → API → Realtime → Enable
  • Check browser console for WebSocket errors
  • Verify RLS policies allow authenticated users to SELECT
  • Ensure channel subscription filter matches conversation ID

Issue 4: Duplicate Messages

Symptoms: Same message appears multiple times in database

Solutions:

  • Implement idempotency check using plivo_message_uuid
  • Handle webhook retries properly (return 200 even if already processed)
  • Add unique constraint on messages.plivo_message_uuid

Issue 5: WhatsApp Messages Failing

Symptoms: WhatsApp messages fail while SMS works

Solutions:

  • Verify recipient opted-in to receive WhatsApp messages (required by Meta)
  • Check 24-hour session window – use templates for outbound messages outside window
  • Ensure WhatsApp Business API is enabled on Plivo account (contact support)
  • Verify WhatsApp number is correctly configured in environment variables

Debugging Tools:

  1. Plivo Console Logs: Real-time message status and error details
  2. Supabase SQL Editor: Query database directly to verify data
  3. ngrok Web Interface: Inspect webhook payloads at http://127.0.0.1:4040
  4. Browser DevTools: Network tab for API requests, Console for errors

12. How Do You Ensure Security and Compliance?

12.1 Data Security

Encryption:

  • Supabase encrypts data at rest using AES-256 (Supabase Security)
  • All API communication uses TLS 1.2+ (enforced by Next.js and Vercel)
  • Store sensitive credentials in environment variables, never in code

Access Control:

sql
-- Example RLS policy for authenticated users
CREATE POLICY "Users can view their own conversations"
ON conversations FOR SELECT
TO authenticated
USING (auth.uid() = user_id);

CREATE POLICY "Users can view their own messages"
ON messages FOR SELECT
TO authenticated
USING (
  conversation_id IN (
    SELECT id FROM conversations WHERE user_id = auth.uid()
  )
);

12.2 Regulatory Compliance

TCPA (US): Telephone Consumer Protection Act requires:

  • Prior express written consent before sending marketing messages
  • Honor opt-out requests within 24 hours
  • Include opt-out instructions in messages (e.g., "Reply STOP to unsubscribe")

Implementation:

typescript
// lib/optOut.ts
export async function handleOptOut(phoneNumber: string) {
  await supabaseServer
    .from('conversations')
    .update({ opted_out: true, opted_out_at: new Date().toISOString() })
    .eq('phone_number', phoneNumber);
}

// Check before sending
const { data: conversation } = await supabaseServer
  .from('conversations')
  .select('opted_out')
  .eq('phone_number', to)
  .single();

if (conversation?.opted_out) {
  throw new Error('Recipient has opted out');
}

GDPR (EU): General Data Protection Regulation requires:

  • Right to access: Users can request all stored data
  • Right to erasure: Delete user data on request
  • Data minimization: Only store necessary information
  • Consent management: Track and respect user preferences

CASL (Canada): Similar to TCPA, requires consent and opt-out mechanism.

Reference: Plivo Compliance Guide

12.3 Security Checklist

  • Webhook signature verification enabled
  • Environment variables stored securely (never committed to git)
  • Row Level Security (RLS) policies configured
  • Rate limiting implemented on API routes
  • Input validation on all user inputs (phone numbers, message text)
  • SQL injection prevention (using Supabase parameterized queries)
  • XSS prevention (React escapes output by default)
  • CORS configured correctly (Next.js defaults are secure)
  • Audit logging for sensitive operations
  • Regular security updates (npm audit, dependabot)

Summary

You've built a complete WhatsApp and SMS integration using:

  • Plivo API for reliable message delivery
  • Next.js 14 with App Router for modern full-stack development
  • Supabase for PostgreSQL database and real-time subscriptions
  • TypeScript for type-safe code
  • Webhook security with signature verification
  • Real-time UI with Supabase subscriptions

Key Capabilities:

  • ✅ Send SMS and WhatsApp messages programmatically
  • ✅ Receive inbound messages via webhooks
  • ✅ Track delivery status in real-time
  • ✅ Store complete message history in database
  • ✅ Real-time UI updates without polling
  • ✅ Production-ready security and error handling

Next Steps:

  1. Implement user authentication (NextAuth.js + Supabase)
  2. Add message templates and scheduling
  3. Build analytics dashboard
  4. Implement bulk messaging with queues
  5. Add multi-language support
  6. Create admin panel for message management

Resources:

Support:

Frequently Asked Questions

How to send WhatsApp messages with Node.js and Express

Use the Vonage Messages API and the Node.js SDK. After initializing the SDK, call `vonage.messages.send()` with the recipient's WhatsApp number, your Vonage WhatsApp Sandbox number (for testing) or dedicated number, and the message text. Ensure the recipient has opted into your Sandbox if using it for testing.

What is the Vonage Messages API?

The Vonage Messages API is a unified interface for sending and receiving messages across SMS, MMS, WhatsApp, Viber, and Facebook Messenger. This guide demonstrates sending SMS and WhatsApp messages using the API.

Why does Vonage require a 200 OK response for webhooks?

A 200 OK response acknowledges successful webhook receipt. Without it, Vonage assumes delivery failure and retries, potentially leading to duplicate message processing in your application. This is crucial for avoiding unintended actions or data inconsistencies based on repeated webhook invocations.

When should I use ngrok for Vonage webhooks?

Use ngrok during local development to expose your local server and test webhook functionality. ngrok creates a secure tunnel, providing a public HTTPS URL for Vonage to send webhooks to while you are developing and testing locally.

Can I send WhatsApp messages outside the 24-hour window?

Sending freeform WhatsApp messages beyond the 24-hour customer care window typically requires pre-approved templates from Vonage/WhatsApp Business. The customer care window is 24 hours from the user's last interaction with your WhatsApp account or last received message.

How to set up a Node.js project for Vonage messaging

Initialize a Node project, install Express, the Vonage Server SDK, and dotenv. Create `index.js`, `.env`, and `.gitignore` files. Set up your `.env` file to store API credentials and configuration. Then, initialize Express and the Vonage SDK in `index.js`.

What is the purpose of a Vonage Application ID?

The Vonage Application ID acts as a container for your Vonage project's configuration, numbers, and security credentials, including your private key. It's generated when you create an application in the Vonage Dashboard.

Why store Vonage private key outside project directory?

Storing the Vonage private key outside the project directory is a crucial security practice to prevent accidental exposure via version control systems like Git. While convenient for local development to have it directly in the project, placing it elsewhere avoids the risk of committing sensitive information.

How to handle inbound messages from Vonage

Create a webhook endpoint (e.g., `/webhooks/inbound`) in your Express app to handle incoming message requests. Vonage will send POST requests to this endpoint whenever a message is sent to your Vonage number. Your endpoint should parse the request body for message details.

What is the VONAGE_PRIVATE_KEY_CONTENT environment variable used for?

The `VONAGE_PRIVATE_KEY_CONTENT` environment variable is recommended for storing your Vonage private key content, especially in production environments. This provides enhanced security compared to using file paths, as the key material is stored directly within a secure environment instead of a file.

How to receive message status updates from Vonage

Set up a webhook endpoint (e.g., `/webhooks/status`) in your Express app. Vonage sends POST requests to this endpoint with delivery status updates for messages sent via the API. Extract the status information from the request body.

What are the prerequisites for Vonage WhatsApp integration

You need a Vonage API account, a Vonage virtual number (for SMS), access to the Vonage WhatsApp Sandbox (for testing), Node.js and npm installed, and a basic understanding of Node.js, Express, and APIs.

How to link a Vonage number to an application

In the Vonage Dashboard, go to Numbers -> Your Numbers. Click "Link" next to the desired number, select your Vonage Application from the dropdown list, and confirm. This routes messages to the correct application.

What is the role of dotenv in a Vonage application?

Dotenv manages environment variables, loading them from a `.env` file into `process.env`. This is useful for securely storing sensitive information, like API keys and configuration, keeping them separate from your codebase.