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 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:
Sending outbound SMS and WhatsApp messages: Programmatically send messages to users via the Plivo API.
Receiving inbound SMS and WhatsApp messages: Handle incoming messages sent to your Plivo number via webhooks.
Tracking message status: Process delivery receipts and status updates from Plivo via webhooks.
Persisting message data: Store all messages, statuses, and conversation history in Supabase PostgreSQL database.
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.
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? Nocd plivo-nextjs-supabase-messaging
Step 2: Install Dependencies
Install Plivo SDK, Supabase client, and additional utilities:
Create the database tables to store messages, conversations, and delivery statuses using Supabase SQL Editor.
Step 1: Design Database Schema
Create three main tables:
conversations: Track conversation threads
messages: Store all SMS/WhatsApp messages
message_status: Track delivery status updates
Step 2: Execute SQL in Supabase
Open your Supabase project dashboard
Navigate to SQL Editor in the left sidebar
Click New Query
Copy and paste the following SQL:
SQL
-- Enable UUID extensionCREATE EXTENSION IF NOTEXISTS "uuid-ossp";
-- Conversations tableCREATE TABLE IF NOTEXISTS conversations (
id UUID PRIMARY KEYDEFAULT uuid_generate_v4(),
phone_number TEXT NOT NULLUNIQUE,
channel TEXT NOT NULLCHECK (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 tableCREATE TABLE IF NOTEXISTS messages (
id UUID PRIMARY KEYDEFAULT uuid_generate_v4(),
conversation_id UUID REFERENCES conversations(id) ONDELETE CASCADE,
plivo_message_uuid TEXT UNIQUE,
direction TEXT NOT NULLCHECK (direction IN ('inbound', 'outbound')),
channel TEXT NOT NULLCHECK (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 tableCREATE TABLE IF NOTEXISTS message_status (
id UUID PRIMARY KEYDEFAULT uuid_generate_v4(),
message_id UUID REFERENCES messages(id) ONDELETE 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 performanceCREATE INDEX IF NOTEXISTS idx_conversations_phone ON conversations(phone_number);
CREATE INDEX IF NOTEXISTS idx_conversations_updated ON conversations(updated_at DESC);
CREATE INDEX IF NOTEXISTS idx_messages_conversation ON messages(conversation_id);
CREATE INDEX IF NOTEXISTS idx_messages_plivo_uuid ON messages(plivo_message_uuid);
CREATE INDEX IF NOTEXISTS idx_messages_created ON messages(created_at DESC);
CREATE INDEX IF NOTEXISTS idx_message_status_message ON message_status(message_id);
CREATE INDEX IF NOTEXISTS idx_message_status_uuid ON message_status(plivo_message_uuid);
-- Function to update conversation timestampCREATEOR REPLACE FUNCTION update_conversation_timestamp()
RETURNSTRIGGERAS $$
BEGINUPDATE conversations
SET
last_message_at = NEW.created_at,
updated_at = NOW()
WHERE id = NEW.conversation_id;
RETURNNEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to auto-update conversation on new messageCREATETRIGGER trigger_update_conversation_timestamp
AFTER INSERTON messages
FOREACHROWEXECUTEFUNCTION update_conversation_timestamp();
-- Row Level Security (RLS) policiesALTER 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 accessCREATE POLICY "Service role can do everything on conversations"
ON conversations FORALLTO service_role
USING (true)
WITHCHECK (true);
CREATE POLICY "Service role can do everything on messages"
ON messages FORALLTO service_role
USING (true)
WITHCHECK (true);
CREATE POLICY "Service role can do everything on message_status"
ON message_status FORALLTO service_role
USING (true)
WITHCHECK (true);
Click Run to execute the schema
Step 3: Verify Schema Creation
Navigate to Table Editor in Supabase Dashboard
Verify you see three tables: conversations, messages, message_status
Check the Indexes tab to confirm indexes were created
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)
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.tsimport plivo from'plivo';
if (!process.env.PLIVO_AUTH_ID || !process.env.PLIVO_AUTH_TOKEN) {
thrownewError(
'Missing Plivo credentials. Set PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN in .env.local'
);
}
// Initialize Plivo client with auth credentialsexportconst plivoClient = new plivo.Client(
process.env.PLIVO_AUTH_ID,
process.env.PLIVO_AUTH_TOKEN
);
// Export configuration constantsexportconstPLIVO_SMS_FROM = process.env.PLIVO_SMS_FROM_NUMBER || '';
exportconstPLIVO_WHATSAPP_FROM = process.env.PLIVO_WHATSAPP_NUMBER || '';
// Validate phone numbers are configuredif (!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.tsimport { createClient } from'@supabase/supabase-js';
importtype { Database } from'@/types/database';
if (!process.env.NEXT_PUBLIC_SUPABASE_URL) {
thrownewError('Missing NEXT_PUBLIC_SUPABASE_URL');
}
if (!process.env.SUPABASE_SERVICE_ROLE_KEY) {
thrownewError('Missing SUPABASE_SERVICE_ROLE_KEY');
}
// Server-side client with service_role key (bypasses RLS)// Use this ONLY in API routes, never expose to clientexportconst 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).
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:
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)
For status updates, go to Account → Messaging Settings
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.
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:
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).
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
Copy your ngrok HTTPS URL (e.g., https://abc123.ngrok-free.app)
// In your send message API routeif (mediaUrls.length > 0) {
await plivoClient.messages.create({
src: PLIVO_SMS_FROM,
dst: to,
text: text || '', // Required even for media-onlytype: 'mms',
media_urls: mediaUrls, // Array of public URLsmedia_ids: [] // Or use uploaded media IDs
});
}
-- Message delivery rateSELECT
channel,
COUNT(*) as total_sent,
SUM(CASEWHEN status ='delivered'THEN1ELSE0END) as delivered,
ROUND(SUM(CASEWHEN status ='delivered'THEN1ELSE0END)::numeric/COUNT(*) *100, 2) as delivery_rate
FROM messages
WHERE direction ='outbound'GROUPBY channel;
-- Average delivery timeSELECTAVG(EXTRACT(EPOCH FROM (delivered_at - created_at))) as avg_delivery_seconds
FROM messages
WHERE delivered_at ISNOT 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
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 usersCREATE POLICY "Users can view their own conversations"
ON conversations FORSELECTTO authenticated
USING (auth.uid() = user_id);
CREATE POLICY "Users can view their own messages"
ON messages FORSELECTTO authenticated
USING (
conversation_id IN (
SELECT id FROM conversations WHERE user_id = auth.uid()
)
);
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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:
plivo): Official SDK for simplified interaction with Plivo APIs.@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:
Prerequisites:
ngrok(for development): Installed and authenticated (Download ngrok)Version Requirements:
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:
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-messagingStep 2: Install Dependencies
Install Plivo SDK, Supabase client, and additional utilities:
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 APIsStep 3: Create Project Structure
Create necessary directories and files for API routes and utilities:
# 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.localYour project structure should look like:
Step 4: Configure
.gitignoreUpdate
.gitignoreto exclude sensitive files (Next.js template includes most of these):Next.js creates
.env*.localpattern automatically. Ensure.env.localis never committed.Step 5: Set Up Environment Variables
Open
.env.localand add your Plivo and Supabase credentials:# .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=developmentWhere to find these values:
NEXT_PUBLIC_SUPABASE_URL: Project URLNEXT_PUBLIC_SUPABASE_ANON_KEY:anonpublickey (safe for client-side)SUPABASE_SERVICE_ROLE_KEY:service_rolesecretkey (server-only, bypasses RLS)Security Note:
NEXT_PUBLIC_*variables are exposed to the browser. Only use for public keys.SUPABASE_SERVICE_ROLE_KEYmust NEVER be exposed to the client. Only use in API routes.Step 6: Create
.env.examplefor Team CollaborationCreate a template file for other developers:
# .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=development2. 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:
Step 2: Execute SQL in Supabase
-- 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);Step 3: Verify Schema Creation
conversations,messages,message_statusSchema Explanation:
conversations.last_message_atwhen new messages arriveReferences:
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)// 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)// 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_rolekey 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)// 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)// 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:
Replace
your-project-refwith 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)// 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:
POST /api/messages/sendPlivo Message Object: The SDK returns
messageUuidarray andapiIdfor 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:
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:
{ "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)// 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-urlencodedPOST requests (Plivo Inbound Message Webhook). Key fields include:MessageUUID: Unique message identifierFrom: Sender's phone number (E.164)To: Your Plivo number that received the messageText: Message contentType: Message type (smsorwhatsapp)Idempotency: Plivo may retry webhooks if they don't receive a 200 response within 5 seconds. The code checks for duplicate
MessageUUIDto prevent double-processing (Plivo Webhook Retry Policy).Step 2: Create Status Update Webhook Route (
app/api/webhooks/plivo/status/route.ts)// 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 deliverysent: Message sent to carrierdelivered: Message delivered to recipientundelivered: Delivery failed after retriesfailed: Immediate failure (invalid number, etc.)rejected: Rejected by carrierStep 3: Configure Webhook URLs in Plivo Console
https://your-domain.com/api/webhooks/plivo/inbound(POST)https://your-domain.com/api/webhooks/plivo/status(POST)Development Testing with ngrok:
# 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/statusWebhook 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
// 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:
// 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:
// 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);// 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:
MessageUUIDto handle retriesStep 5: Environment Variable for Development
Add to
.env.localfor easier development:# .env.local SKIP_WEBHOOK_VERIFICATION=true # Only for development! Remove in production7. 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
// 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
filterparameter uses PostgREST syntax to only receive events for the specific conversation (PostgREST Filtering).Step 2: Create Send Message Form Component
// 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
// 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
# Terminal 1: Start Next.js dev server npm run dev # Terminal 2: Start ngrok tunnel ngrok http 3000Step 2: Configure Plivo Webhooks
https://abc123.ngrok-free.app)https://abc123.ngrok-free.app/api/webhooks/plivo/inboundhttps://abc123.ngrok-free.app/api/webhooks/plivo/statusStep 3: Test Outbound Message
Using curl or Postman:
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:
messagestableStep 4: Test Inbound Message
messagestableStep 5: Verify Database
Open Supabase Dashboard → Table Editor:
-- 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:
PLIVO_AUTH_TOKENmatches your Plivo account9. 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):# Production URLs NEXT_PUBLIC_APP_URL=https://your-app.vercel.appStep 2: Deploy to Vercel
# Install Vercel CLI npm i -g vercel # Login to Vercel vercel login # Deploy vercelFollow prompts to link your GitHub/GitLab repository for automatic deployments.
Step 3: Configure Environment Variables in Vercel
Go to Vercel Dashboard → Your Project → Settings → Environment Variables
Add all variables from
.env.local:PLIVO_AUTH_IDPLIVO_AUTH_TOKENPLIVO_SMS_FROM_NUMBERPLIVO_WHATSAPP_NUMBERNEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEYNEXT_PUBLIC_APP_URL(your Vercel URL)NODE_ENV=productionImportant: Do NOT set
SKIP_WEBHOOK_VERIFICATIONin productionStep 4: Update Plivo Webhook URLs
After deployment, update Plivo Console with production URLs:
https://your-app.vercel.app/api/webhooks/plivo/inboundhttps://your-app.vercel.app/api/webhooks/plivo/statusStep 5: Production Checklist
SKIP_WEBHOOK_VERIFICATION)Production Monitoring:
Cost Considerations:
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):
// 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:
// 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):
10.3 Rate Limiting
Implement rate limiting to prevent abuse:
// 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:
// 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:
-- 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:
Issue 2: Webhooks Not Received
Symptoms: Inbound messages or status updates not appearing in database
Solutions:
Issue 3: Real-Time Updates Not Working
Symptoms: UI doesn't update when new messages arrive
Solutions:
Issue 4: Duplicate Messages
Symptoms: Same message appears multiple times in database
Solutions:
plivo_message_uuidmessages.plivo_message_uuidIssue 5: WhatsApp Messages Failing
Symptoms: WhatsApp messages fail while SMS works
Solutions:
Debugging Tools:
12. How Do You Ensure Security and Compliance?
12.1 Data Security
Encryption:
Access Control:
-- 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:
Implementation:
// 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:
CASL (Canada): Similar to TCPA, requires consent and opt-out mechanism.
Reference: Plivo Compliance Guide
12.3 Security Checklist
Summary
You've built a complete WhatsApp and SMS integration using:
Key Capabilities:
Next Steps:
Resources:
Support: