code examples

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

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

[User Phone] <--> [Plivo Platform] <--> [Express Backend] <--> [Vite Frontend] | | [Webhook Handler] [React/Vue UI] [XML Response] [Message Display] [Signature Validation] [API Polling/WebSocket]
  1. 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
  2. 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)
  • 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

Step 1.2: Install Backend Dependencies

bash
npm install express plivo dotenv
npm install --save-dev nodemon
  • express: Web framework for Node.js
  • plivo: Official Plivo Node.js SDK (v4.74.0)
  • dotenv: Environment variable management
  • nodemon: Auto-restarts server on file changes (development only)

Step 1.3: Configure Environment Variables

Create .env in the backend/ directory:

env
# .env
PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
PLIVO_FROM_NUMBER=+15551234567
PORT=3001

# Security
NODE_ENV=development

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.

Create .gitignore:

text
# .gitignore
node_modules
.env
*.log
.DS_Store
dist

Step 1.4: Create Express Server with Webhook Handler

Create backend/server.js:

javascript
// 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 testing

Step 1.5: Update package.json

Add "type": "module" and scripts to backend/package.json:

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:

bash
npm run dev

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

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:

bash
cd ../frontend
npm create vite@latest . -- --template react
npm install
npm install axios

Create frontend/src/App.jsx:

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:

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:

javascript
// 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:

bash
npm run dev

Option B: Vue Frontend

Alternatively, create a Vue 3 app:

bash
cd ../frontend
npm create vite@latest . -- --template vue
npm install
npm install axios

Create frontend/src/App.vue:

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:

javascript
// 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:

bash
ngrok http 3001

Output 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

  1. Log in to Plivo Console
  2. Navigate to Messaging > Applications > XML Applications
  3. Click Add New Application
  4. Application Name: SMS Webhook Handler
  5. Message URL: Paste your ngrok URL + /api/receive_sms
    • Example: https://xxxx-xx-xx-xx-xx.ngrok-free.app/api/receive_sms
  6. Method: POST
  7. Message Method: POST (default)
  8. Click Create Application

Step 3.3: Assign Application to Your Plivo Number

  1. Go to Phone Numbers > Your Numbers
  2. Click on your SMS-enabled number
  3. Under Application Type, select XML Application
  4. Under Plivo Application, select SMS Webhook Handler
  5. 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:

  1. Ensure backend is running: npm run dev in backend/
  2. Ensure frontend is running: npm run dev in frontend/
  3. Ensure ngrok is exposing port 3001
  4. Send an SMS to your Plivo number from your mobile phone (must be verified in Sandbox if using trial account)
  5. Watch terminal logs for webhook POST request
  6. Check frontend browser—message should appear within 3 seconds (polling interval)
  7. You should receive an auto-reply SMS on your phone

Test Outbound SMS:

  1. In the frontend UI, enter a verified phone number (E.164 format)
  2. Type a message and click "Send SMS"
  3. Message appears in the message list
  4. 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_TOKEN in .env matches 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:

  1. Plivo generates HMAC signature: HMAC-SHA256(Auth Token, URL + Nonce + Body)
  2. Signature sent in X-Plivo-Signature-V3 header
  3. Your server recomputes signature and compares
  4. 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 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:

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

bash
npm install validator
javascript
import 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:

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

  1. Install Railway CLI: npm i -g @railway/cli
  2. railway login
  3. railway init
  4. Set environment variables in Railway dashboard
  5. 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:

javascript
const API_BASE = import.meta.env.PROD
  ? 'https://your-backend.herokuapp.com/api'
  : 'http://localhost:3001/api';

Option B: Netlify

bash
npm run build
netlify deploy --prod --dir=dist

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

bash
npm install @prisma/client
npm install -D prisma
npx prisma init

Define schema in prisma/schema.prisma:

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:

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

bash
npm install socket.io
javascript
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:

bash
npm install socket.io-client
javascript
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:

bash
npm install redis
javascript
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):

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

  1. Verify PLIVO_AUTH_TOKEN in .env matches Plivo Console exactly
  2. Restart backend after changing .env
  3. Check ngrok URL in Plivo application matches exactly (including /api/receive_sms path)
  4. If behind proxy, ensure X-Forwarded-Proto and X-Forwarded-Host headers are correctly set
  5. Test signature validation by temporarily logging the reconstructed URL:
javascript
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:

  1. Check browser console for CORS errors
  2. Verify Vite proxy configuration in vite.config.js
  3. Ensure backend /api/messages endpoint returns data:
    bash
    curl http://localhost:3001/api/messages
  4. Check that messages are being stored (add console.log in webhook handler)
  5. 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).

Solution: Detect encoding and warn users:

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

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

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

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

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

bash
npm install openai
javascript
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

bash
npm install --save-dev jest supertest

Create backend/server.test.js:

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

bash
npm test

11.2 Integration Testing with Plivo

Use Plivo's test credentials (available in Console) to send messages without charges:

javascript
// 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:

javascript
// 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:

bash
npm install @sentry/node
javascript
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.log with Winston or Pino:

bash
npm install winston
javascript
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:

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

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
  • Deploy to production and configure custom domain
  • Implement MMS support for multimedia messaging

Related Resources:

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.