messaging channels
messaging channels
How to Build Two-Way SMS Messaging with Vonage API, React & Node.js
A guide to building a web application for real-time, two-way SMS conversations using Vonage Messages API, React, Vite, Node.js, Express, and Socket.IO.
Learn how to build a real-time, two-way SMS messaging application using the Vonage Messages API, React, and Node.js. This comprehensive tutorial shows you how to send and receive SMS messages through a web interface with instant delivery via WebSocket connections.
This pattern works well for customer support dashboards, notification management systems, team communication tools, and any application requiring bidirectional SMS communication. The architecture separates concerns: a React frontend powered by Vite provides fast development, while a Node.js backend using Express handles Vonage webhooks and API interactions. WebSockets (socket.io) bridge the backend and frontend for real-time updates.
Estimated completion time: 90–120 minutes (excluding Vonage account setup).
Project Goals:
- Receive inbound SMS messages sent to a Vonage number
- Display received messages instantly on a web frontend
- Send outbound SMS messages from the web frontend via the Vonage API
- Provide a foundation for building more complex conversational applications
Technologies Used:
- Vonage Messages API: Sends and receives SMS messages. Uses @vonage/server-sdk 3.24.x. Learn the basics in our Vonage SMS sending guide.
- Node.js & Express: Backend framework to handle API requests and webhooks. Requires Node.js 20.19+ or 22.12+ (Node.js 18 reached EOL April 2025).
- React & Vite: Frontend library and build tool for the user interface. Uses Vite 7.0 with ESM-only distribution.
- Socket.IO: Enables real-time, bidirectional communication between server and client. Uses Socket.IO 4.8.1 (requires Node.js 14+). We chose Socket.IO over native WebSockets for its automatic reconnection, room support, and fallback mechanisms. For simpler one-way updates, Server-Sent Events (SSE) work well, but two-way messaging requires WebSockets or polling. Review WebSocket security best practices for production deployments.
- Ngrok (for local development): Exposes your local server to the internet for Vonage webhooks. Requires free account signup for authtoken.
- Render (example deployment): Cloud platform for deploying the backend and frontend.
Architecture:
[User's Phone] <---- SMS ----> [Vonage Platform] <---- Webhook/API ----> [Node.js Backend (Express)]
^ |
|-------------------------------- SMS -------------------------------------|
| (WebSocket)
V
[React Frontend (Vite)] <----> [Developer/User]Data Flow:
| Step | Flow Description |
|---|---|
| Inbound SMS | User sends SMS → Vonage receives it → Vonage POSTs to your webhook → Backend broadcasts via WebSocket → Frontend displays message |
| Outbound SMS | User types in frontend → Frontend POSTs to backend API → Backend calls Vonage API → Backend broadcasts via WebSocket → Frontend displays sent message → Vonage delivers SMS |
Edge Cases:
- WebSocket disconnection: Frontend shows "Disconnected" status. Messages sent during disconnection won't appear until reconnection.
- No connected clients: Backend always responds to Vonage webhooks with 200 OK to prevent retries, even if no clients are connected.
- Webhook failures: Vonage retries failed webhooks. Check the Vonage Dashboard logs for delivery attempts.
Prerequisites:
- Node.js 20.19+ or 22.12+ and npm installed (Node.js 18 reached EOL April 2025)
- A Vonage Developer Account (Sign up for free credit)
- Intermediate understanding of JavaScript, React, Node.js, and REST APIs
ngrokinstalled for local development webhook testing (requires free account for authtoken)- A code editor (e.g., VS Code)
- Git installed
Skill Level: Intermediate (comfortable with React hooks, Express routing, and async/await patterns). Estimated Time: 90–120 minutes for complete setup and testing.
Step 1: Set Up Your Node.js Backend Server
Set up the Node.js backend server to handle Vonage interactions and manage real-time WebSocket connections.
1. Create Project Directory:
mkdir vonage-sms-app
cd vonage-sms-app
mkdir backend
cd backend2. Initialize Node.js Project:
npm init -y3. Install Dependencies:
| Package | Purpose |
|---|---|
express | Web framework for routing and middleware |
dotenv | Loads environment variables from a .env file |
@vonage/server-sdk | Vonage Node.js SDK (version 3.24.x) for sending SMS |
body-parser | Parses incoming JSON and URL-encoded request bodies |
socket.io | Enables WebSocket communication (version 4.8.1) |
cors | Enables Cross-Origin Resource Sharing for frontend communication |
npm install express dotenv @vonage/server-sdk body-parser socket.io cors4. Create Server File:
Create a file named server.js.
// backend/server.js
require('dotenv').config();
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const bodyParser = require('body-parser');
const cors = require('cors');
const { Vonage } = require('@vonage/server-sdk');
// Import the specific message type class (adjust path based on SDK version if needed)
const { SMS } = require('@vonage/server-sdk/dist/classes/Messages/SMS');
// -----------------------------------------------------------------------------
// Vonage Configuration
// -----------------------------------------------------------------------------
const vonage = new Vonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET,
applicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: process.env.VONAGE_PRIVATE_KEY_PATH // Path to your private key file
});
// Validate configuration on startup
if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
console.error('ERROR: VONAGE_API_KEY and VONAGE_API_SECRET must be set in environment variables.');
process.exit(1);
}
if (!process.env.VONAGE_APPLICATION_ID || !process.env.VONAGE_PRIVATE_KEY_PATH) {
console.error('ERROR: VONAGE_APPLICATION_ID and VONAGE_PRIVATE_KEY_PATH must be set in environment variables.');
process.exit(1);
}
// -----------------------------------------------------------------------------
// Express App Setup
// -----------------------------------------------------------------------------
const app = express();
const server = http.createServer(app);
const PORT = process.env.PORT || 3001;
// Ensure FRONTEND_URL is set in your .env for CORS
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; // Default for Vite dev
app.use(cors({
origin: frontendUrl, // Allow requests only from your frontend URL
methods: ['GET', 'POST'],
}));
app.use(bodyParser.json()); // Parse JSON bodies
app.use(bodyParser.urlencoded({ extended: true })); // Parse URL-encoded bodies
// Enhanced health check endpoint
app.get('/health', (req, res) => {
const healthCheck = {
status: 'OK',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
vonageConfigured: !!(process.env.VONAGE_API_KEY && process.env.VONAGE_APPLICATION_ID)
};
res.status(200).json(healthCheck);
});
// -----------------------------------------------------------------------------
// WebSocket (Socket.IO) Setup
// -----------------------------------------------------------------------------
const io = new Server(server, {
cors: {
origin: frontendUrl, // Allow connections only from your frontend URL
methods: ['GET', 'POST']
},
pingTimeout: 60000, // Increase timeout for unstable connections
pingInterval: 25000
});
io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
socket.on('disconnect', (reason) => {
console.log('Client disconnected:', socket.id, '– Reason:', reason);
});
socket.on('error', (error) => {
console.error('Socket error:', error);
});
// Optional: Handle other events from the client if needed
});
// Handle Socket.IO errors
io.engine.on('connection_error', (err) => {
console.error('Connection error:', err);
});
// Function to emit messages to all connected clients
// Note: In production, consider using rooms for multi-user scenarios
function broadcastSms(messageData) {
io.emit('sms_received', messageData); // Event name can be customized
console.log('Emitted message to clients:', messageData);
}
// -----------------------------------------------------------------------------
// Vonage Webhook Endpoint (Inbound SMS)
// -----------------------------------------------------------------------------
app.post('/webhooks/inbound-sms', (req, res) => {
console.log('Inbound SMS Webhook Received:');
console.log('Body:', JSON.stringify(req.body, null, 2)); // Log the full body for debugging
// Destructure expected fields. Note: Vonage payloads include more fields like type, channel, etc.
const { msisdn, to, text, messageId, timestamp } = req.body;
if (!msisdn || !to || !text) {
console.error('Incomplete SMS data received');
// Still send 200 OK to Vonage to acknowledge receipt, but log error
return res.status(200).end();
}
const messageData = {
from: msisdn, // The sender's number
to: to, // Your Vonage number
text: text, // The message content
messageId: messageId, // Unique message ID from Vonage
timestamp: timestamp, // Timestamp from Vonage
direction: 'inbound' // Mark message direction
};
// Broadcast the received message via WebSocket
broadcastSms(messageData);
// IMPORTANT: Always respond to Vonage webhooks with a 200 OK
res.status(200).end();
});
// -----------------------------------------------------------------------------
// API Endpoint to Send Outbound SMS
// -----------------------------------------------------------------------------
app.post('/send-sms', async (req, res) => {
const { to, text } = req.body;
const from = process.env.VONAGE_NUMBER; // Your Vonage number
if (!to || !text) {
return res.status(400).json({ error: "Missing 'to' or 'text' in request body." });
}
if (!from) {
console.error('VONAGE_NUMBER environment variable not set.');
return res.status(500).json({ error: 'Server configuration error.' });
}
// Basic E.164 format validation
const e164Regex = /^\+[1-9]\d{1,14}$/;
if (!e164Regex.test(to)) {
return res.status(400).json({
error: 'Invalid phone number format. Use E.164 format: +[country code][number] (e.g., +12015550123).'
});
}
console.log(`Attempting to send SMS from ${from} to ${to}: "${text}"`);
try {
const resp = await vonage.messages.send(
new SMS(
{
to: to,
from: from,
text: text,
// clientRef: `my-app-${Date.now()}` // Optional client reference
}
)
);
console.log('Vonage API Response:', resp); // Includes message_uuid
// Also broadcast the sent message to update the UI
const sentMessageData = {
from: from,
to: to,
text: text,
messageId: resp.messageUuid, // Use the UUID returned by Vonage
timestamp: new Date().toISOString(),
direction: 'outbound'
};
broadcastSms(sentMessageData);
res.status(200).json({ success: true, messageUuid: resp.messageUuid });
} catch (error) {
console.error('Error sending SMS via Vonage:', error);
// Log more detailed error if available
if (error.response && error.response.data) {
console.error('Vonage Error Details:', error.response.data);
}
// Common Vonage error codes:
// - 1320: Invalid number format
// - 1330: Number not reachable
// - 9: Partner quota exceeded
const errorMessage = error.response?.data?.title || error.message || 'Failed to send SMS.';
res.status(500).json({ success: false, error: errorMessage, details: error.message });
}
});
// -----------------------------------------------------------------------------
// Start Server
// -----------------------------------------------------------------------------
server.listen(PORT, () => {
console.log(`Backend server listening on port ${PORT}`);
console.log(`CORS enabled for origin: ${frontendUrl}`);
});5. Create Environment File:
Create a file named .env in the backend directory. Never commit this file to Git.
# backend/.env
# Vonage Credentials
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Or the full path to your downloaded key
# Must be in E.164 format: +[country code][number], e.g., +12015550123
VONAGE_NUMBER=YOUR_VONAGE_PHONE_NUMBER
# Server Configuration
PORT=3001
# Frontend URL (Update for development/production)
FRONTEND_URL=http://localhost:5173 # Default Vite dev server URLWhere to find these credentials:
- API Key & Secret: Available on the main Vonage Dashboard after signup
- Application ID: Generated when you create a Vonage Application (see Section 2)
- Private Key: Downloaded when you create the Vonage Application (see Section 2)
- Vonage Number: Purchase in the Dashboard under Numbers → Buy Numbers (see Section 2)
Create a .env.example file to track required variables:
# backend/.env.example
# Vonage Credentials
VONAGE_API_KEY=
VONAGE_API_SECRET=
VONAGE_APPLICATION_ID=
VONAGE_PRIVATE_KEY_PATH=
# Must be in E.164 format: +[country code][number], e.g., +12015550123
VONAGE_NUMBER=
# Server Configuration
PORT=3001
# Frontend URL (Update for development/production)
FRONTEND_URL=http://localhost:5173Note: Format your VONAGE_NUMBER in E.164 format – the international phone number standard (e.g., +14155552671).
6. Add .gitignore:
Create a .gitignore file in the backend directory:
# backend/.gitignore
node_modules
.env
*.log
private.key # Ensure your private key is not committedStep 2: Configure Your Vonage Account for Two-Way SMS
Configure your Vonage account to connect it to your backend and enable inbound SMS webhooks. (Note: The Vonage Dashboard UI may change over time, so exact button names or locations might differ slightly.)
1. Get Vonage Credentials:
* Sign up at Vonage API if you haven't already. New accounts receive free credit.
* Log in to the Vonage API Dashboard.
* Find your API Key and API Secret on the main dashboard page. Add these to your backend/.env file.
2. Buy a Vonage Number:
* Navigate to Numbers → Buy Numbers.
* Select your country.
* Ensure the number has SMS capability. The Voice capability is often bundled but isn't required for this SMS-only application.
* Search and buy a number. Numbers typically cost $0.90–$3.00/month depending on the country. Add it to backend/.env as VONAGE_NUMBER in E.164 format.
* Pricing: Outbound SMS pricing varies by destination country. Check Vonage SMS pricing for rates. US domestic SMS typically costs $0.0075–$0.0110 per message.
3. Create a Vonage Application:
* Navigate to Applications → Create a new application.
* Give your application a name (e.g., "My Two-Way SMS App").
* Generate public and private key: Click this button. A private.key file downloads. This key authenticates your application with Vonage's API. Move this file into your backend directory (or provide the correct path in VONAGE_PRIVATE_KEY_PATH in your .env). Keep this key secure – treat it like a password!
* Capabilities:
* Toggle Messages ON.
* Inbound URL: Leave blank for now (update during testing/deployment).
* Status URL: Leave blank for now (update during testing/deployment).
* Toggle Voice OFF unless you plan to add voice features later.
* Click Generate new application.
* Find the Application ID and add it to your backend/.env file.
4. Link Your Number: * Scroll down on the application details page to Linked numbers. * Click Link next to the Vonage number you purchased.
5. Configure API Settings (Optional but Recommended): * Navigate to API Settings from the left menu. * Under SMS Settings → Default SMS Setting, ensure "Deliver inbound message via: Your message webhook URL defined in application settings" is selected (this is usually the default).
Step 3: Test Webhooks Locally with Ngrok
Vonage needs a publicly accessible URL to send inbound SMS webhooks. ngrok creates a secure tunnel to your local machine for development testing.
Important: Ngrok requires a free account signup to obtain an authtoken. Sign up at ngrok.com and configure your authtoken:
ngrok config add-authtoken <your-auth-token>Alternatives to ngrok: localtunnel, serveo, Cloudflare Tunnel.
1. Start Your Backend Server:
# In the backend directory
node server.jsYou should see "Backend server listening on port 3001".
Common startup errors:
- Port already in use: Another process uses port 3001. Change
PORTin.envor kill the existing process. - Module not found: Run
npm installto install dependencies. - Missing environment variables: Check that all required variables are set in
.env.
2. Start Ngrok:
Open a new terminal window and run:
ngrok http 3001 # Use the port your backend is running onNgrok displays output similar to this:
Session Status online
Account Your Name (Plan: Free)
Version x.x.x
Region United States (us)
Forwarding http://xxxxxxxx.ngrok.io -> http://localhost:3001
Forwarding https://xxxxxxxx.ngrok.io -> http://localhost:3001
Web Interface http://127.0.0.1:4040Note: The free tier includes 1 static domain, request inspection, and email support. As of 2025, ngrok provides free static domains to free users, eliminating the need to update webhook URLs on every restart.
3. Update Vonage Webhook URL:
* Copy the HTTPS Forwarding URL provided by ngrok (e.g., https://xxxxxxxx.ngrok.io).
* Go back to your Vonage Application settings (Applications → Your App → Edit).
* In the Messages capability section:
* Inbound URL: Paste the ngrok HTTPS URL followed by your webhook path: https://xxxxxxxx.ngrok.io/webhooks/inbound-sms
* Status URL: For now, use the same URL: https://xxxxxxxx.ngrok.io/webhooks/inbound-sms. In production, create a separate /webhooks/status endpoint to handle delivery receipts (submitted, delivered, rejected, failed statuses).
* Click Save changes.
4. Test Inbound SMS:
* Send an SMS message from your personal phone to your Vonage number.
* Watch the terminal where node server.js is running. You should see the "Inbound SMS Webhook Received:" log message with the SMS details.
* Check the terminal where ngrok is running. You should see a POST /webhooks/inbound-sms 200 OK request logged.
If you see the logs, your backend and webhook setup work correctly!
Troubleshooting webhooks:
| Problem | Solution |
|---|---|
| No webhook received | Verify the URL in Vonage matches ngrok's HTTPS URL exactly. Check that your server is running. |
| 404 Not Found | Verify the path /webhooks/inbound-sms matches your Express route. |
| 500 Internal Server Error | Check backend logs for errors. Verify environment variables are set. |
| Ngrok connection refused | Ensure backend is running on the correct port before starting ngrok. |
Step 4: Build Your React Frontend with Vite
Now, build the user interface for sending and receiving SMS messages in real-time.
1. Create React Project:
Navigate back to the root vonage-sms-app directory in your terminal.
# In the vonage-sms-app directory
npm create vite@latest frontend -- --template react
cd frontend
npm install2. Install Frontend Dependencies:
socket.io-client: The client library for Socket.IO.
npm install socket.io-client3. Basic Styling (Optional):
You can add some basic styles to src/index.css or src/App.css.
/* src/index.css (example additions) */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f4f7f6;
}
#root {
max-width: 800px;
margin: 40px auto;
padding: 20px;
background-color: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 8px;
}
/* Add more specific styles in App.css */4. Implement React Component (App.jsx):
Replace the contents of src/App.jsx with the following:
// frontend/src/App.jsx
import React, { useState, useEffect, useRef, useCallback } from 'react';
import io from 'socket.io-client';
import './App.css'; // Create this file for custom styles
// Ensure this URL matches your backend (adjust for dev/prod)
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
const socket = io(BACKEND_URL, {
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5
}); // Connect to the backend WebSocket
function App() {
const [messages, setMessages] = useState([]);
const [recipient, setRecipient] = useState(''); // Store the number to reply to
const [newMessage, setNewMessage] = useState('');
const [isConnected, setIsConnected] = useState(socket.connected);
const [isSending, setIsSending] = useState(false);
const [error, setError] = useState('');
const messagesEndRef = useRef(null); // To auto-scroll
// Scroll to bottom whenever messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Handle WebSocket connection events
useEffect(() => {
socket.on('connect', () => {
console.log('WebSocket Connected:', socket.id);
setIsConnected(true);
setError(''); // Clear any connection errors
});
socket.on('disconnect', (reason) => {
console.log('WebSocket Disconnected:', reason);
setIsConnected(false);
if (reason === 'io server disconnect') {
// Server disconnected, manual reconnection needed
socket.connect();
}
});
socket.on('connect_error', (err) => {
console.error('Connection Error:', err);
setError('Unable to connect to server. Retrying…');
setIsConnected(false);
});
// Listen for incoming/outgoing SMS messages from the backend
socket.on('sms_received', (message) => {
console.log('Received message via WebSocket:', message);
setMessages((prevMessages) => [...prevMessages, message]);
// Auto-populate recipient if it's an inbound message
// and we don't have a recipient set yet, or it's a new conversation
if (message.direction === 'inbound' && (!recipient || recipient !== message.from)) {
// Simple logic: reply to the last inbound sender
setRecipient(message.from);
console.log(`Recipient auto-set to: ${message.from}`);
}
});
// Clean up listeners on component unmount
return () => {
socket.off('connect');
socket.off('disconnect');
socket.off('connect_error');
socket.off('sms_received');
};
}, [recipient]); // Re-run effect if recipient changes (for auto-population logic)
// Handle sending a message
const handleSendMessage = useCallback(async (e) => {
e.preventDefault(); // Prevent form submission reload
if (!newMessage.trim() || !recipient.trim()) {
setError('Enter a recipient phone number and a message.');
return;
}
// Basic E.164 format validation
const e164Regex = /^\+[1-9]\d{1,14}$/;
if (!e164Regex.test(recipient)) {
setError('Invalid phone number format. Use E.164 format: +[country code][number] (e.g., +12015550123).');
return;
}
setError(''); // Clear previous errors
setIsSending(true);
console.log(`Sending message to ${recipient}: "${newMessage}"`);
try {
const response = await fetch(`${BACKEND_URL}/send-sms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: recipient,
text: newMessage,
}),
});
const result = await response.json();
if (response.ok && result.success) {
console.log('Message sent successfully via API:', result);
setNewMessage(''); // Clear the input field
// Note: The message will appear in the list via the WebSocket 'sms_received' event
} else {
console.error('Failed to send message:', result);
setError(`Failed to send message: ${result.error || 'Unknown error'}`);
}
} catch (error) {
console.error('Network or server error sending message:', error);
setError(`Error sending message: ${error.message}`);
} finally {
setIsSending(false);
}
}, [newMessage, recipient]); // Dependencies for useCallback
// Format timestamp for display
const formatTimestamp = (isoString) => {
if (!isoString) return '';
try {
return new Date(isoString).toLocaleString();
} catch (e) {
return isoString; // Fallback
}
};
// Calculate character count and SMS segments
const charCount = newMessage.length;
const segmentSize = 160; // Standard SMS segment size
const segments = Math.ceil(charCount / segmentSize) || 1;
return (
<div className="App">
<h1>Vonage Two-Way SMS</h1>
<p>
Status: <span className={isConnected ? 'connected' : 'disconnected'}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</p>
{error && <div className="error-message">{error}</div>}
<div className="message-list">
{messages.length === 0 && <p className="empty-state">No messages yet. Send an SMS to your Vonage number!</p>}
{messages.map((msg, index) => (
<div key={msg.messageId || index} className={`message ${msg.direction}`}>
<span className="sender">
{msg.direction === 'inbound' ? `From: ${msg.from}` : `To: ${msg.to}`}
</span>
<p>{msg.text}</p>
<span className="timestamp">{formatTimestamp(msg.timestamp)}</span>
</div>
))}
<div ref={messagesEndRef} /> {/* Anchor for scrolling */}
</div>
<form className="message-form" onSubmit={handleSendMessage}>
<div className="form-group">
<label htmlFor="recipient">Recipient:</label>
<input
type="tel"
id="recipient"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder="Enter phone number (e.g., +12015550124)"
required
aria-label="Recipient phone number"
/>
</div>
<div className="form-group">
<label htmlFor="message-text">Message:</label>
<textarea
id="message-text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Type your message…"
rows={3}
required
aria-label="Message content"
/>
<div className="character-count">
{charCount} characters | {segments} SMS {segments === 1 ? 'segment' : 'segments'}
</div>
</div>
<button
type="submit"
disabled={!isConnected || !recipient || !newMessage || isSending}
aria-label="Send SMS"
>
{isSending ? 'Sending…' : 'Send SMS'}
</button>
</form>
</div>
);
}
export default App;5. Add CSS (App.css):
Create src/App.css and add some styles:
/* frontend/src/App.css */
.App {
display: flex;
flex-direction: column;
height: calc(100vh - 80px); /* Adjust based on root padding */
max-height: 80vh; /* Limit height */
}
h1 {
text-align: center;
color: #333;
margin-bottom: 20px;
}
p {
text-align: center;
margin-bottom: 20px;
}
.connected {
color: green;
font-weight: bold;
}
.disconnected {
color: red;
font-weight: bold;
}
.error-message {
background-color: #fee;
border: 1px solid #f66;
color: #c00;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
text-align: center;
}
.message-list {
flex-grow: 1;
overflow-y: auto;
border: 1px solid #eee;
padding: 15px;
margin-bottom: 20px;
background-color: #fdfdfd;
border-radius: 4px;
}
.empty-state {
color: #888;
text-align: center;
font-style: italic;
}
.message {
margin-bottom: 15px;
padding: 10px 15px;
border-radius: 18px;
max-width: 70%;
word-wrap: break-word;
}
.message.inbound {
background-color: #e1f5fe; /* Light blue */
margin-right: auto; /* Align left */
border-bottom-left-radius: 4px;
}
.message.outbound {
background-color: #dcedc8; /* Light green */
margin-left: auto; /* Align right */
text-align: right;
border-bottom-right-radius: 4px;
}
.message .sender {
display: block;
font-size: 0.8em;
font-weight: bold;
color: #555;
margin-bottom: 4px;
}
.message p {
margin: 0;
padding: 0;
text-align: left; /* Keep text aligned left regardless of bubble position */
}
.message .timestamp {
display: block;
font-size: 0.75em;
color: #888;
margin-top: 5px;
}
/* Anchor for scrolling */
.message-list div[ref] {
height: 0;
}
.message-form {
display: flex;
flex-direction: column;
gap: 15px; /* Spacing between elements */
border-top: 1px solid #eee;
padding-top: 20px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 5px;
font-weight: bold;
font-size: 0.9em;
color: #333;
}
.message-form input[type="tel"],
.message-form textarea {
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1em;
}
.message-form textarea {
resize: vertical; /* Allow vertical resize */
}
.character-count {
font-size: 0.85em;
color: #666;
margin-top: 5px;
text-align: right;
}
.message-form button {
padding: 12px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
transition: background-color 0.2s ease;
}
.message-form button:hover:not(:disabled) {
background-color: #0056b3;
}
.message-form button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}6. Configure Vite Environment Variables:
Vite uses VITE_ prefix for environment variables exposed to the frontend. Create .env and .env.example files in the frontend directory.
# frontend/.env
VITE_BACKEND_URL=http://localhost:3001 # Your local backend URL# frontend/.env.example
VITE_BACKEND_URL=http://localhost:30017. Run Frontend Development Server:
# In the frontend directory
npm run devVite will start the development server, typically at http://localhost:5173. Open this URL in your browser.
Step 5: Test Your Two-Way SMS Application End-to-End
With both backend and frontend running (and ngrok tunneling to your backend):
- Inbound Test: Send an SMS from your phone to your Vonage number.
- Expected: The message should appear in the React app's message list within 1–3 seconds. The "Recipient" field should auto-populate with your phone number.
- Outbound Test:
- Verify the "Recipient" field contains your personal phone number (or enter it).
- Type a message in the text area.
- Click "Send SMS".
- Expected: The message should appear in the React app's message list (marked as outbound) within 1 second. You should receive the SMS on your personal phone within 5–30 seconds (varies by carrier). Check the backend console for success logs from Vonage.
Expected latency:
- Webhook delivery: 1–3 seconds from SMS sent to webhook received
- WebSocket broadcast: <100ms from webhook to frontend display
- SMS delivery: 5–30 seconds from API call to recipient's phone (carrier-dependent)
Debugging failed tests:
| Test Failure | Check These Items |
|---|---|
| Inbound message not appearing | Verify webhook URL in Vonage. Check backend logs. Confirm WebSocket connected in UI. |
| Outbound message not sent | Check backend logs for Vonage API errors. Verify credentials. Confirm account has credit. |
| Messages appear in UI but not on phone | Check Vonage Dashboard logs. Verify recipient number format. |
| WebSocket disconnected | Check FRONTEND_URL and VITE_BACKEND_URL match. Review CORS settings. |
How to Secure Your Two-Way SMS Application
- API Credentials: Never hardcode API keys, secrets, or application IDs. Use environment variables (
.envfiles locally, platform environment variables in production) and ensure.envis in your.gitignore. Keep yourprivate.keyfile secure and ensure it's not committed to Git. - Input Validation: Sanitize and validate input on the backend. The example includes basic E.164 validation. For production, use libraries like
joiorexpress-validator:
// Example with express-validator
const { body, validationResult } = require('express-validator');
app.post('/send-sms', [
body('to').matches(/^\+[1-9]\d{1,14}$/).withMessage('Invalid E.164 phone number'),
body('text').trim().isLength({ min: 1, max: 1600 }).withMessage('Message must be 1-1600 characters'),
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// ... rest of handler
});- Rate Limiting: Implement rate limiting on the
/send-smsendpoint usingexpress-rate-limit:
const rateLimit = require('express-rate-limit');
const sendSmsLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10, // Limit each IP to 10 requests per minute
message: 'Too many SMS requests, please try again later.',
standardHeaders: true,
legacyHeaders: false,
});
app.post('/send-sms', sendSmsLimiter, async (req, res) => {
// ... handler
});- Webhook Security: This guide skips implementing signed webhooks for brevity, but implement them for production. Vonage uses JWT (JSON Web Token) with HMAC-SHA256 signature for webhook authentication. The signature secret (minimum 32 bits recommended) is available in your Vonage Dashboard. Verify the JWT by:
- Extract the token from the
Authorization: Bearer <token>header - Decode the JWT using your signature secret
- Verify the
payload_hash(SHA-256 hash of the webhook payload) to prevent replay attacks - Check the
iat(issued at) timestamp claim
- Extract the token from the
Refer to the Vonage webhook security documentation for implementation details. Also review WebSocket security best practices for securing real-time connections.
- XSS Prevention: React automatically escapes content in JSX, preventing XSS attacks. Never use
dangerouslySetInnerHTMLwith user-provided content. - CORS: The
corsmiddleware is configured to only allow requests from your specificFRONTEND_URL. Ensure this is correctly set in production.
Error Handling and Monitoring Best Practices
- Backend: The current code includes basic
try...catchblocks for the Vonage API call and logs errors to the console. For production, use a dedicated logging library (likewinstonorpino) to structure logs and send them to a logging service:
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'error.log', level: 'error' }),
],
});
// Replace console.log with logger.info, console.error with logger.error
logger.info('Backend server listening on port ' + PORT);- Frontend: The frontend includes basic
try...catchfor thefetchcall and displays errors inline (no morealert()). Monitor WebSocket connection status and provide visual cues to the user. - Vonage Errors: Common Vonage API error codes:
| Error Code | Meaning | Solution |
|---|---|---|
| 1320 | Invalid number format | Verify E.164 format |
| 1330 | Number not reachable | Check if number is active |
| 9 | Partner quota exceeded | Add credit to account |
| 1001 | Authentication failed | Verify API key and secret |
How to Deploy Your Two-Way SMS App to Production (Render)
Render is a cloud platform suitable for deploying both Node.js backends and static frontends.
Note: Render's free tier has limitations including inactivity shutdowns (services spin down after 15 minutes of inactivity), 750 hours/month of runtime, and limited CPU/memory resources. For production applications with consistent traffic, upgrade to a paid plan ($7/month for Starter).
Alternative platforms: Vercel (frontend), Railway (backend), Fly.io (backend), Heroku (backend), Netlify (frontend).
1. Prepare for Deployment:
- Backend:
- Ensure
server.jsusesprocess.env.PORT. - Ensure
corsusesprocess.env.FRONTEND_URL. - Add a
startscript tobackend/package.json:json// backend/package.json "scripts": { "start": "node server.js", // ... other scripts },
- Ensure
- Frontend:
- The build command (
npm run build) generates static files in thefrontend/distdirectory. - Ensure
frontend/.env(or environment variables in Render) setsVITE_BACKEND_URLto your deployed backend URL.
- The build command (
2. Create render.yaml (Blueprint):
Create a file named render.yaml in the root (vonage-sms-app) directory. This defines both services.
# render.yaml (in root directory)
services:
# Backend Service (Node.js)
- type: web
name: sms-backend
env: node
rootDir: backend # Specify the subdirectory
plan: free # Or your desired plan (note: free tier has inactivity shutdowns)
buildCommand: npm install
startCommand: npm run start
envVars:
# Use Node.js 22 (20.19+ or 22.12+ required for Vite 7.0)
- key: NODE_VERSION
value: 22
# Reference secrets stored in Render Dashboard
- key: VONAGE_API_KEY
fromSecret: vonage_api_key
- key: VONAGE_API_SECRET
fromSecret: vonage_api_secret
- key: VONAGE_APPLICATION_ID
fromSecret: vonage_app_id
- key: VONAGE_NUMBER
fromSecret: vonage_number
# Store private key as a secret file in Render
- key: VONAGE_PRIVATE_KEY_PATH
value: /etc/secrets/private.key # Path for Render secret file
# Automatically use the deployed frontend URL
- key: FRONTEND_URL
fromService:
type: web
name: sms-frontend
property: url
# Frontend Service (Static Site)
- type: web
name: sms-frontend
env: static
rootDir: frontend # Specify the subdirectory
plan: free # Or your desired plan
buildCommand: npm install && npm run build
staticPublishPath: dist # Directory containing build output
envVars:
# Automatically use the deployed backend URL
- key: VITE_BACKEND_URL
fromService:
type: web
name: sms-backend
property: url
routes:
# Standard SPA rewrite rule for client-side routing
- type: rewrite
source: /*
destination: /index.htmlWhy each setting is needed:
rootDir: Points to the subdirectory containing each service's codeNODE_VERSION: 22: Ensures compatibility with Vite 7.0's ESM requirementsfromSecret: Securely references credentials stored in Render DashboardfromService: Automatically injects the deployed URL of the other serviceroutesrewrite: Ensures direct URL access works for SPAs
3. Deploy to Render:
- Push your code (including
render.yaml) to a GitHub/GitLab repository. - Log in to Render and create a new Blueprint Instance.
- Connect your repository. Render will detect
render.yaml. - Configure Secrets/Environment Variables:
- Go to the Environment section for the
sms-backendservice. - Create Secrets for
vonage_api_key,vonage_api_secret,vonage_app_id, andvonage_numberwith their respective values. - Private Key: Create a Secret File. Set the filename to
private.keyand the path to/etc/secrets/private.key(matching theVONAGE_PRIVATE_KEY_PATHinrender.yaml). Paste the contents of your localprivate.keyfile into the value field.
- Go to the Environment section for the
- Click Create Blueprint. Render will build and deploy both services.
Verify deployment:
- Check the Logs tab for each service to ensure they started successfully
- Visit the frontend URL and verify the UI loads
- Test the
/healthendpoint on the backend:https://sms-backend-xxxx.onrender.com/health - Send a test SMS to verify end-to-end functionality
4. Update Vonage Webhook URL (Final Step):
- Once the backend service (
sms-backend) is deployed, copy its public URL (e.g.,https://sms-backend-xxxx.onrender.com). - Go to your Vonage Application settings (Applications > Your App > Edit).
- Update the Messages capability URLs:
- Inbound URL:
https://sms-backend-xxxx.onrender.com/webhooks/inbound-sms - Status URL:
https://sms-backend-xxxx.onrender.com/webhooks/inbound-sms
- Inbound URL:
- Save changes.
Your application should now be live and functional! Access the frontend via its Render URL (e.g., https://sms-frontend-yyyy.onrender.com).
Common Issues and Troubleshooting Solutions
Webhooks Not Working:
| Symptom | Solution |
|---|---|
| No webhook received | Verify URL in Vonage matches your deployed URL (including https:// and path). Check server is running. |
| 404 Not Found | Verify path /webhooks/inbound-sms matches Express route exactly. |
| 500 Internal Server Error | Check backend logs for errors. Verify environment variables. |
| Firewall blocking | Check local firewall settings or cloud provider security groups. |
| Vonage service issues | Check Vonage Status Page. |
| Webhook timing out | Ensure backend responds within 1 second. Move slow operations to background jobs. |
Messages Not Sending:
| Problem | Cause | Solution |
|---|---|---|
| "Configuration error" | Missing credentials | Verify VONAGE_API_KEY, VONAGE_API_SECRET, VONAGE_APPLICATION_ID, and VONAGE_PRIVATE_KEY_PATH in environment. |
| "Invalid number" | Wrong format | Use E.164 format: +[country code][number]. |
| "Authentication failed" | Wrong credentials | Regenerate credentials in Vonage Dashboard. |
| "Quota exceeded" | No credit | Add credit to Vonage account. |
| Number not linked | Configuration issue | Link your number to the application in Vonage Dashboard. |
WebSocket Issues:
- CORS errors: Ensure
FRONTEND_URLin backend.envmatches the actual frontend URL (includinghttp/https). Check browser console for specific CORS errors. - Backend URL mismatch: Ensure
VITE_BACKEND_URLin frontend.envpoints to your running backend. - Server crash: WebSocket connections drop if backend crashes. Check logs and implement error handling.
- Network instability: Unstable networks cause disconnections. Socket.IO auto-reconnects by default.
Frontend UI Not Updating:
- WebSocket connection: Verify "Connected" status in UI. Check browser console for connection errors.
- Event mismatch: Ensure backend emits
sms_receivedand frontend listens for the same event. - State updates: Verify React state updates correctly (check with React DevTools).
Deployment Issues (Render):
- Build failures: Check build logs for missing dependencies or syntax errors. Verify
package.jsonscripts. - Start command issues: Ensure
startCommandinrender.yamlmatches yourpackage.jsonstart script. - Environment variables missing: Verify all secrets are configured in Render Dashboard.
- Private key path wrong: Ensure path matches between
render.yamland secret file configuration. - Free tier limitations: Services spin down after 15 minutes of inactivity. First request after spin-down takes 30–60 seconds.
Next Steps: Enhancing Your Two-Way SMS Application
Immediate Improvements (1–2 hours each):
- Conversation History (Medium): Persist messages to PostgreSQL, MongoDB, or SQLite. Load history when the app starts or a user connects. Use Render's PostgreSQL add-on or Supabase.
- Delivery Receipts (Easy): Implement
/webhooks/statusendpoint to track message delivery status (delivered,failed, etc.) and update UI with status indicators. - Better Error Messages (Easy): Display specific error messages from Vonage API instead of generic "Failed to send SMS."
Advanced Features (4–8 hours each):
- Multiple Conversations (Hard): Organize messages by conversation thread (based on the other party's number). Add a sidebar showing all active conversations.
- User Authentication (Medium): Implement JWT-based authentication so multiple users can access the app. Associate messages with specific users using Firebase Auth, Auth0, or Passport.js.
- Improved UI/UX (Medium): Add loading states, toast notifications (using
react-hot-toast), relative timestamps ("5 minutes ago"), typing indicators, message search. - Webhook Security (Medium): Implement Vonage signed webhook verification for production security.
Scalability Enhancements (8+ hours):
- Database Integration: Move from in-memory to PostgreSQL/MongoDB for message persistence.
- WebSocket Rooms: Use Socket.IO rooms to broadcast messages only to relevant users in multi-user scenarios.
- Backend Architecture: Implement worker queues (Bull/BullMQ) for handling high-volume SMS sending. Use Redis for caching and session management.
- Add Voice (Hard): Extend the application using the Vonage Voice API for click-to-call or voice notifications. Requires additional webhook endpoints and UI components.
Resources for enhancements: