How to Receive SMS in Node.js with Plivo, React, and Vue: Two-Way Messaging
Learn how to receive SMS messages in Node.js using Plivo webhooks. Complete tutorial for building two-way SMS messaging with Express backend, React or Vue frontend, and real-time updates.
Build a production-ready full-stack application that receives and responds to inbound SMS messages using Plivo's API. This comprehensive tutorial shows you how to receive SMS in Node.js, handle Plivo webhooks with Express, and create a modern Vite-powered frontend using React or Vue for real-time two-way SMS communication.
Whether you're building customer support systems, appointment reminders, or interactive information services, this guide covers everything from webhook implementation with signature validation to automated SMS responses using Plivo's XML format, frontend integration patterns, security best practices, and production deployment strategies.
Technologies Used
Backend:
Node.js: JavaScript runtime environment for server-side applications. Recommended: Node.js v22 LTS (Active LTS began October 2024, maintained until April 2027, minimum v18+).
Express.js: Minimal and flexible web application framework for Node.js (v4.16+ includes necessary body parsing middleware via express.urlencoded() and express.json()).
Plivo Node.js SDK: Official SDK for interacting with Plivo's API and generating XML responses. Latest version: 4.74.0 (as of January 2025) (GitHub, npm).
dotenv: Module for loading environment variables from .env files.
Frontend:
Vite: Next-generation frontend build tool offering instant server start and lightning-fast Hot Module Replacement (HMR). Created by Evan You (Vue.js creator), supports React, Vue, and other frameworks (Documentation).
React or Vue.js: Modern JavaScript frameworks for building interactive user interfaces.
Fetch API / Axios: For HTTP requests from frontend to backend.
Development Tools:
ngrok: Exposes local development servers to the internet for webhook testing. Note: Free tier includes 1 static domain (since August 2023), eliminating constant URL updates (ngrok.com).
System Architecture
The application follows a full-stack architecture for receiving and sending SMS messages:
Inbound SMS Flow: User sends SMS to your Plivo number → Plivo sends HTTP POST to your webhook → Express validates signature → Processes message → Returns XML response → Plivo sends reply SMS
Frontend Flow: Vite dev server proxies API requests → Express backend serves message data → Frontend polls or receives updates → Displays messages in real-time
Prerequisites
Plivo Account: Sign up for free at Plivo Console. New accounts receive trial credits for testing.
SMS-Enabled Plivo Number: Purchase a number via Plivo Console: Phone Numbers > Buy Numbers. Ensure the number supports SMS in your target region. Pricing: US local numbers start at $0.50/month (Plivo Pricing).
Node.js and npm: v22 LTS recommended (download from nodejs.org).
ngrok: Install for local webhook testing (ngrok download).
Basic understanding of JavaScript, Node.js, REST APIs, and either React or Vue.js.
Important Trial Account Limitation: Plivo trial accounts can only send SMS to and receive SMS from numbers verified in your Sandbox (Phone Numbers > Sandbox Numbers). You must verify test numbers before development or upgrade to send/receive from any number.
SMS Character Limits and Encoding
Understanding SMS character limits is essential for proper message handling:
GSM-7 Encoding: Standard SMS uses GSM 03.38 character set. Single message: 160 characters. Multi-part messages: 153 characters per segment (7 characters reserved for concatenation headers). Maximum: 1,600 characters (≈10 segments) (Plivo Support).
UCS-2/Unicode Encoding: Messages with Unicode characters (emoji, non-Latin scripts). Single message: 70 characters. Multi-part: 67 characters per segment. Maximum: 737 characters (≈11 segments).
Automatic Concatenation: Multi-part messages are automatically reassembled by mobile networks that support long message concatenation. Recipients see a single message.
Cost Implications: Each segment counts as one SMS for billing. A 200-character GSM message costs 2× a standard SMS ($0.0055 × 2 = $0.011 to send in the US) (Plivo Pricing).
E.164 Phone Number Format
All phone numbers must use E.164 international format for reliable SMS delivery:
Structure:[+][country code][subscriber number including area code]
Maximum Length: 15 digits (excluding the + sign)
Examples:
United States: +12125551234 (country code 1, area code 212, number 5551234)
United Kingdom: +442071234567 (country code 44, area code 20, number 71234567)
Singapore: +6561234567 (country code 65, no area code, number 61234567)
Validation: No spaces, parentheses, dashes, or symbols. Must start with + followed by 1-9 (no leading zeros).
Regex Pattern:^\+[1-9]\d{1,14}$
Plivo automatically reformats valid variations to E.164, but using correct format from the start ensures reliability and avoids validation errors (Plivo E.164 Guide).
1. Backend Setup: Express Server with Plivo Webhook
Step 1.1: Create Project Structure
Create a new project directory and initialize Node.js:
Bash
mkdir plivo-sms-app
cd plivo-sms-app
mkdir backend frontend
cd backend
npm init -y
Important: Replace YOUR_PLIVO_AUTH_ID and YOUR_PLIVO_AUTH_TOKEN with actual credentials from your Plivo Console. Set PLIVO_FROM_NUMBER to your purchased Plivo number in E.164 format.
Server running on http://localhost:3001
Plivo webhook endpoint: http://localhost:3001/api/receive_sms
Use ngrok to expose this endpoint for Plivo webhooks
Test the health endpoint:
Bash
curl http://localhost:3001/health
2. Frontend Setup: Vite with React or Vue
Choose either React or Vue for your frontend. Both integrate identically with the backend.
Option A: React Frontend
Navigate to frontend directory and create Vite + React app:
Copy the https:// URL (e.g., https://xxxx-xx-xx-xx-xx.ngrok-free.app).
Note: Free ngrok tier includes 1 static domain since August 2023. Configure a static domain at ngrok dashboard to avoid changing URLs between sessions.
Reject request if signatures don't match (403 Forbidden)
URL Reconstruction Issues: Behind proxies/load balancers, req.protocol and req.get('host') may differ from actual webhook URL. Use X-Forwarded-Proto and X-Forwarded-Host headers as shown in the middleware.
5.2 Environment Variables
Never commit .env files to version control
Use platform-specific secret management in production:
Heroku: Config Vars
AWS: Systems Manager Parameter Store, Secrets Manager
Vercel/Netlify: Environment Variables UI
Rotate Auth Token periodically (generate new token in Plivo Console)
5.3 Rate Limiting
Protect /api/send_sms endpoint from abuse:
Bash
npm install express-rate-limit
JavaScript
import rateLimit from'express-rate-limit';
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 100, // Limit each IP to 100 requests per windowmessage: 'Too many requests, please try again later.'
});
app.use('/api/send_sms', apiLimiter);
5.4 Input Validation and Sanitization
Phone Number Validation: Use libphonenumber-js for robust E.164 validation:
Always use HTTPS for webhook URLs in production (Plivo requires it)
ngrok provides HTTPS automatically
Production servers: Use Let's Encrypt, Cloudflare, or platform-provided SSL
5.6 Opt-Out Compliance
Legal requirement: Honor STOP/UNSUBSCRIBE requests (TCPA compliance in US, similar regulations globally).
The provided webhook handler includes basic keyword detection:
JavaScript
if (lowerText === 'stop' || lowerText === 'unsubscribe') {
// TODO: Update database to mark user as unsubscribed// Do NOT send further marketing messages to this number
}
Production implementation:
Store opt-out status in database
Check opt-out status before sending ANY message
Provide opt-in mechanism (START keyword)
Maintain opt-out records for compliance audits
6. Production Deployment
6.1 Deploy Backend
Option A: Heroku
Bash
cd backend
heroku create your-app-name
heroku config:set PLIVO_AUTH_ID=your_auth_id
heroku config:set PLIVO_AUTH_TOKEN=your_auth_token
heroku config:set PLIVO_FROM_NUMBER=+15551234567
git push heroku main
Option B: Railway
Install Railway CLI: npm i -g @railway/cli
railway login
railway init
Set environment variables in Railway dashboard
railway up
Option C: AWS EC2/Elastic Beanstalk
Deploy as standard Node.js application. Configure environment variables via AWS Systems Manager or .env file (ensure proper permissions).
6.2 Deploy Frontend
Option A: Vercel
Bash
cd frontend
npm install -g vercel
vercel
Update API_BASE in App.jsx/App.vue to production backend URL:
model Message {
id String @id @default(uuid())
from String
to String
text String
type String // 'inbound' or 'outbound'
messageUUID String? @unique
timestamp DateTime @default(now())
}
Run migrations and update server.js to use Prisma Client.
7. Performance Optimizations
7.1 Async Webhook Processing
For high-volume applications, acknowledge webhook immediately and process asynchronously:
Check that messages are being stored (add console.log in webhook handler)
Verify frontend polling is running (check Network tab in DevTools)
Issue: Plivo Retry Loop
Cause: Webhook returns 5xx status code, triggering Plivo's automatic retry mechanism (3 retries: at 60s, 120s, 240s intervals).
Solution: Always return 200 OK with valid XML, even on errors:
JavaScript
// Instead of res.status(500).send('Error')const errorResponse = new plivo.Response();
res.status(200).set('Content-Type', 'application/xml').send(errorResponse.toXML());
Use 5xx statuses only if you want Plivo to retry (e.g., temporary database outage).
Issue: Character Encoding Problems
Cause: Unicode characters exceed GSM-7 character set, switching to UCS-2 (70 char limit).
Full backend with webhook handling and signature validation
React and Vue frontend implementations
Docker Compose setup for local development
Deployment configurations for Heroku, Railway, Vercel
Database migrations and Prisma schema
Unit and integration tests
Environment variable templates
Clone and run:
Bash
git clone https://github.com/plivo/plivo-vite-sms-example
cd plivo-vite-sms-example
cp .env.example .env# Add your credentials
docker-compose up
Conclusion
You now have a production-ready full-stack two-way SMS application using Plivo, Node.js/Express, and Vite with React or Vue. This architecture supports:
✅ Inbound SMS handling via Plivo webhooks
✅ Automated XML responses for instant replies
✅ Modern frontend with real-time message display
✅ Security best practices including signature validation
✅ Scalability with async processing and database integration
✅ Production deployment on major platforms
Next Steps:
Add user authentication and multi-tenant support
Implement conversation threading and search
Integrate AI for intelligent auto-replies (OpenAI, Anthropic Claude)
Add analytics dashboard for message volume and response times
For production support and volume pricing, contact Plivo Sales.
Frequently asked questions
You can send SMS messages programmatically using Node.js, Express, and the Vonage API. Set up an Express server, integrate the Vonage Node.js SDK, create a POST endpoint to handle SMS sending logic, and configure your Vonage API credentials.
The Vonage Node.js SDK (@vonage/server-sdk) simplifies interaction with the Vonage APIs from your Node.js application. It handles authentication and provides methods for sending SMS messages, making voice calls, and more.
You need a Vonage virtual number to send SMS messages from. This number acts as the sender ID and is required by the Vonage API. Purchase one through the Vonage Dashboard and ensure it's SMS-capable.
While this tutorial uses the simpler vonage.sms.send method, the Messages API (vonage.messages.send) is recommended for more advanced use cases. This includes sending MMS, WhatsApp messages, or when you need features like delivery receipts.
Trial accounts can only send SMS to verified test numbers added in your Vonage Dashboard settings. To send to any number, you must upgrade your account by providing billing information.
Create a project directory, initialize npm (npm init -y), install express, @vonage/server-sdk, and dotenv, configure package.json for ES modules, set up a .env file with your API credentials, and create a .gitignore file.
The .env file stores your sensitive Vonage API credentials (API Key, API Secret, Virtual Number) and server configuration. It's loaded by the dotenv package. Never commit this file to version control.
The Vonage API returns a status code ('0' for success). Implement error handling to check for non-zero status codes and provide appropriate feedback to the user. The server logs should record detailed error messages.
The provided example has basic validation. Use a dedicated library like libphonenumber-js (the Node.js port of google-libphonenumber) for robust E.164 validation to prevent issues and improve reliability.
Never commit your .env file to version control. In production, use secure environment variable management provided by your deployment platform (e.g., platform secrets, vault) to prevent unauthorized access.
Use a middleware package like express-rate-limit to restrict how many requests a user can make within a time period. This protects your application and Vonage account from abuse and unexpected costs.
This is common with Vonage trial accounts. Ensure the recipient number is added to the Test Numbers list in your Vonage Dashboard under Account > Settings > Test Numbers. Upgrading your account removes this limitation.
Check that the VONAGE_FROM_NUMBER in your .env file is a valid, SMS-capable Vonage number assigned to your account and is in the correct format (typically E.164 without the leading '+', like '15551234567').
Extend your application by receiving SMS messages (webhooks), implementing delivery receipts, adding more robust error handling and retry logic, using asynchronous sending with queues, or integrating a database for message logging.
Build a production-ready full-stack application that receives and responds to inbound SMS messages using Plivo's API. This comprehensive tutorial shows you how to receive SMS in Node.js, handle Plivo webhooks with Express, and create a modern Vite-powered frontend using React or Vue for real-time two-way SMS communication.
Whether you're building customer support systems, appointment reminders, or interactive information services, this guide covers everything from webhook implementation with signature validation to automated SMS responses using Plivo's XML format, frontend integration patterns, security best practices, and production deployment strategies.
Technologies Used
Backend:
express.urlencoded()andexpress.json())..envfiles.Frontend:
Development Tools:
System Architecture
The application follows a full-stack architecture for receiving and sending SMS messages:
Prerequisites
Important Trial Account Limitation: Plivo trial accounts can only send SMS to and receive SMS from numbers verified in your Sandbox (Phone Numbers > Sandbox Numbers). You must verify test numbers before development or upgrade to send/receive from any number.
SMS Character Limits and Encoding
Understanding SMS character limits is essential for proper message handling:
E.164 Phone Number Format
All phone numbers must use E.164 international format for reliable SMS delivery:
[+][country code][subscriber number including area code]+sign)+12125551234(country code 1, area code 212, number 5551234)+442071234567(country code 44, area code 20, number 71234567)+6561234567(country code 65, no area code, number 61234567)+followed by 1-9 (no leading zeros).^\+[1-9]\d{1,14}$Plivo automatically reformats valid variations to E.164, but using correct format from the start ensures reliability and avoids validation errors (Plivo E.164 Guide).
1. Backend Setup: Express Server with Plivo Webhook
Step 1.1: Create Project Structure
Create a new project directory and initialize Node.js:
mkdir plivo-sms-app cd plivo-sms-app mkdir backend frontend cd backend npm init -yStep 1.2: Install Backend Dependencies
express: Web framework for Node.jsplivo: Official Plivo Node.js SDK (v4.74.0)dotenv: Environment variable managementnodemon: Auto-restarts server on file changes (development only)Step 1.3: Configure Environment Variables
Create
.envin thebackend/directory:Important: Replace
YOUR_PLIVO_AUTH_IDandYOUR_PLIVO_AUTH_TOKENwith actual credentials from your Plivo Console. SetPLIVO_FROM_NUMBERto your purchased Plivo number in E.164 format.Create
.gitignore:Step 1.4: Create Express Server with Webhook Handler
Create
backend/server.js:// backend/server.js import express from 'express'; import plivo from 'plivo'; import crypto from 'crypto'; import 'dotenv/config'; const app = express(); const PORT = process.env.PORT || 3001; // Middleware app.use(express.urlencoded({ extended: true })); // Parse Plivo webhook data app.use(express.json()); // Parse JSON from frontend // In-memory message store (use database in production) const messages = []; // Initialize Plivo Client (for outbound SMS if needed) const plivoClient = new plivo.Client( process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN ); // === WEBHOOK SIGNATURE VALIDATION MIDDLEWARE === // Validates that requests genuinely originate from Plivo // Uses X-Plivo-Signature-V3 header with SHA256 HMAC const validatePlivoSignature = (req, res, next) => { const signature = req.headers['x-plivo-signature-v3']; const nonce = req.headers['x-plivo-signature-v3-nonce']; const authToken = process.env.PLIVO_AUTH_TOKEN; if (!signature || !nonce) { console.error('Missing Plivo signature headers'); return res.status(400).send('Missing signature headers'); } try { // Reconstruct URL (important: must match exactly how Plivo called it) const protocol = req.headers['x-forwarded-proto'] || req.protocol; const host = req.headers['x-forwarded-host'] || req.get('host'); const url = `${protocol}://${host}${req.originalUrl}`; // Verify signature using Plivo SDK const isValid = plivo.validateV3Signature( url, nonce, signature, authToken ); if (!isValid) { console.error('Invalid Plivo signature'); return res.status(403).send('Invalid signature'); } next(); } catch (error) { console.error('Signature validation error:', error); res.status(500).send('Signature validation failed'); } }; // === WEBHOOK ENDPOINT === // Plivo POSTs here when SMS is received on your number app.post('/api/receive_sms', validatePlivoSignature, (req, res) => { console.log('Received inbound SMS:', req.body); try { // Extract webhook parameters from Plivo const { From: fromNumber, To: toNumber, Text: messageText, MessageUUID: messageUUID, Type: messageType } = req.body; // Store message (add database persistence in production) const message = { id: messageUUID, from: fromNumber, to: toNumber, text: messageText, type: 'inbound', timestamp: new Date().toISOString() }; messages.push(message); console.log(`Stored message: ${messageUUID}`); // === GENERATE XML RESPONSE === // Plivo executes XML instructions to send reply SMS const response = new plivo.Response(); // Business logic: Auto-reply based on message content let replyText; const lowerText = messageText.toLowerCase().trim(); if (lowerText === 'stop' || lowerText === 'unsubscribe') { replyText = 'You have been unsubscribed. Reply START to resubscribe.'; // TODO: Update database to mark user as unsubscribed } else if (lowerText === 'start') { replyText = 'Welcome! You are now subscribed to our updates.'; // TODO: Update database to mark user as subscribed } else if (lowerText.includes('help')) { replyText = 'Reply STOP to unsubscribe, START to resubscribe, or send any message for assistance.'; } else { replyText = `Thank you for your message! We received: "${messageText}". Our team will respond shortly.`; } // Add Message element to XML response response.addMessage(replyText, { src: toNumber, // Your Plivo number (sender) dst: fromNumber // Original sender (recipient) }); // Store outbound reply const replyMessage = { id: crypto.randomUUID(), from: toNumber, to: fromNumber, text: replyText, type: 'outbound', timestamp: new Date().toISOString() }; messages.push(replyMessage); // Send XML response to Plivo res.set('Content-Type', 'application/xml'); res.status(200).send(response.toXML()); console.log('Sent XML response to Plivo'); } catch (error) { console.error('Error processing webhook:', error); // Return 200 with empty XML to prevent Plivo retries // Use 5xx status if you want Plivo to retry const errorResponse = new plivo.Response(); res.set('Content-Type', 'application/xml'); res.status(200).send(errorResponse.toXML()); } }); // === FRONTEND API ENDPOINTS === // Serve message data to Vite frontend // GET all messages (for frontend display) app.get('/api/messages', (req, res) => { res.json({ success: true, messages: messages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp) ) }); }); // POST send outbound SMS (triggered by frontend) app.post('/api/send_sms', async (req, res) => { const { to, text } = req.body; if (!to || !text) { return res.status(400).json({ success: false, message: 'Missing required fields: to, text' }); } try { const response = await plivoClient.messages.create({ src: process.env.PLIVO_FROM_NUMBER, dst: to, text: text }); // Store sent message const message = { id: response.messageUuid[0], from: process.env.PLIVO_FROM_NUMBER, to: to, text: text, type: 'outbound', timestamp: new Date().toISOString() }; messages.push(message); res.json({ success: true, messageId: response.messageUuid[0] }); } catch (error) { console.error('Error sending SMS:', error); res.status(500).json({ success: false, message: error.message }); } }); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Start server if (process.env.NODE_ENV !== 'test') { app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); console.log(`Plivo webhook endpoint: http://localhost:${PORT}/api/receive_sms`); console.log('Use ngrok to expose this endpoint for Plivo webhooks'); }); } export default app; // Export for testingStep 1.5: Update package.json
Add
"type": "module"and scripts tobackend/package.json:{ "name": "plivo-sms-backend", "version": "1.0.0", "type": "module", "scripts": { "start": "node server.js", "dev": "nodemon server.js" }, "dependencies": { "dotenv": "^16.4.5", "express": "^4.21.2", "plivo": "^4.74.0" }, "devDependencies": { "nodemon": "^3.1.9" } }Step 1.6: Test Backend Locally
Start the server:
Expected output:
Test the health endpoint:
2. Frontend Setup: Vite with React or Vue
Choose either React or Vue for your frontend. Both integrate identically with the backend.
Option A: React Frontend
Navigate to frontend directory and create Vite + React app:
cd ../frontend npm create vite@latest . -- --template react npm install npm install axiosCreate
frontend/src/App.jsx:// frontend/src/App.jsx import { useState, useEffect } from 'react'; import axios from 'axios'; import './App.css'; const API_BASE = 'http://localhost:3001/api'; function App() { const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState({ to: '', text: '' }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Fetch messages on mount and poll every 3 seconds useEffect(() => { fetchMessages(); const interval = setInterval(fetchMessages, 3000); return () => clearInterval(interval); }, []); const fetchMessages = async () => { try { const response = await axios.get(`${API_BASE}/messages`); setMessages(response.data.messages); setError(null); } catch (err) { console.error('Error fetching messages:', err); setError('Failed to load messages'); } }; const sendMessage = async (e) => { e.preventDefault(); if (!newMessage.to || !newMessage.text) return; setLoading(true); try { await axios.post(`${API_BASE}/send_sms`, newMessage); setNewMessage({ to: '', text: '' }); fetchMessages(); // Refresh message list setError(null); } catch (err) { console.error('Error sending message:', err); setError(err.response?.data?.message || 'Failed to send message'); } finally { setLoading(false); } }; return ( <div className="App"> <header> <h1>📱 Plivo SMS Dashboard</h1> <p>Two-Way Messaging with React + Vite</p> </header> <main> <section className="send-message"> <h2>Send SMS</h2> <form onSubmit={sendMessage}> <input type="tel" placeholder="To: +15551234567" value={newMessage.to} onChange={(e) => setNewMessage({ ...newMessage, to: e.target.value })} required /> <textarea placeholder="Message text..." value={newMessage.text} onChange={(e) => setNewMessage({ ...newMessage, text: e.target.value })} required /> <button type="submit" disabled={loading}> {loading ? 'Sending...' : 'Send SMS'} </button> </form> {error && <p className="error">{error}</p>} </section> <section className="messages"> <h2>Messages ({messages.length})</h2> <div className="message-list"> {messages.length === 0 ? ( <p className="empty">No messages yet. Send an SMS to your Plivo number!</p> ) : ( messages.map((msg) => ( <div key={msg.id} className={`message ${msg.type}`}> <div className="message-header"> <span className="label">{msg.type === 'inbound' ? '📥 Received' : '📤 Sent'}</span> <span className="timestamp">{new Date(msg.timestamp).toLocaleTimeString()}</span> </div> <div className="message-body"> <p><strong>From:</strong> {msg.from}</p> <p><strong>To:</strong> {msg.to}</p> <p className="text">{msg.text}</p> </div> </div> )) )} </div> </section> </main> </div> ); } export default App;Create
frontend/src/App.css:/* frontend/src/App.css */ * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; } .App { max-width: 1200px; margin: 0 auto; padding: 2rem; } header { text-align: center; color: white; margin-bottom: 2rem; } header h1 { font-size: 2.5rem; margin-bottom: 0.5rem; } main { display: grid; grid-template-columns: 1fr 2fr; gap: 2rem; } section { background: white; border-radius: 12px; padding: 1.5rem; box-shadow: 0 10px 30px rgba(0,0,0,0.2); } h2 { margin-bottom: 1rem; color: #333; } /* Send Message Form */ .send-message form { display: flex; flex-direction: column; gap: 1rem; } input, textarea { padding: 0.75rem; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 1rem; transition: border-color 0.3s; } input:focus, textarea:focus { outline: none; border-color: #667eea; } textarea { min-height: 100px; resize: vertical; } button { padding: 0.75rem 1.5rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: transform 0.2s, opacity 0.2s; } button:hover:not(:disabled) { transform: translateY(-2px); } button:disabled { opacity: 0.6; cursor: not-allowed; } .error { color: #e74c3c; margin-top: 0.5rem; font-size: 0.9rem; } /* Message List */ .message-list { max-height: 600px; overflow-y: auto; display: flex; flex-direction: column; gap: 1rem; } .message { border: 2px solid #e0e0e0; border-radius: 8px; padding: 1rem; transition: transform 0.2s; } .message:hover { transform: translateX(4px); } .message.inbound { border-left: 4px solid #2ecc71; } .message.outbound { border-left: 4px solid #3498db; } .message-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; color: #666; } .label { font-weight: 600; } .message-body p { margin: 0.25rem 0; font-size: 0.9rem; } .text { margin-top: 0.5rem !important; padding: 0.75rem; background: #f8f9fa; border-radius: 4px; font-size: 1rem !important; line-height: 1.5; } .empty { text-align: center; color: #999; padding: 2rem; } /* Responsive */ @media (max-width: 768px) { main { grid-template-columns: 1fr; } }Update
frontend/vite.config.jsto proxy API requests:// frontend/vite.config.js import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:3001', changeOrigin: true } } } })Start the frontend:
Option B: Vue Frontend
Alternatively, create a Vue 3 app:
cd ../frontend npm create vite@latest . -- --template vue npm install npm install axiosCreate
frontend/src/App.vue:Configure Vite proxy in
frontend/vite.config.js:// frontend/vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:3001', changeOrigin: true } } } })3. Configuring Plivo Webhooks with ngrok
Plivo needs a publicly accessible HTTPS URL to send webhook requests. Use ngrok to expose your local server during development.
Step 3.1: Start ngrok
In a new terminal window:
Output will display:
Copy the
https://URL (e.g.,https://xxxx-xx-xx-xx-xx.ngrok-free.app).Note: Free ngrok tier includes 1 static domain since August 2023. Configure a static domain at ngrok dashboard to avoid changing URLs between sessions.
Step 3.2: Configure Plivo Application
SMS Webhook Handler/api/receive_smshttps://xxxx-xx-xx-xx-xx.ngrok-free.app/api/receive_smsPOSTPOST(default)Step 3.3: Assign Application to Your Plivo Number
XML ApplicationSMS Webhook HandlerYour Express webhook is now connected to your Plivo number. Incoming SMS messages will trigger HTTP POST requests to your server.
4. Testing End-to-End Flow
Test Inbound SMS:
npm run devinbackend/npm run devinfrontend/Test Outbound SMS:
Troubleshooting:
PLIVO_AUTH_TOKENin.envmatches Plivo Console. Restart backend after changing.env.vite.config.js.5. Security Best Practices
5.1 Webhook Signature Validation
Critical security measure: Always validate
X-Plivo-Signature-V3header to prevent spoofed requests. The providedvalidatePlivoSignaturemiddleware uses Plivo'svalidateV3Signaturefunction with SHA256 HMAC (Plivo Signature Validation Guide).How it works:
HMAC-SHA256(Auth Token, URL + Nonce + Body)X-Plivo-Signature-V3headerURL Reconstruction Issues: Behind proxies/load balancers,
req.protocolandreq.get('host')may differ from actual webhook URL. UseX-Forwarded-ProtoandX-Forwarded-Hostheaders as shown in the middleware.5.2 Environment Variables
.envfiles to version control5.3 Rate Limiting
Protect
/api/send_smsendpoint from abuse:import rateLimit from 'express-rate-limit'; const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per window message: 'Too many requests, please try again later.' }); app.use('/api/send_sms', apiLimiter);5.4 Input Validation and Sanitization
Phone Number Validation: Use
libphonenumber-jsfor robust E.164 validation:import { parsePhoneNumber } from 'libphonenumber-js'; const validatePhoneNumber = (number) => { try { const phoneNumber = parsePhoneNumber(number); return phoneNumber.isValid() && phoneNumber.format('E.164'); } catch { return false; } }; // In /api/send_sms route: const validNumber = validatePhoneNumber(req.body.to); if (!validNumber) { return res.status(400).json({ success: false, message: 'Invalid phone number format' }); }Message Text Sanitization: Prevent injection attacks if storing messages in database:
import validator from 'validator'; const sanitizedText = validator.escape(req.body.text);5.5 HTTPS in Production
5.6 Opt-Out Compliance
Legal requirement: Honor STOP/UNSUBSCRIBE requests (TCPA compliance in US, similar regulations globally).
The provided webhook handler includes basic keyword detection:
if (lowerText === 'stop' || lowerText === 'unsubscribe') { // TODO: Update database to mark user as unsubscribed // Do NOT send further marketing messages to this number }Production implementation:
6. Production Deployment
6.1 Deploy Backend
Option A: Heroku
cd backend heroku create your-app-name heroku config:set PLIVO_AUTH_ID=your_auth_id heroku config:set PLIVO_AUTH_TOKEN=your_auth_token heroku config:set PLIVO_FROM_NUMBER=+15551234567 git push heroku mainOption B: Railway
npm i -g @railway/clirailway loginrailway initrailway upOption C: AWS EC2/Elastic Beanstalk
Deploy as standard Node.js application. Configure environment variables via AWS Systems Manager or
.envfile (ensure proper permissions).6.2 Deploy Frontend
Option A: Vercel
cd frontend npm install -g vercel vercelUpdate API_BASE in
App.jsx/App.vueto production backend URL:const API_BASE = import.meta.env.PROD ? 'https://your-backend.herokuapp.com/api' : 'http://localhost:3001/api';Option B: Netlify
npm run build netlify deploy --prod --dir=distConfigure environment variables in Netlify dashboard.
6.3 Update Plivo Webhook URL
After deployment, update the Message URL in your Plivo Application to your production backend URL:
6.4 Database Integration
Replace in-memory
messagesarray with persistent storage:PostgreSQL with Prisma:
Define schema in
prisma/schema.prisma:Run migrations and update server.js to use Prisma Client.
7. Performance Optimizations
7.1 Async Webhook Processing
For high-volume applications, acknowledge webhook immediately and process asynchronously:
import { Queue } from 'bullmq'; const messageQueue = new Queue('messages', { connection: redisConnection }); app.post('/api/receive_sms', validatePlivoSignature, async (req, res) => { // Add to queue for async processing await messageQueue.add('process-sms', req.body); // Return immediate response to Plivo const response = new plivo.Response(); res.set('Content-Type', 'application/xml'); res.status(200).send(response.toXML()); });Benefits:
7.2 Frontend: WebSocket for Real-Time Updates
Replace polling with WebSocket for instant message updates:
Backend (add to server.js):
import { Server } from 'socket.io'; import { createServer } from 'http'; const httpServer = createServer(app); const io = new Server(httpServer, { cors: { origin: 'http://localhost:5173' } }); // In webhook handler, emit new messages: io.emit('new-message', message); httpServer.listen(PORT, () => { /* ... */ });Frontend:
import { io } from 'socket.io-client'; const socket = io('http://localhost:3001'); socket.on('new-message', (message) => { setMessages(prev => [...prev, message]); });7.3 Caching
Use Redis to cache frequently accessed data:
import { createClient } from 'redis'; const redis = createClient(); await redis.connect(); // Cache recent messages app.get('/api/messages', async (req, res) => { const cached = await redis.get('recent-messages'); if (cached) return res.json(JSON.parse(cached)); // Fetch from database... await redis.setEx('recent-messages', 30, JSON.stringify(messages)); res.json(messages); });8. Cost Estimation (Plivo Pricing)
Based on Plivo Pricing (US, as of January 2025):
Example monthly cost (US):
Free trial credits: New accounts receive credits for testing (typically $5-10).
Volume discounts: Available for committed spend agreements ($750+/month minimum).
9. Troubleshooting Common Issues
Issue: Webhook Returns 403 Invalid Signature
Cause: Auth Token mismatch or URL reconstruction error.
Solutions:
PLIVO_AUTH_TOKENin.envmatches Plivo Console exactly.env/api/receive_smspath)X-Forwarded-ProtoandX-Forwarded-Hostheaders are correctly setconsole.log('Reconstructed URL:', url); console.log('Expected signature:', signature);Issue: No SMS Received on Phone
Causes and solutions:
+and country codeIssue: Messages Not Appearing in Frontend
Solutions:
vite.config.js/api/messagesendpoint returns data:Issue: Plivo Retry Loop
Cause: Webhook returns 5xx status code, triggering Plivo's automatic retry mechanism (3 retries: at 60s, 120s, 240s intervals).
Solution: Always return 200 OK with valid XML, even on errors:
// Instead of res.status(500).send('Error') const errorResponse = new plivo.Response(); res.status(200).set('Content-Type', 'application/xml').send(errorResponse.toXML());Use 5xx statuses only if you want Plivo to retry (e.g., temporary database outage).
Issue: Character Encoding Problems
Cause: Unicode characters exceed GSM-7 character set, switching to UCS-2 (70 char limit).
Solution: Detect encoding and warn users:
const isGSM = /^[@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&'()*+,\-./0-9:;<=>?¡A-Z\ÄÖÑܧ¿a-zäöñüà]+$/.test(text); const limit = isGSM ? 160 : 70; console.log(`Encoding: ${isGSM ? 'GSM-7' : 'UCS-2'}, Limit: ${limit}`);Consider removing emoji or using alternative text for longer messages.
10. Advanced Features and Extensions
10.1 Delivery Receipts (DLRs)
Track message delivery status by configuring a callback URL:
const response = await plivoClient.messages.create({ src: process.env.PLIVO_FROM_NUMBER, dst: to, text: text, url: 'https://your-backend.com/api/dlr', // DLR webhook method: 'POST' });Handle DLR webhook:
app.post('/api/dlr', validatePlivoSignature, (req, res) => { const { MessageUUID, Status, ErrorCode } = req.body; console.log(`Message ${MessageUUID} status: ${Status}`); // Update database with delivery status res.status(200).send('OK'); });Possible statuses:
queued,sent,failed,delivered,undelivered,rejected.10.2 MMS Support
Send multimedia messages (images, videos, audio):
const response = await plivoClient.messages.create({ src: process.env.PLIVO_FROM_NUMBER, dst: to, text: 'Check out this image!', type: 'mms', media_urls: ['https://example.com/image.jpg'] });Limitations:
10.3 Conversation Threading
Group messages into conversations by phone number pair:
const conversationId = `${Math.min(from, to)}_${Math.max(from, to)}`; // Store with conversationId in database const conversation = await prisma.conversation.upsert({ where: { id: conversationId }, update: { lastMessage: messageText, updatedAt: new Date() }, create: { id: conversationId, participant1: from, participant2: to } });Display grouped conversations in frontend UI.
10.4 Natural Language Processing (NLP)
Integrate AI for intelligent auto-replies:
import OpenAI from 'openai'; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // In webhook handler: const completion = await openai.chat.completions.create({ model: 'gpt-4', messages: [ { role: 'system', content: 'You are a helpful SMS assistant.' }, { role: 'user', content: messageText } ], max_tokens: 150 }); const replyText = completion.choices[0].message.content;Use cases: Customer support bots, FAQ automation, appointment scheduling.
11. Testing and Quality Assurance
11.1 Unit Tests with Jest
Create
backend/server.test.js:import request from 'supertest'; import app from './server.js'; describe('POST /api/receive_sms', () => { it('should reject requests without signature', async () => { const res = await request(app) .post('/api/receive_sms') .send({ From: '+15551234567', Text: 'Test' }); expect(res.status).toBe(400); expect(res.text).toContain('Missing signature headers'); }); }); describe('GET /api/messages', () => { it('should return messages array', async () => { const res = await request(app).get('/api/messages'); expect(res.status).toBe(200); expect(res.body).toHaveProperty('success', true); expect(res.body).toHaveProperty('messages'); expect(Array.isArray(res.body.messages)).toBe(true); }); });Run tests:
npm test11.2 Integration Testing with Plivo
Use Plivo's test credentials (available in Console) to send messages without charges:
// In test environment, use test credentials const authId = process.env.NODE_ENV === 'test' ? 'test_auth_id' : process.env.PLIVO_AUTH_ID;11.3 Load Testing
Simulate high-volume webhook traffic with
k6:// load-test.js import http from 'k6/http'; import { check } from 'k6'; export let options = { stages: [ { duration: '1m', target: 100 }, // Ramp to 100 RPS { duration: '3m', target: 100 }, // Stay at 100 RPS { duration: '1m', target: 0 } // Ramp down ] }; export default function () { const res = http.post('http://localhost:3001/api/receive_sms', JSON.stringify({ From: '+15551234567', To: '+15559876543', Text: 'Load test message', MessageUUID: `test-${Date.now()}` }), { headers: { 'Content-Type': 'application/json' } }); check(res, { 'status is 200': (r) => r.status === 200 }); }Run:
k6 run load-test.js12. Monitoring and Observability
12.1 Application Performance Monitoring (APM)
Integrate Datadog, New Relic, or Sentry:
import * as Sentry from '@sentry/node'; Sentry.init({ dsn: process.env.SENTRY_DSN }); app.use(Sentry.Handlers.requestHandler()); app.use(Sentry.Handlers.errorHandler());12.2 Structured Logging
Replace
console.logwith Winston or Pino:import winston from 'winston'; const logger = winston.createLogger({ level: 'info', format: winston.format.json(), transports: [ new winston.transports.File({ filename: 'error.log', level: 'error' }), new winston.transports.File({ filename: 'combined.log' }) ] }); // Use in code: logger.info('Received webhook', { from: fromNumber, text: messageText }); logger.error('Signature validation failed', { signature, url });12.3 Health Check Endpoint
Monitor uptime with health checks:
app.get('/health', async (req, res) => { const health = { uptime: process.uptime(), timestamp: Date.now(), status: 'ok', checks: { database: await checkDatabase(), plivo: await checkPlivoConnection() } }; res.status(health.status === 'ok' ? 200 : 503).json(health); });Configure monitoring services (UptimeRobot, Pingdom) to ping
/healthevery 5 minutes.13. Compliance and Legal Considerations
13.1 TCPA Compliance (US)
The Telephone Consumer Protection Act regulates automated SMS:
Violation penalties: Up to $1,500 per message.
13.2 GDPR Compliance (EU)
If serving EU users:
13.3 Other Regulations
Recommendation: Consult legal counsel when launching SMS services in new regions.
14. Complete Code Repository
A complete working example of this tutorial is available on GitHub:
Repository: github.com/plivo/plivo-vite-sms-example (adapt URL to actual example repo)
Includes:
Clone and run:
git clone https://github.com/plivo/plivo-vite-sms-example cd plivo-vite-sms-example cp .env.example .env # Add your credentials docker-compose upConclusion
You now have a production-ready full-stack two-way SMS application using Plivo, Node.js/Express, and Vite with React or Vue. This architecture supports:
Next Steps:
Related Resources:
Resources:
For production support and volume pricing, contact Plivo Sales.