code examples
code examples
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.
Plivo Inbound SMS with Vite, React, and Vue: Two-Way Messaging Tutorial
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()andexpress.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
.envfiles.
-
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:
[User Phone] <--> [Plivo Platform] <--> [Express Backend] <--> [Vite Frontend]
| |
[Webhook Handler] [React/Vue UI]
[XML Response] [Message Display]
[Signature Validation] [API Polling/WebSocket]
- 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).
- Plivo Auth Credentials: Auth ID and Auth Token from your Plivo Console Dashboard.
- 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)
- United States:
- 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:
mkdir plivo-sms-app
cd plivo-sms-app
mkdir backend frontend
cd backend
npm init -yStep 1.2: Install Backend Dependencies
npm install express plivo dotenv
npm install --save-dev nodemonexpress: 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 .env in the backend/ directory:
# .env
PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
PLIVO_FROM_NUMBER=+15551234567
PORT=3001
# Security
NODE_ENV=developmentImportant: 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.
Create .gitignore:
# .gitignore
node_modules
.env
*.log
.DS_Store
distStep 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 to backend/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:
npm run devExpected output:
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:
curl http://localhost:3001/health2. 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.js to 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:
npm run devOption 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:
<!-- frontend/src/App.vue -->
<template>
<div class="app">
<header>
<h1>📱 Plivo SMS Dashboard</h1>
<p>Two-Way Messaging with Vue + Vite</p>
</header>
<main>
<section class="send-message">
<h2>Send SMS</h2>
<form @submit.prevent="sendMessage">
<input
v-model="newMessage.to"
type="tel"
placeholder="To: +15551234567"
required
/>
<textarea
v-model="newMessage.text"
placeholder="Message text..."
required
/>
<button type="submit" :disabled="loading">
{{ loading ? 'Sending...' : 'Send SMS' }}
</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
</section>
<section class="messages">
<h2>Messages ({{ messages.length }})</h2>
<div class="message-list">
<p v-if="messages.length === 0" class="empty">
No messages yet. Send an SMS to your Plivo number!
</p>
<div
v-for="msg in messages"
:key="msg.id"
:class="['message', msg.type]"
>
<div class="message-header">
<span class="label">
{{ msg.type === 'inbound' ? '📥 Received' : '📤 Sent' }}
</span>
<span class="timestamp">
{{ new Date(msg.timestamp).toLocaleTimeString() }}
</span>
</div>
<div class="message-body">
<p><strong>From:</strong> {{ msg.from }}</p>
<p><strong>To:</strong> {{ msg.to }}</p>
<p class="text">{{ msg.text }}</p>
</div>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import axios from 'axios';
const API_BASE = 'http://localhost:3001/api';
const messages = ref([]);
const newMessage = ref({ to: '', text: '' });
const loading = ref(false);
const error = ref(null);
let pollInterval = null;
const fetchMessages = async () => {
try {
const response = await axios.get(`${API_BASE}/messages`);
messages.value = response.data.messages;
error.value = null;
} catch (err) {
console.error('Error fetching messages:', err);
error.value = 'Failed to load messages';
}
};
const sendMessage = async () => {
if (!newMessage.value.to || !newMessage.value.text) return;
loading.value = true;
try {
await axios.post(`${API_BASE}/send_sms`, newMessage.value);
newMessage.value = { to: '', text: '' };
await fetchMessages();
error.value = null;
} catch (err) {
console.error('Error sending message:', err);
error.value = err.response?.data?.message || 'Failed to send message';
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchMessages();
pollInterval = setInterval(fetchMessages, 3000);
});
onUnmounted(() => {
if (pollInterval) clearInterval(pollInterval);
});
</script>
<style scoped>
/* Same CSS as React version - copy from App.css above */
/* Or import from a separate file */
</style>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:
ngrok http 3001Output will display:
Forwarding https://xxxx-xx-xx-xx-xx.ngrok-free.app -> http://localhost:3001
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
- Log in to Plivo Console
- Navigate to Messaging > Applications > XML Applications
- Click Add New Application
- Application Name:
SMS Webhook Handler - Message URL: Paste your ngrok URL +
/api/receive_sms- Example:
https://xxxx-xx-xx-xx-xx.ngrok-free.app/api/receive_sms
- Example:
- Method:
POST - Message Method:
POST(default) - Click Create Application
Step 3.3: Assign Application to Your Plivo Number
- Go to Phone Numbers > Your Numbers
- Click on your SMS-enabled number
- Under Application Type, select
XML Application - Under Plivo Application, select
SMS Webhook Handler - Click Update Number
Your 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:
- Ensure backend is running:
npm run devinbackend/ - Ensure frontend is running:
npm run devinfrontend/ - Ensure ngrok is exposing port 3001
- Send an SMS to your Plivo number from your mobile phone (must be verified in Sandbox if using trial account)
- Watch terminal logs for webhook POST request
- Check frontend browser—message should appear within 3 seconds (polling interval)
- You should receive an auto-reply SMS on your phone
Test Outbound SMS:
- In the frontend UI, enter a verified phone number (E.164 format)
- Type a message and click "Send SMS"
- Message appears in the message list
- Recipient receives SMS
Troubleshooting:
- No webhook received: Check ngrok URL matches Plivo application configuration exactly. Verify ngrok is running and forwarding to correct port.
- 403 Invalid Signature: Auth Token mismatch. Verify
PLIVO_AUTH_TOKENin.envmatches Plivo Console. Restart backend after changing.env. - Trial account errors: Verify recipient number is in Sandbox (Phone Numbers > Sandbox Numbers).
- CORS errors: Ensure Vite proxy is configured correctly in
vite.config.js.
5. Security Best Practices
5.1 Webhook Signature Validation
Critical security measure: Always validate X-Plivo-Signature-V3 header to prevent spoofed requests. The provided validatePlivoSignature middleware uses Plivo's validateV3Signature function with SHA256 HMAC (Plivo Signature Validation Guide).
How it works:
- Plivo generates HMAC signature:
HMAC-SHA256(Auth Token, URL + Nonce + Body) - Signature sent in
X-Plivo-Signature-V3header - Your server recomputes signature and compares
- 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
.envfiles 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:
npm install express-rate-limitimport 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-js for robust E.164 validation:
npm install libphonenumber-jsimport { 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:
npm install validatorimport validator from 'validator';
const sanitizedText = validator.escape(req.body.text);5.5 HTTPS in Production
- 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:
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
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
- Install Railway CLI:
npm i -g @railway/cli railway loginrailway 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
cd frontend
npm install -g vercel
vercelUpdate API_BASE in App.jsx/App.vue to 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:
https://your-backend.herokuapp.com/api/receive_sms
6.4 Database Integration
Replace in-memory messages array with persistent storage:
PostgreSQL with Prisma:
npm install @prisma/client
npm install -D prisma
npx prisma initDefine schema in prisma/schema.prisma:
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:
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:
- Webhook responds in <100ms
- Heavy processing (database writes, external API calls) happens in background
- Prevents Plivo timeouts (5-second limit)
7.2 Frontend: WebSocket for Real-Time Updates
Replace polling with WebSocket for instant message updates:
Backend (add to server.js):
npm install socket.ioimport { 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:
npm install socket.io-clientimport { 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:
npm install redisimport { 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):
- Phone Number: $0.50/month (local), $1.00/month (toll-free)
- Inbound SMS: $0.00550 per message received (free tier available)
- Outbound SMS: $0.00700 per message sent
- Multi-part SMS: Each 153-character segment counts as one message
Example monthly cost (US):
- 1 local number: $0.50
- 1,000 inbound SMS: 1,000 × $0.0055 = $5.50
- 500 outbound SMS: 500 × $0.0070 = $3.50
- Total: ≈ $9.50/month
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:
- Verify
PLIVO_AUTH_TOKENin.envmatches Plivo Console exactly - Restart backend after changing
.env - Check ngrok URL in Plivo application matches exactly (including
/api/receive_smspath) - If behind proxy, ensure
X-Forwarded-ProtoandX-Forwarded-Hostheaders are correctly set - Test signature validation by temporarily logging the reconstructed URL:
console.log('Reconstructed URL:', url);
console.log('Expected signature:', signature);Issue: No SMS Received on Phone
Causes and solutions:
- Trial account restriction: Verify sending number is in Plivo Sandbox (Phone Numbers > Sandbox Numbers)
- Carrier filtering: Some carriers block shortcodes or unfamiliar numbers. Test with different carriers.
- Invalid E.164 format: Ensure destination number starts with
+and country code - Plivo account balance: Check account balance in Console (negative balance suspends sending)
- Number not SMS-capable: Verify purchased number supports SMS in destination country
Issue: Messages Not Appearing in Frontend
Solutions:
- Check browser console for CORS errors
- Verify Vite proxy configuration in
vite.config.js - Ensure backend
/api/messagesendpoint returns data:bashcurl http://localhost:3001/api/messages - 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:
// 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:
- MMS supported in US, Canada, and select countries
- File size limits vary by carrier (typically 500KB-3MB)
- Higher costs than SMS ($0.02-$0.04 per MMS segment)
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:
npm install openaiimport 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
npm install --save-dev jest supertestCreate 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.js
12. Monitoring and Observability
12.1 Application Performance Monitoring (APM)
Integrate Datadog, New Relic, or Sentry:
npm install @sentry/nodeimport * 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.log with Winston or Pino:
npm install winstonimport 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 /health every 5 minutes.
13. Compliance and Legal Considerations
13.1 TCPA Compliance (US)
The Telephone Consumer Protection Act regulates automated SMS:
- Opt-In Required: Obtain explicit consent before sending marketing messages
- Opt-Out Mechanism: Honor STOP/UNSUBSCRIBE immediately (within 1 business day)
- Identification: Include sender identity and contact info in initial messages
- Time Restrictions: No messages before 8 AM or after 9 PM recipient's local time
- Recordkeeping: Maintain consent records for 4 years minimum
Violation penalties: Up to $1,500 per message.
13.2 GDPR Compliance (EU)
If serving EU users:
- Data Minimization: Only collect necessary data (phone number, message content)
- Right to Erasure: Implement user data deletion on request
- Data Processing Agreement: Execute DPA with Plivo (available in Console)
- Consent Management: Document explicit consent for SMS communications
13.3 Other Regulations
- CASL (Canada): Similar to TCPA, requires explicit consent
- Privacy Act (Australia): Opt-in for marketing, opt-out mechanism required
- UK PECR: Electronic communications regulations similar to GDPR
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:
- 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:
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:
- ✅ 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
- Deploy to production and configure custom domain
- Implement MMS support for multimedia messaging
Related Resources:
- How to Send SMS with Node.js and Plivo - Learn the basics of sending SMS
- Plivo SMS API Documentation - Official API reference
- Node.js SMS Gateway Setup Guide - Alternative SMS gateway options
- Building SMS Applications with Express - Deep dive into Express SMS apps
Resources:
For production support and volume pricing, contact Plivo Sales.
Frequently Asked Questions
How to send SMS with Node.js and Express?
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.
What is the Vonage Node.js SDK?
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.
Why does Vonage require a virtual number?
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.
When should I use the Vonage Messages API?
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.
Can I send SMS to any number with a Vonage trial account?
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.
How to set up a Node.js project for sending SMS?
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.
What is the purpose of the .env file in the Node.js SMS project?
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.
How to handle Vonage API errors in my Node.js app?
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.
How to validate phone numbers for SMS sending with Vonage?
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.
What is the recommended way to secure my Vonage API credentials?
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.
How to implement rate limiting for the send SMS endpoint?
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.
How to troubleshoot "Non Whitelisted Destination" errors with Vonage?
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.
Why am I getting "Invalid Sender Address" errors from the Vonage API?
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').
What are the next steps after setting up a basic Node.js SMS service?
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.