This guide provides a complete walkthrough for building a production-ready Node.js application using the Express framework to send and receive both WhatsApp and SMS messages via the Vonage Messages API. We'll cover everything from initial project setup and core messaging logic to database integration, security best practices, error handling, deployment, and verification.
By the end of this tutorial, you will have a robust application capable of:
- Sending SMS and WhatsApp messages programmatically.
- Receiving incoming SMS and WhatsApp messages via webhooks.
- Storing message history in a database.
- Verifying incoming webhook requests for security.
- Handling errors gracefully.
Target Audience: Developers familiar with Node.js and basic web concepts looking to implement reliable two-way messaging capabilities in their applications.
Technologies Used:
- Node.js: JavaScript runtime environment.
- Express: Minimalist web framework for Node.js.
- Vonage Messages API: Unified API for sending and receiving messages across multiple channels (SMS, WhatsApp, MMS, etc.).
- Vonage Node SDK: Simplifies interaction with Vonage APIs.
- PostgreSQL: Robust open-source relational database.
- Prisma: Modern ORM for Node.js and TypeScript (used here for database interaction).
- ngrok: Tool to expose local servers to the internet for webhook testing.
- dotenv: Module to load environment variables from a
.env
file.
System Architecture
The basic flow of information is as follows:
- Outbound Messages: Your Node.js/Express application uses the Vonage Node SDK to send an API request to the Vonage Messages API, specifying the channel (SMS or WhatsApp), recipient, sender ID/number, and message content. Vonage handles the delivery to the end user.
- Inbound Messages: A user sends an SMS or WhatsApp message to your Vonage provisioned number/WhatsApp sender. Vonage receives the message and sends an HTTP POST request (webhook) to a predefined endpoint in your Express application (
/webhooks/inbound
). - Message Status Updates: Vonage sends status updates (e.g.,
delivered
,read
,failed
) about outbound messages to another predefined webhook endpoint in your application (/webhooks/status
). - Database Interaction: Your application interacts with a PostgreSQL database (via Prisma) to store records of inbound and outbound messages, along with their statuses.
[ User ] <---- SMS/WhatsApp ----> [ Vonage Platform ] <---- API Calls/Webhooks ----> [ Your Node.js/Express App ] <---- Prisma ----> [ PostgreSQL DB ]
^ |
|--- ( ngrok tunnel during dev ) ----|
Prerequisites:
- Vonage API Account: Sign up for free at Vonage. You'll get free credit to start.
- Node.js: Version 16 or higher recommended. Install from nodejs.org.
- npm or yarn: Package manager for Node.js (comes with Node.js).
- ngrok: Install from ngrok.com. A free account is sufficient for this guide.
- PostgreSQL Database: A running instance accessible to your application. You will need to set this up yourself, common methods include running it locally, using Docker, or utilizing a cloud database provider.
- Vonage CLI (Optional but Recommended): Install via npm:
npm install -g @vonage/cli
. Useful for managing applications and numbers.
1. Setting Up the Project
Let's initialize the project, install dependencies, and configure the basic structure.
1.1 Create Project Directory & Initialize:
Open your terminal and run the following commands:
# Create a new directory for your project
mkdir vonage-messaging-app
cd vonage-messaging-app
# Initialize a new Node.js project
npm init -y
1.2 Install Dependencies:
We need Express for the server, the Vonage SDK, Prisma for database interaction, dotenv
for environment variables, and the PostgreSQL driver.
# Install runtime dependencies
npm install express @vonage/server-sdk dotenv pg @prisma/client
# Install development dependencies (Prisma CLI, nodemon for auto-restarts)
npm install --save-dev prisma nodemon
1.3 Configure Prisma:
Initialize Prisma in your project. This creates a prisma
directory with a schema.prisma
file and a .env
file (if one doesn't exist).
npx prisma init --datasource-provider postgresql
1.4 Configure Environment Variables (.env
):
Prisma creates a basic .env
file. Open it and add the necessary variables for Vonage and your database. Crucially, obtain these values as described below the code block.
# .env
# Database Connection (Adjust based on your PostgreSQL setup)
# Example: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
DATABASE_URL="postgresql://your_db_user:your_db_password@localhost:5432/vonage_messages?schema=public"
# Vonage API Credentials
VONAGE_API_KEY="YOUR_VONAGE_API_KEY"
VONAGE_API_SECRET="YOUR_VONAGE_API_SECRET"
# Vonage Application Credentials
VONAGE_APPLICATION_ID="YOUR_VONAGE_APPLICATION_ID"
# Path relative to your project root where you save the private key
VONAGE_PRIVATE_KEY_PATH="./private.key"
# Vonage Numbers / Senders
# Your Vonage virtual number capable of sending SMS
VONAGE_SMS_FROM_NUMBER="YOUR_VONAGE_SMS_NUMBER"
# Your Vonage WhatsApp Sender ID (often the same as SMS number or a dedicated one)
VONAGE_WHATSAPP_FROM_NUMBER="YOUR_VONAGE_WHATSAPP_NUMBER" # Use the sandbox number initially (e.g., 14157386102)
# Vonage Webhook Security
# Generate a secure random string for this
VONAGE_SIGNATURE_SECRET="YOUR_WEBHOOK_SIGNATURE_SECRET"
# Server Configuration
PORT=3000 # Or any port you prefer
How to Obtain Environment Variable Values:
DATABASE_URL
: Construct this based on your PostgreSQL setup (username, password, host, port, database name).VONAGE_API_KEY
,VONAGE_API_SECRET
: Found at the top of your Vonage API Dashboard after logging in.VONAGE_APPLICATION_ID
,VONAGE_PRIVATE_KEY_PATH
: You will generate these when creating a Vonage Application (Section 4). The path should point to where you save the downloadedprivate.key
file within your project.VONAGE_SMS_FROM_NUMBER
: A virtual number you purchase or rent from Vonage (Section 4). Must be SMS-capable.VONAGE_WHATSAPP_FROM_NUMBER
: For initial testing, use the number provided by the Vonage Messages API Sandbox (Section 4). For production, this will be your approved WhatsApp Business number linked to Vonage.VONAGE_SIGNATURE_SECRET
: Go to your Vonage Dashboard Settings. Under "API settings", find the "Signature secret" for your API key. If none exists, you might need to generate one or use the main account signature secret. It's crucial for webhook security.PORT
: The local port your Express server will listen on.
1.5 Configure .gitignore
:
Ensure sensitive files and generated files are not committed to version control. Create a .gitignore
file:
# .gitignore
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment Variables
.env
# Vonage Private Key
private.key
# Build Output (if applicable)
dist/
build/
# OS generated files
.DS_Store
Thumbs.db
1.6 Project Structure:
Organize your code for clarity. A possible structure:
vonage-messaging-app/
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── src/
│ ├── services/
│ │ └── vonage.service.js
│ ├── routes/
│ │ ├── api.routes.js
│ │ └── webhooks.routes.js
│ ├── middleware/
│ │ └── verifySignature.js
│ ├── controllers/
│ │ ├── message.controller.js
│ │ └── webhook.controller.js
│ ├── utils/
│ │ └── logger.js
│ ├── db.js # Prisma client instance
│ └── server.js # Express server setup
├── .env
├── .gitignore
├── package.json
├── package-lock.json
└── private.key # <-- Store your downloaded private key here
1.7 Add npm Scripts:
Modify the scripts
section in your package.json
for easier development and running:
{
"name": "vonage-messaging-app"_
"version": "1.0.0"_
"description": ""_
"main": "src/server.js"_
"scripts": {
"start": "node src/server.js"_
"dev": "nodemon src/server.js"_
"db:migrate": "npx prisma migrate dev"_
"db:generate": "npx prisma generate"_
"test": "echo \"Error: no test specified\" && exit 1"
}_
"keywords": []_
"author": ""_
"license": "ISC"_
"dependencies": {
"@prisma/client": "^5.x.x"_ # Replace with actual version
"@vonage/server-sdk": "^3.x.x"_ # Replace with actual version
"dotenv": "^16.x.x"_ # Replace with actual version
"express": "^4.x.x"_ # Replace with actual version
"pg": "^8.x.x" # Replace with actual version
}_
"devDependencies": {
"nodemon": "^3.x.x"_ # Replace with actual version
"prisma": "^5.x.x" # Replace with actual version
}
}
(Note: Replace x.x.x
placeholders in package.json
with actual installed versions if needed_ or remove the version numbers entirely if managing via package-lock.json
)
Now you can run npm run dev
to start the server with auto-reloading via nodemon
_ and npm run db:migrate
to apply database schema changes.
2. Implementing Core Functionality (Sending & Receiving Logic)
Let's write the code to interact with Vonage and handle basic server setup.
2.1 Initialize Prisma Client:
Create src/db.js
to export a singleton instance of the Prisma client.
// src/db.js
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
module.exports = prisma;
2.2 Create Utility Logger:
Create a simple logger utility in src/utils/logger.js
. You can replace this with a more robust library like Winston later.
// src/utils/logger.js
const logger = {
log: (...args) => console.log('[LOG]', ...args),
error: (...args) => console.error('[ERROR]', ...args),
warn: (...args) => console.warn('[WARN]', ...args),
info: (...args) => console.info('[INFO]', ...args),
};
module.exports = logger;
2.3 Create Vonage Service:
This service will encapsulate interactions with the Vonage SDK.
// src/services/vonage.service.js
require('dotenv').config(); // Ensure env vars are loaded
const { Vonage } = require('@vonage/server-sdk');
const { Message } = require('@vonage/server-sdk/dist/messages/message'); // Adjusted path if needed
const logger = require('../utils/logger'); // Import the logger
const prisma = require('../db');
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,
});
/**
* Sends a message via the Vonage Messages API.
* @param {string} to - Recipient phone number (E.164 format).
* @param {string} text - Message content.
* @param {'sms' | 'whatsapp'} channel - Messaging channel.
* @returns {Promise<object>} - The Vonage API response.
*/
async function sendMessage(to, text, channel) {
const from = channel === 'sms'
? process.env.VONAGE_SMS_FROM_NUMBER
: process.env.VONAGE_WHATSAPP_FROM_NUMBER;
if (!from) {
logger.error(`Vonage 'from' number/ID not configured for channel: ${channel}`);
throw new Error(`Sender ID not configured for ${channel}`);
}
logger.info(`Attempting to send ${channel} message from ${from} to ${to}`);
try {
// Use the Message builder for flexibility
const message = new Message(
{ type: 'text', text: text }, // Content
{ number: to }, // To
{ type: channel, number: from } // From
);
const response = await vonage.messages.send(message);
logger.info('Message sent successfully:', response);
// Store outbound message record (async, don't block response)
prisma.message.create({
data: {
vonageMessageId: response.message_uuid,
direction: 'outbound',
channel: channel,
to: to,
from: from,
text: text,
status: 'submitted', // Initial status from Vonage
timestamp: new Date(), // Use current time as submission time
}
}).catch(dbError => logger.error('Failed to save outbound message to DB:', dbError));
return response; // Contains message_uuid
} catch (error) {
const errorData = error?.response?.data || { message: error.message };
logger.error('Error sending Vonage message:', errorData);
// Attempt to store failed submission
prisma.message.create({
data: {
vonageMessageId: `failed_${Date.now()}`, // Placeholder ID
direction: 'outbound',
channel: channel,
to: to,
from: from,
text: text,
status: 'failed_submission',
timestamp: new Date(),
errorDetails: JSON.stringify(errorData),
}
}).catch(dbError => logger.error('Failed to save FAILED outbound message to DB:', dbError));
throw error; // Re-throw for the controller to handle
}
}
module.exports = {
sendMessage,
vonage // Export instance if needed elsewhere (e.g., for signature verification)
};
2.4 Basic Express Server Setup:
Create the main server file src/server.js
.
// src/server.js
require('dotenv').config();
const express = require('express');
const logger = require('./utils/logger');
const apiRoutes = require('./routes/api.routes');
const webhookRoutes = require('./routes/webhooks.routes');
const prisma = require('./db'); // Import to ensure connection pool is ready (optional)
const app = express();
const PORT = process.env.PORT || 3000;
// --- IMPORTANT: Raw Body Middleware for Webhook Verification ---
// This MUST come BEFORE the webhook routes are defined and BEFORE express.json()
// if you want the default JSON parser to run afterwards on those routes.
// However, it's safer to put it just before the webhook router is used.
// We will configure it more precisely in section 7.1.
// For now, let's use the standard JSON parser first.
// Standard Middleware
// Note: We will modify express.json() later in Section 7.1 for webhook signature verification
app.use(express.json()); // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies
// Simple Request Logger Middleware
app.use((req, res, next) => {
logger.info(`${req.method} ${req.url}`);
next();
});
// Routes
app.use('/api', apiRoutes);
app.use('/webhooks', webhookRoutes); // Webhook routes will include specific body parsing/verification
// Basic Root Route
app.get('/', (req, res) => {
res.send('Vonage Messaging App is running!');
});
// Global Error Handler (Basic)
app.use((err, req, res, next) => {
logger.error('Unhandled Error:', err);
res.status(500).json({ error: 'Internal Server Error' });
});
// Start Server and store the server instance
const server = app.listen(PORT, () => {
logger.log(`Server listening on port ${PORT}`);
logger.log(`Local URL: http://localhost:${PORT}`);
// Remind about ngrok for webhooks
logger.warn('Remember to run ngrok and update Vonage webhook URLs if testing locally!');
logger.warn(`Example ngrok command: ngrok http ${PORT}`);
});
// Graceful Shutdown (Optional but Recommended)
process.on('SIGTERM', async () => {
logger.info('SIGTERM signal received. Closing HTTP server.');
// Close the server (requires the 'server' variable assigned above)
server.close(async () => {
logger.info('HTTP server closed.');
// Disconnect Prisma Client
try {
await prisma.$disconnect();
logger.info('Prisma Client disconnected.');
} catch (e) {
logger.error('Error disconnecting Prisma:', e);
} finally {
process.exit(0);
}
});
});
3. Building the API Layer (Sending Messages)
Let's create an API endpoint to trigger sending messages.
3.1 Create Message Controller:
Handles the logic for the send message route.
// src/controllers/message.controller.js
const vonageService = require('../services/vonage.service');
const logger = require('../utils/logger');
async function handleSendMessage(req, res) {
const { to, text, channel } = req.body;
// Basic Validation
if (!to || !text || !channel) {
return res.status(400).json({ error: 'Missing required fields: to, text, channel' });
}
if (channel !== 'sms' && channel !== 'whatsapp') {
return res.status(400).json({ error: 'Invalid channel. Must be "sms" or "whatsapp".' });
}
// Decide whether to reject or attempt anyway based on format
// Stricter E.164 format check (requires '+', country code, and subscriber number)
if (!/^\+\d{10,15}$/.test(to)) {
logger.error(`Invalid 'to' number format received: ${to}`);
return res.status(400).json({ error: 'Invalid "to" number format. Use E.164 standard (e.g., "+15551234567").' });
}
try {
const response = await vonageService.sendMessage(to, text, channel);
res.status(202).json({ // 202 Accepted - request initiated
message: `Message sending initiated via ${channel}.`,
message_uuid: response.message_uuid
});
} catch (error) {
logger.error(`Failed to send message via API: ${error.message}`);
// Determine appropriate status code based on error type if possible
const statusCode = error.response?.status || 500;
const errorDetails = error.response?.data?.title || error.message;
res.status(statusCode).json({
error: 'Failed to send message',
details: errorDetails
});
}
}
module.exports = {
handleSendMessage,
};
3.2 Create API Routes:
Define the route that uses the controller.
// src/routes/api.routes.js
const express = require('express');
const messageController = require('../controllers/message.controller');
// Add rate limiting later (Section 7)
const router = express.Router();
// POST /api/send-message
router.post('/send-message', messageController.handleSendMessage);
// Add a simple health check endpoint
router.get('/health', (req, res) => {
res.status(200).json({ status: 'UP' });
});
module.exports = router;
3.3 Testing the API Endpoint:
Once the server is running (npm run dev
), you can test this endpoint using curl
or Postman.
Curl Example (SMS):
curl -X POST http://localhost:3000/api/send-message \
-H "Content-Type: application/json" \
-d '{
"to": "+15551234567",
"text": "Hello from Node.js via Vonage SMS!",
"channel": "sms"
}'
Curl Example (WhatsApp):
Remember: WhatsApp sending via Sandbox requires the recipient to have first messaged the Sandbox number.
curl -X POST http://localhost:3000/api/send-message \
-H "Content-Type: application/json" \
-d '{
"to": "+15551234567",
"text": "Hello from Node.js via Vonage WhatsApp Sandbox!",
"channel": "whatsapp"
}'
Expected JSON Response (Success):
{
"message": "Message sending initiated via sms.",
"message_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
Expected JSON Response (Validation Error):
{
"error": "Invalid \"to\" number format. Use E.164 standard (e.g., \"+15551234567\")."
}
Expected JSON Response (Vonage API Error):
{
"error": "Failed to send message",
"details": "Authentication failure"
}
4. Integrating with Vonage (Dashboard & ngrok Setup)
This section details the crucial steps within the Vonage Dashboard and using ngrok
.
4.1 Start ngrok:
Before configuring Vonage webhooks, you need a publicly accessible URL for your local server.
Open a new terminal window in your project directory and run:
# Replace 3000 if you used a different PORT in .env
ngrok http 3000
ngrok
will output something like this:
Session Status online
Account Your Name (Plan: Free)
Version x.x.x
Region United States (us-cal-1)
Web Interface http://127.0.0.1:4040
Forwarding https://xxxxxxxxxxxx.ngrok-free.app -> http://localhost:3000
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
Copy the https://xxxxxxxxxxxx.ngrok-free.app
URL. This is your public base URL. Keep this terminal running.
4.2 Create a Vonage Application:
Vonage Applications act as containers for your configurations (like webhook URLs) and link your virtual numbers.
- Log in to the Vonage API Dashboard.
- Navigate to ""Applications"" -> ""Create a new application"".
- Name: Give it a descriptive name (e.g., ""Node Express Messenger"").
- Generate Public/Private Key: Click ""Generate public and private key"". Immediately save the
private.key
file that downloads into the root of your project directory (or whereverVONAGE_PRIVATE_KEY_PATH
points). Vonage does not store this key, so save it securely. - Capabilities:
- Toggle Messages ON.
- Inbound URL: Paste your ngrok Forwarding URL and append
/webhooks/inbound
. Example:https://xxxxxxxxxxxx.ngrok-free.app/webhooks/inbound
- Status URL: Paste your ngrok Forwarding URL and append
/webhooks/status
. Example:https://xxxxxxxxxxxx.ngrok-free.app/webhooks/status
- (Optional) Enable other capabilities like Voice if needed later.
- Click ""Create application"".
- On the next page, you'll see the Application ID. Copy this value and paste it into your
.env
file forVONAGE_APPLICATION_ID
.
4.3 Link a Virtual Number (for SMS):
You need a Vonage number to send/receive SMS messages.
- If you don't have one, go to ""Numbers"" -> ""Buy numbers"". Search for a number with SMS capability in your desired country and purchase it.
- Go back to ""Applications"", find the application you just created, and click its name.
- Scroll down to the ""Link numbers"" section.
- Find your purchased number and click the ""Link"" button next to it.
- Copy this phone number (in E.164 format, e.g.,
12015550123
) and paste it into your.env
file forVONAGE_SMS_FROM_NUMBER
.
4.4 Set Up Messages API Sandbox (for WhatsApp Testing):
The Sandbox provides a shared number for testing WhatsApp integration without needing your own approved WhatsApp Business number initially.
- In the Vonage Dashboard, navigate to ""Developer Tools"" -> ""Messages API Sandbox"".
- Whitelist your number: Follow the instructions to send a specific WhatsApp message from your personal phone number to the provided Sandbox number (e.g.,
+14157386102
). This allows the Sandbox to send messages to you. - Configure Webhooks: In the Sandbox page, find the ""Webhooks"" section.
- Inbound URL: Enter your ngrok URL +
/webhooks/inbound
(e.g.,https://xxxxxxxxxxxx.ngrok-free.app/webhooks/inbound
). - Status URL: Enter your ngrok URL +
/webhooks/status
(e.g.,https://xxxxxxxxxxxx.ngrok-free.app/webhooks/status
). - Click ""Save webhooks"".
- Inbound URL: Enter your ngrok URL +
- Sandbox Number: Note the Sandbox WhatsApp number (e.g.,
14157386102
). Use this value in your.env
file forVONAGE_WHATSAPP_FROM_NUMBER
during testing.
4.5 Ensure Correct Vonage API Settings:
Vonage has older APIs (like the SMS API). Ensure your account is set to use the Messages API as the default for SMS to receive webhooks in the correct format expected by this guide.
- Go to Vonage Dashboard Settings.
- Scroll down to ""API settings"".
- Under ""Default SMS Setting"", ensure ""Messages API"" is selected. If not, change it and click ""Save changes"".
5. Implementing Error Handling, Logging, and Retry Mechanisms
Robust applications need proper error handling and logging.
5.1 Consistent Error Handling Strategy:
- Service Layer: Catch specific Vonage API errors in
vonage.service.js
. Log detailed errors. Re-throw generic or transformed errors for controllers. - Controller Layer: Catch errors from the service layer. Log context-specific errors. Send appropriate HTTP status codes (4xx for client errors, 5xx for server errors) and JSON error responses to the client.
- Webhook Handlers: Use
try...catch
extensively. Always respond with200 OK
quickly to Vonage, even if processing fails internally (log the failure). Vonage retries webhooks if it doesn't receive a 2xx response, which can cause duplicate processing. - Global Error Handler: Use the Express global error handler (
app.use((err, req, res, next) => {...})
) inserver.js
as a final catch-all for unexpected errors.
5.2 Logging:
The simple logger.js
provided is basic. For production, consider a more structured logging library like Winston or Pino.
- Log Levels: Use different levels (INFO, WARN, ERROR, DEBUG) appropriately.
- Structured Logging: Log objects (JSON format) instead of just strings. Include timestamps, request IDs (if using), error details, and relevant context. This makes logs easier to parse and analyze.
- Log Destinations: Configure logging to output to console during development and to files or a log aggregation service (like Datadog, Logstash, Splunk, Sentry) in production.
Example using basic logger (already integrated):
// In vonage.service.js (already added)
logger.info('Message sent successfully:', response);
logger.error('Error sending Vonage message:', error?.response?.data || error.message);
// In message.controller.js (already added)
logger.error(`Failed to send message via API: ${error.message}`);
// In webhook controller (add in Section 6)
logger.info('Received inbound message webhook:', JSON.stringify(req.body, null, 2));
logger.error('Error processing inbound webhook:', error);
5.3 Retry Mechanisms (Vonage Webhooks):
Vonage handles retries for webhooks automatically if it doesn't receive a 200 OK
response within a certain timeout.
- Your Responsibility: Ensure your webhook endpoints respond quickly with
200 OK
. Perform time-consuming processing (like complex database operations, external API calls) asynchronously after sending the response. - Idempotency: Design your webhook handlers to be idempotent. This means processing the same webhook request multiple times should not cause unintended side effects (e.g., creating duplicate database entries). Check if a message ID already exists before creating a new record.
5.4 Testing Error Scenarios:
- Invalid Credentials: Temporarily change
VONAGE_API_KEY
orVONAGE_PRIVATE_KEY_PATH
in.env
and try sending a message. - Invalid Recipient: Send a message to a known invalid number format (e.g., missing '+').
- Network Issues: Simulate network failure (e.g., disconnect your machine briefly) while
ngrok
is running and try sending/receiving. - Webhook Errors: Introduce an error in your webhook processing logic (e.g.,
throw new Error('Test webhook error')
) and observe logs. Ensure a200 OK
is still sent if the error is caught correctly after the response. - Signature Verification Failure: Send a manual POST request to your webhook endpoint without the correct Vonage signature (Section 7).
6. Creating a Database Schema and Data Layer (Prisma)
Let's define the database schema and integrate it into our webhook handlers.
6.1 Define Prisma Schema:
Open prisma/schema.prisma
and define the model for storing messages.
// prisma/schema.prisma
generator client {
provider = ""prisma-client-js""
}
datasource db {
provider = ""postgresql""
url = env(""DATABASE_URL"")
}
model Message {
id String @id @default(cuid()) // Unique ID for the DB record
vonageMessageId String @unique // The UUID from Vonage (important for idempotency)
direction String // 'inbound' or 'outbound'
channel String // 'sms', 'whatsapp', etc.
from String // Sender number/ID (E.164 or WhatsApp ID)
to String // Recipient number/ID (E.164 or WhatsApp ID)
text String? // Message content (nullable if it's not text)
status String // Vonage status (e.g., 'submitted', 'delivered', 'read', 'inbound', 'failed')
timestamp DateTime // Timestamp from Vonage webhook or submission time
errorDetails Json? // Store error info if status is 'failed' or 'rejected'
imageUrl String? // Store image URL for MMS/WhatsApp images
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
6.2 Create and Apply Migration:
Run the Prisma migrate command to generate SQL migration files and apply them to your database.
# Ensure your DATABASE_URL in .env is correct and the DB is running
npm run db:migrate -- --name init-messages-table
This creates a migrations
folder and updates your database schema. You also need to generate the Prisma Client code:
npm run db:generate
6.3 Create Webhook Controller:
Handles the logic for processing incoming Vonage webhooks.
// src/controllers/webhook.controller.js
const prisma = require('../db');
const logger = require('../utils/logger');
/**
* Handles inbound message webhooks (/webhooks/inbound)
*/
async function handleInboundMessage(req, res) {
const payload = req.body;
logger.info('Received /inbound webhook:', JSON.stringify(payload, null, 2));
// Respond quickly to Vonage
res.status(200).send('OK');
// --- Asynchronous Processing ---
(async () => {
try {
// Basic validation - ensure essential fields exist
if (!payload.message_uuid || !payload.from || !payload.to || !payload.channel) {
logger.warn('Inbound webhook missing essential fields.', payload);
return; // Don't process incomplete data
}
// Idempotency Check: See if we already processed this message UUID
const existingMessage = await prisma.message.findUnique({
where: { vonageMessageId: payload.message_uuid },
});
if (existingMessage) {
logger.warn(`Duplicate inbound message webhook received: ${payload.message_uuid}. Ignoring.`);
return;
}
// Extract sender/recipient numbers correctly (can be object or string)
const fromNumber = typeof payload.from === 'object' ? payload.from.number : payload.from;
const toNumber = typeof payload.to === 'object' ? payload.to.number : payload.to;
// Determine text content, handling different payload structures
let textContent = null;
if (payload.message?.content?.type === 'text') {
textContent = payload.message.content.text;
} else if (payload.text) { // Fallback for older/different structures
textContent = payload.text;
}
// Determine image URL, handling different payload structures
let imageUrlContent = null;
if (payload.message?.content?.type === 'image') {
imageUrlContent = payload.message.content.url;
} else if (payload.image?.[0]?.url) { // Fallback for potential alternative structure
imageUrlContent = payload.image[0].url;
}
// Store the inbound message
await prisma.message.create({
data: {
vonageMessageId: payload.message_uuid,
direction: 'inbound',
channel: payload.channel,
from: fromNumber,
to: toNumber,
text: textContent,
status: 'inbound', // Custom status for received messages
timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(),
imageUrl: imageUrlContent,
// Add more fields from the payload if needed (e.g., payload.message.content for various types)
},
});
logger.info(`Inbound message ${payload.message_uuid} saved to DB.`);
} catch (error) {
logger.error(`Error processing inbound webhook ${payload.message_uuid}:`, error);
// Consider sending an alert here (e.g., to Sentry)
}
})(); // Immediately invoke the async function
}
/**
* Handles message status webhooks (/webhooks/status)
*/
async function handleMessageStatus(req, res) {
const payload = req.body;
logger.info('Received /status webhook:', JSON.stringify(payload, null, 2));
// Respond quickly to Vonage
res.status(200).send('OK');
// --- Asynchronous Processing ---
(async () => {
try {
if (!payload.message_uuid || !payload.status) {
logger.warn('Status webhook missing essential fields.', payload);
return;
}
// Find the original outbound message by Vonage UUID
const message = await prisma.message.findUnique({
where: { vonageMessageId: payload.message_uuid },
});
if (!message) {
// This can happen if the webhook arrives before the initial send record is saved,
// or if it's a status for an inbound message (which we might not track status for).
logger.warn(`Received status update for unknown message UUID: ${payload.message_uuid}. Status: ${payload.status}`);
return;
}
// Update the message status
await prisma.message.update({
where: { vonageMessageId: payload.message_uuid },
data: {
status: payload.status.toLowerCase(), // Normalize status
timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(), // Update timestamp to status time
// Optionally store error details if the status indicates failure
errorDetails: (payload.status === 'failed' || payload.status === 'rejected') ? payload.error || payload : undefined,
updatedAt: new Date(), // Explicitly set updatedAt
},
});
logger.info(`Updated status for message ${payload.message_uuid} to ${payload.status}.`);
} catch (error) {
logger.error(`Error processing status webhook ${payload.message_uuid}:`, error);
// Consider sending an alert here
}
})(); // Immediately invoke the async function
}
module.exports = {
handleInboundMessage,
handleMessageStatus,
};