Send SMS and WhatsApp messages with Node.js, Express, and Vonage
This guide provides a comprehensive walkthrough for building a production-ready Node.js application using the Express framework to send both SMS and WhatsApp messages via the Vonage Messages API. We'll cover everything from initial project setup and core messaging functionality to security, error handling, deployment, and testing.
By the end of this tutorial, you will have a robust application capable of sending messages through different channels using a unified interface, complete with webhook handling for message status updates and inbound messages.
Project Overview and Goals
What We're Building:
We are building a Node.js Express application that serves two primary functions:
- Provides a simple REST API endpoint to send outgoing messages via either SMS or WhatsApp.
- Listens for incoming webhook events from Vonage for message status updates and inbound messages (both SMS and WhatsApp).
Problem Solved:
This application centralizes messaging logic, enabling developers to integrate SMS and WhatsApp capabilities into their systems through a single API call, abstracting away the channel-specific details of the Vonage Messages API. It also demonstrates best practices for handling credentials, webhooks, and basic security.
Technologies Used:
- Node.js: A JavaScript runtime environment for server-side development. (v18+ recommended)
- Express: A minimal and flexible Node.js web application framework for building the API and handling webhooks.
- Vonage Messages API: A unified API for sending and receiving messages across multiple channels (SMS, MMS, WhatsApp, Viber, Facebook Messenger).
- Vonage Node.js Server SDK: Simplifies interaction with Vonage APIs (
@vonage/server-sdk
,@vonage/messages
). dotenv
: Module to load environment variables from a.env
file.pino
/pino-pretty
: For structured, efficient logging.express-validator
: For robust input validation.ngrok
: A tool to expose local servers to the internet for webhook testing during development.
System Architecture:
A user or client application makes an API call to our Node.js/Express application. The Node.js application then uses the Vonage SDK to interact with the Vonage Messages API Gateway. Vonage handles routing the message through the appropriate channel (SMS or WhatsApp) to the recipient's phone. For status updates and inbound messages, Vonage sends webhooks back to configured endpoints on our Node.js application (exposed via ngrok during development).
Expected Outcome:
A functional Node.js Express application running locally (exposed via ngrok) that can:
- Accept POST requests to
/api/send-message
to send SMS or WhatsApp messages. - Receive and log message status updates at
/webhooks/status
. - Receive and log inbound SMS/WhatsApp messages at
/webhooks/inbound
.
Prerequisites:
- Node.js and npm (or yarn): Installed on your development machine. Download Node.js
- Vonage API Account: Sign up for free at Vonage API Dashboard. You'll get free credit for testing.
ngrok
Account and Installation: Needed to expose your local server for Vonage webhooks. Download ngrok- A Vonage Phone Number: Capable of sending SMS. You can rent one from the Vonage Dashboard under ""Numbers"" > ""Buy numbers"".
- WhatsApp Sandbox Access: Configured in the Vonage Dashboard for testing WhatsApp messages.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir vonage-node-messaging cd vonage-node-messaging
-
Initialize Node.js Project: This creates a
package.json
file.npm init -y
-
Install Dependencies: We need Express for the server, the Vonage SDKs,
dotenv
,pino
for logging, andexpress-validator
. We also include other dependencies used in later sections (like Prisma, Sentry, Helmet, rate-limiting) for completeness.npm install express @vonage/server-sdk @vonage/messages dotenv pino express-validator @prisma/client @sentry/node @sentry/profiling-node express-rate-limit helmet npm install prisma --save-dev # Prisma CLI is a dev dependency
-
Install Development Dependencies:
nodemon
automatically restarts the server during development.pino-pretty
formats logs nicely in development.jest
andsupertest
are for testing.npm install --save-dev nodemon pino-pretty jest supertest
-
Create Project Structure: Organize your code for clarity.
mkdir src touch src/server.js touch .env touch .gitignore
-
Configure
.gitignore
: Prevent sensitive files and unnecessary modules from being committed to version control. Add the following lines to your.gitignore
file:# Dependencies node_modules/ # Environment variables .env .env.* !.env.example # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pids *.pid *.seed *.pid.lock # Optional files .DS_Store # Sensitive Keys private.key *.pem # Prisma prisma/generated/ # Build output dist/
-
Set up
npm
Scripts: Add scripts to yourpackage.json
for easily running the server and other tasks.{ ""name"": ""vonage-node-messaging"", ""version"": ""1.0.0"", ""description"": ""Node.js app to send SMS/WhatsApp via Vonage"", ""main"": ""src/server.js"", ""scripts"": { ""start"": ""node src/server.js"", ""dev"": ""nodemon src/server.js | pino-pretty"", ""test"": ""jest"", ""prisma:migrate:dev"": ""prisma migrate dev"", ""prisma:generate"": ""prisma generate"" }, ""keywords"": [ ""vonage"", ""sms"", ""whatsapp"", ""nodejs"", ""express"" ], ""author"": """", ""license"": ""ISC"", ""dependencies"": { ""@prisma/client"": ""^5.14.0"", ""@sentry/node"": ""^7.112.2"", ""@sentry/profiling-node"": ""^7.112.2"", ""@vonage/messages"": ""^1.14.1"", ""@vonage/server-sdk"": ""^3.14.1"", ""dotenv"": ""^16.4.5"", ""express"": ""^4.19.2"", ""express-rate-limit"": ""^7.2.0"", ""express-validator"": ""^7.1.0"", ""helmet"": ""^7.1.0"", ""pino"": ""^9.1.0"" }, ""devDependencies"": { ""jest"": ""^29.7.0"", ""nodemon"": ""^3.1.0"", ""pino-pretty"": ""^11.1.0"", ""prisma"": ""^5.14.0"", ""supertest"": ""^7.0.0"" } }
-
Environment Variables (
.env
): Create a.env
file in the project root. Populate this with credentials obtained from Vonage (see Section 4). Do not commit this file to Git.# .env # Vonage Credentials VONAGE_API_KEY=YOUR_API_KEY VONAGE_API_SECRET=YOUR_API_SECRET VONAGE_APPLICATION_ID=YOUR_APPLICATION_ID VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root # VONAGE_SIGNATURE_SECRET=YOUR_SIGNATURE_SECRET # Only needed for older signature verification # Vonage Numbers (Use E.164 format, e.g., 14155550100) VONAGE_SMS_NUMBER=YOUR_VONAGE_SMS_NUMBER VONAGE_WHATSAPP_NUMBER=VONAGE_SANDBOX_WHATSAPP_NUMBER # From Sandbox page # Server Configuration PORT=8000 LOG_LEVEL=info NODE_ENV=development # Or 'production' # Database (Example for Prisma with PostgreSQL) DATABASE_URL=""postgresql://user:password@host:port/database?schema=public"" # Sentry (Optional) # SENTRY_DSN=YOUR_SENTRY_DSN
Explanation:
VONAGE_API_KEY
,VONAGE_API_SECRET
: Found on your Vonage Dashboard. May be needed by the SDK for some operations.VONAGE_APPLICATION_ID
: Unique ID for your Vonage Application (created later). Links webhooks and numbers.VONAGE_PRIVATE_KEY_PATH
: Path to theprivate.key
file generated when creating the Vonage Application. Used for JWT generation for Messages API authentication.VONAGE_SIGNATURE_SECRET
: Found in Vonage Dashboard Settings. Used only for verifying webhooks signed with the older shared secret method. JWT verification is standard for Messages API v2.VONAGE_SMS_NUMBER
: Your purchased Vonage number capable of sending SMS.VONAGE_WHATSAPP_NUMBER
: The specific number provided by the Vonage WhatsApp Sandbox for testing.PORT
: The local port your Express server will listen on.LOG_LEVEL
: Controls logging verbosity (e.g., 'debug', 'info', 'warn', 'error').NODE_ENV
: Set to 'development' or 'production'. Affects logging format.DATABASE_URL
: Connection string for your database (used by Prisma).SENTRY_DSN
: Optional Data Source Name for Sentry error tracking.
2. Implementing Core Functionality
Now, let's write the core logic for our Express server, including initializing the Vonage client and setting up webhook handlers.
// src/server.js
// 1. Import Dependencies
require('dotenv').config(); // Load .env variables into process.env
const express = require('express');
const { Vonage } = require('@vonage/server-sdk');
const { WhatsAppText } = require('@vonage/messages');
const pino = require('pino');
const { body, validationResult } = require('express-validator');
// Initialize Logger (using Pino)
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
// Use pino-pretty for development, JSON for production
transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined,
});
// 2. Initialize Express App
const app = express();
app.use(express.json()); // Middleware to parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Middleware to parse URL-encoded bodies
// 3. Initialize Vonage Client
// Prioritize Application ID and Private Key for Messages API JWT authentication.
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,
}, {
logger: logger, // Inject our logger into the SDK
// apiHost: 'https://messages-sandbox.nexmo.com' // Uncomment for WhatsApp Sandbox testing ONLY.
// Remove or comment out for production WABA numbers.
});
// --- Core Functions ---
/**
* Sends a message using the Vonage Messages API.
* @param {object} params - The message parameters.
* @param {string} params.channel - 'sms' or 'whatsapp'.
* @param {string} params.to - Recipient number in E.164 format (digits only).
* @param {string} params.text - Message content.
* @returns {Promise<string>} The message UUID on success.
* @throws {Error} If sending fails.
*/
const sendVonageMessage = async ({ channel, to, text }) => {
logger.info(`Attempting to send ${channel} message to ${to}`);
let fromNumber;
let messagePayload;
try {
if (channel === 'sms') {
fromNumber = process.env.VONAGE_SMS_NUMBER;
if (!fromNumber) throw new Error('VONAGE_SMS_NUMBER is not configured in .env');
messagePayload = {
message_type: 'text',
text: text,
to: to,
from: fromNumber,
channel: 'sms',
};
} else if (channel === 'whatsapp') {
fromNumber = process.env.VONAGE_WHATSAPP_NUMBER;
if (!fromNumber) throw new Error('VONAGE_WHATSAPP_NUMBER is not configured in .env');
// For WhatsApp Sandbox or freeform replies within 24h window
messagePayload = new WhatsAppText({
text: text,
to: to,
from: fromNumber,
// client_ref: `my-app-ref-${Date.now()}` // Optional client reference
});
} else {
throw new Error(`Unsupported channel: ${channel}`);
}
// Use the Vonage SDK to send the message
const response = await vonage.messages.send(messagePayload);
logger.info({ messageUuid: response.messageUuid }, `Message sent successfully via ${channel}`);
return response.messageUuid;
} catch (error) {
const errorMessage = error.response?.data ? JSON.stringify(error.response.data) : error.message;
logger.error({ err: error, responseData: error.response?.data }, `Error sending Vonage message: ${errorMessage}`);
// Rethrow a more specific error or handle as needed
throw new Error(`Failed to send ${channel} message: ${errorMessage}`);
}
};
// --- Webhook Endpoints ---
// Handles incoming messages (SMS/WhatsApp) from Vonage
app.post('/webhooks/inbound', (req, res) => {
logger.info({ webhook: 'inbound', body: req.body }, 'Received Inbound Webhook');
try {
// TODO: Implement webhook security verification (JWT or Signature Secret) - ESSENTIAL for production!
// Consult Vonage documentation for the correct method based on your Application/API setup.
// Example (JWT - requires public key and JWT library): verifyJwt(req.headers.authorization);
// Example (Signature Secret - requires secret & specific SDK function): verifySignature(req.body, req.query.sig, process.env.VONAGE_SIGNATURE_SECRET);
// Failure to verify should result in a 401 or 403 response and no further processing.
const { channel, from, text, message_uuid, timestamp } = req.body;
// Ensure 'from' and 'text' exist before logging/processing
const sender = from?.number || 'unknown_sender';
const messageText = text || '[no text content]';
logger.info(`Received ${channel} message from ${sender} (UUID: ${message_uuid}) at ${timestamp}: ""${messageText}""`);
// --- Add your inbound message processing logic here ---
// Example: Log to database (see Section 6)
// Example: Send an auto-reply (be careful with loops!)
/*
if (channel === 'whatsapp' && text?.toLowerCase().includes('hello')) {
sendVonageMessage({ channel: 'whatsapp', to: from.number, text: 'Hi there! Thanks for your message.' })
.catch(err => logger.error({ err }, ""Failed to send auto-reply""));
}
*/
// Vonage expects a 200 OK response to acknowledge receipt
res.status(200).send('OK');
} catch (error) {
// If verification fails, it should throw before this point typically.
logger.error({ err: error, webhook: 'inbound' }, 'Error processing inbound webhook');
// Respond with an error status if processing error occurs AFTER verification
// Avoid sending 401 here unless verification explicitly failed and you caught it
res.status(500).send('Internal Server Error');
}
});
// Handles message status updates (e.g., delivered, failed) from Vonage
app.post('/webhooks/status', (req, res) => {
logger.info({ webhook: 'status', body: req.body }, 'Received Status Webhook');
try {
// TODO: Implement webhook security verification (JWT or Signature Secret) - ESSENTIAL for production!
// Consult Vonage documentation for the correct method.
// Failure to verify should result in a 401 or 403 response and no further processing.
const { message_uuid, status, timestamp, error, client_ref, from, to } = req.body;
const recipientNumber = to?.number || 'N/A';
const senderNumber = from?.number || 'N/A';
logger.info(`Status update for message ${message_uuid} to ${recipientNumber} from ${senderNumber}: ${status} at ${timestamp}`);
if (error) {
logger.error({ message_uuid, errorDetails: error }, `Message ${message_uuid} failed: ${error.type} - ${error.reason}`);
}
if (client_ref) {
logger.info({ message_uuid, client_ref }, `Client reference: ${client_ref}`);
}
// --- Add your status update processing logic here ---
// Example: Update message status in a database (see Section 6)
// Acknowledge receipt
res.status(200).send('OK');
} catch (error) {
logger.error({ err: error, webhook: 'status' }, 'Error processing status webhook');
// Avoid sending 401 here unless verification explicitly failed and you caught it
res.status(500).send('Internal Server Error');
}
});
// --- API Endpoint (Defined in Section 3) ---
// See below
// --- Server Start ---
const PORT = process.env.PORT || 8000;
const server = app.listen(PORT, () => { // Assign server to variable for testing/shutdown
logger.info(`Server listening on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
logger.info(`Log Level: ${logger.level}`);
logger.info(`Ensure ngrok is running and configured for port ${PORT} for webhook testing.`);
logger.info(`Webhook URLs should point to your ngrok HTTPS address (e.g., https://<your-ngrok-id>.ngrok.io/webhooks/...)`);
});
// Export app and server for testing purposes
module.exports = { app, server };
Explanation:
- Dependencies are imported, including
pino
for logging andexpress-validator
. - The
pino
logger is initialized, respectingNODE_ENV
for formatting. - The
Vonage
client is initialized using credentials from.env
, prioritizing Application ID/Private Key for JWT auth, and injecting our logger. The comment aboutapiHost
for the sandbox is included. - Webhook Security: Placeholders (
// TODO:
) and comments strongly emphasize implementing correct verification (JWT/Public Key or Signature Secret) based on Vonage documentation, marking it as essential. The logic assumes verification happens before processing the body. sendVonageMessage
: An async function handles sending logic for 'sms' and 'whatsapp', selecting the correctfrom
number and constructing the payload forvonage.messages.send
. Logging uses thelogger
object. Error handling includes logging potential response data from Vonage and re-throwing.- Webhook Handlers (
/webhooks/inbound
,/webhooks/status
): These routes listen for POST requests from Vonage. They log the incoming data usinglogger
and contain placeholders for custom logic and the crucial security TODO. They must respond with200 OK
quickly to prevent Vonage retries. Basic checks forfrom
andtext
existence are added.
3. Building a Complete API Layer
Let's create a simple API endpoint to trigger sending messages, incorporating express-validator
for input validation.
Add the following route definition to your src/server.js
file, before the Server Start
section:
// src/server.js
// --- API Endpoint ---
app.post('/api/send-message',
// 1. Validation Middleware (using express-validator)
body('channel').isIn(['sms', 'whatsapp']).withMessage('Invalid channel. Must be ""sms"" or ""whatsapp"".'),
// E.164 format check (digits only, typically 11-15 length, allows flexibility)
body('to').matches(/^\d{11,15}$/).withMessage('Invalid ""to"" number format. Expecting 11-15 digits (E.164 format without \'+\'). Example: 14155550100'),
body('text').notEmpty({ ignore_whitespace: true }).isString().trim().escape().withMessage('Text cannot be empty and must be a string.'),
async (req, res) => {
// Check for validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
logger.warn({ errors: errors.array(), requestBody: req.body }, 'Validation failed for /api/send-message');
return res.status(400).json({ errors: errors.array() });
}
const { channel, to, text } = req.body;
try {
// 2. Call the Core Sending Function
const messageUuid = await sendVonageMessage({ channel, to, text });
// 3. Respond with Success
res.status(202).json({ // 202 Accepted: Request received, processing initiated
message: `${channel.toUpperCase()} message queued for sending.`,
messageUuid: messageUuid
});
} catch (error) {
// 4. Handle Errors during Sending
logger.error({ err: error, requestBody: req.body }, `API Error sending message`);
// Provide a generic error message to the client, matching validator format
res.status(500).json({ errors: [{ msg: 'Failed to send message due to an internal server error.' }] });
}
}
);
// --- Server Start ---
// ... (rest of the server start code and export)
Explanation:
- Validation:
express-validator
middleware (body(...)
) defines rules forchannel
,to
(matching digits-only E.164 format), andtext
(non-empty, string, trimmed, escaped).validationResult(req)
checks for errors. If found, a400 Bad Request
is returned with details. - Call Core Logic: If validation passes,
sendVonageMessage
is called. - Success Response: On success,
202 Accepted
is returned with themessageUuid
. - Error Response: If
sendVonageMessage
throws, the error is logged, and500 Internal Server Error
is returned with a generic message in a consistent error format.
Testing the API Endpoint:
Once the server is running (npm run dev
) and ngrok
is configured (Section 4), you can test this endpoint using curl
or an API client like Postman:
Using curl
:
# Send an SMS (replace placeholders with actual E.164 digits)
curl -X POST http://localhost:8000/api/send-message \
-H ""Content-Type: application/json"" \
-d '{
""channel"": ""sms"",
""to"": ""14155550101"",
""text"": ""Hello from Node.js SMS!""
}'
# Send a WhatsApp message (replace placeholders - ensure recipient is allowlisted in Sandbox)
curl -X POST http://localhost:8000/api/send-message \
-H ""Content-Type: application/json"" \
-d '{
""channel"": ""whatsapp"",
""to"": ""14155550102"",
""text"": ""Hello from Node.js WhatsApp!""
}'
Replace the example to
numbers with actual phone numbers in digits-only E.164 format.
Expected JSON Response (Success):
{
""message"": ""SMS message queued for sending."",
""messageUuid"": ""some-unique-message-uuid-from-vonage""
}
Expected JSON Response (Validation Error):
{
""errors"": [
{
""type"": ""field"",
""value"": ""invalid-number"",
""msg"": ""Invalid \""to\"" number format. Expecting 11-15 digits (E.164 format without '+'). Example: 14155550100"",
""path"": ""to"",
""location"": ""body""
}
]
}
Expected JSON Response (Sending Error):
{
""errors"": [
{
""msg"": ""Failed to send message due to an internal server error.""
}
]
}
4. Integrating with Vonage (Credentials & Webhooks)
This is a critical step where we configure Vonage and link it to our application.
Step 1: Obtain API Key, Secret, and Application Credentials
- Go to your Vonage API Dashboard.
- API Key and Secret: Find these on the main dashboard page. Copy them into your
.env
file forVONAGE_API_KEY
andVONAGE_API_SECRET
. - (Optional) Signature Secret: Navigate to ""Settings"" in the left-hand menu. Find your ""API signature secret"" under Signing Secrets. Click ""Edit"" if needed. Copy this value into
VONAGE_SIGNATURE_SECRET
only if you intend to use the older signature secret webhook verification method (JWT is preferred for Messages API v2).
Step 2: Create a Vonage Application
Vonage Applications link numbers, webhooks, and authentication keys.
- Navigate to ""Applications"" > ""Create a new application"" in the dashboard.
- Give your application a Name (e.g., ""NodeJS Messaging App"").
- Generate Public and Private Key: Click this button. A
private.key
file will be downloaded. Save this file securely in your project root (or whereVONAGE_PRIVATE_KEY_PATH
points). Do not lose this key, and addprivate.key
to your.gitignore
. The public key is stored by Vonage. - Application ID: After generating keys, the Application ID is displayed. Copy this ID into your
.env
file forVONAGE_APPLICATION_ID
. - Capabilities: Enable the Messages capability.
- Configure Webhooks: This requires exposing your local server.
- Open a new terminal window.
- Start
ngrok
to forward to your Express server's port (default 8000):ngrok http 8000
ngrok
will display aForwarding
URL (e.g.,https://<unique-id>.ngrok.io
). Use the HTTPS version.- Back in the Vonage Application settings:
- Inbound URL: Enter your ngrok HTTPS URL +
/webhooks/inbound
(e.g.,https://<unique-id>.ngrok.io/webhooks/inbound
). - Status URL: Enter your ngrok HTTPS URL +
/webhooks/status
(e.g.,https://<unique-id>.ngrok.io/webhooks/status
).
- Inbound URL: Enter your ngrok HTTPS URL +
- Authentication Method: Ensure this is set appropriately for Messages API webhooks (usually JWT, handled by the Application's keys).
- Click ""Generate new application"" (or ""Save changes"").
Step 3: Link Your Vonage SMS Number
- Go to ""Numbers"" > ""Your numbers"".
- Find the Vonage number you want to use for SMS. Buy one if needed.
- Click the ""Link"" icon (or ""Manage"") next to the number.
- Select your Vonage Application (""NodeJS Messaging App"") from the dropdown under the ""Messages"" capability.
- Click ""Confirm"".
- Copy this phone number (E.164 format, e.g.,
14155550100
) into your.env
file forVONAGE_SMS_NUMBER
.
Step 4: Set Up the WhatsApp Sandbox
The Sandbox allows testing without a full WhatsApp Business Account.
- Navigate to ""Messages API Sandbox"" in the Vonage Dashboard (under ""Build & Manage"").
- Activate the Sandbox: Scan the QR code with WhatsApp or send the specified message from your personal WhatsApp number to the Sandbox number shown. This allowlists your number.
- Configure Sandbox Webhooks: Scroll down to the ""Webhooks"" section on the Sandbox page.
- Enter the same
ngrok
URLs used for the Vonage Application:- Inbound URL:
https://<unique-id>.ngrok.io/webhooks/inbound
- Status URL:
https://<unique-id>.ngrok.io/webhooks/status
- Inbound URL:
- Click ""Save webhooks"".
- Enter the same
- Get Sandbox Number: Find the Vonage WhatsApp Sandbox number on this page (e.g.,
+1415...
). Copy this number (E.164 format, digits only for the code, e.g.,14155551234
) into your.env
file forVONAGE_WHATSAPP_NUMBER
.
Crucial Check: Ensure your Node.js server (npm run dev
) and ngrok
(ngrok http 8000
) are both running before testing sending or expecting webhooks.
5. Implementing Error Handling, Logging, and Retry Mechanisms
Robust applications require solid error handling and logging.
Error Handling Strategy:
- API Endpoint (
/api/send-message
):express-validator
handles input validation (returns400
).try...catch
aroundsendVonageMessage
call.- Log detailed errors server-side (
logger.error
). - Return
500
for internal/sending errors with a generic message.
- Webhook Handlers (
/webhooks/...
):- Implement Security Verification First: This is critical. Unverified requests should be rejected (e.g.,
401
/403
). try...catch
around the handler logic after successful verification.- Log detailed errors server-side (
logger.error
). - Crucially: Always try to send a
200 OK
response to Vonage quickly after successful verification, even if subsequent processing fails. This prevents Vonage retries. If Vonage retries occur, design your processing logic to be idempotent (safe to run multiple times with the same input). Return500
only for unexpected errors during processing.
- Implement Security Verification First: This is critical. Unverified requests should be rejected (e.g.,
- Core Function (
sendVonageMessage
):try...catch
around thevonage.messages.send
call.- Log specific errors from the Vonage SDK (check
error.response.data
). - Re-throw errors to be caught by the caller (e.g., the API endpoint).
Logging:
We use Pino for structured JSON logging (production) or pretty-printing (development). Use logger.info
, logger.warn
, logger.error
, logger.debug
. Pass error objects to logger.error({ err: error }, 'Message')
for stack traces.
Retry Mechanisms:
-
Webhook Retries: Vonage handles retries automatically if your endpoint doesn't return
2xx
within the timeout. Ensure endpoints respond quickly (200 OK
) and are idempotent. Implement security verification first. -
Outgoing Message Retries: The SDK call
vonage.messages.send
doesn't automatically retry. You can implement custom retry logic aroundsendVonageMessage
for specific transient errors (e.g., network issues, Vonage5xx
errors), typically using exponential backoff.Conceptual Retry Logic (Simplified Example):
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms)); const sendVonageMessageWithRetry = async (payload, retries = 3, initialDelay = 1000) => { let delay = initialDelay; for (let attempt = 1; attempt <= retries; attempt++) { try { // Assuming 'payload' is the object for vonage.messages.send // Replace the direct call in sendVonageMessage with this function return await vonage.messages.send(payload); } catch (error) { if (attempt === retries || !isRetryableError(error)) { logger.error({ err: error_ attempt }_ `Message sending failed permanently or won't retry.`); throw error; // Re-throw the last error } logger.warn({ err: error_ attempt_ delay }_ `Message sending failed_ retrying in ${delay}ms...`); await wait(delay); delay *= 2; // Exponential backoff } } // Should only be reached if retries = 0_ defensive coding throw new Error(""Exhausted retries for message sending""); }; function isRetryableError(error) { // Check for network errors or specific Vonage 5xx server errors const statusCode = error?.response?.status; const errorCode = error?.code; // Node.js network error codes like ETIMEDOUT return (statusCode && statusCode >= 500 && statusCode < 600) || errorCode === 'ETIMEDOUT' || errorCode === 'ECONNRESET' || errorCode === 'ECONNREFUSED' || errorCode === 'ENOTFOUND' || // DNS lookup failed errorCode === 'EAI_AGAIN'; // DNS lookup temporary failure } // In the original sendVonageMessage function_ you would replace: // const response = await vonage.messages.send(messagePayload); // With: // const response = await sendVonageMessageWithRetry(messagePayload); // Pass the constructed payload
Caution: Implement retries carefully. Avoid retrying on client errors (
4xx
) like invalid credentials (401
) or bad requests (400
). Ensure idempotency if retries might cause duplicate actions (e.g._ use a uniqueclient_ref
in the message payload if needed).
6. Creating a Database Schema and Data Layer (Optional)
A database is useful for tracking message history_ status_ and associating messages. We'll use Prisma as an example ORM with PostgreSQL.
-
Install Prisma (if not done in Step 1):
npm install @prisma/client npm install prisma --save-dev
-
Initialize Prisma: Choose your database provider (e.g._
postgresql
_mysql
_sqlite
).npx prisma init --datasource-provider postgresql
This creates
prisma/schema.prisma
and addsDATABASE_URL
to.env
. ConfigureDATABASE_URL
in.env
for your database. -
Define Schema (
prisma/schema.prisma
): Define models to store message information.// prisma/schema.prisma generator client { provider = ""prisma-client-js"" } datasource db { provider = ""postgresql"" // Or your chosen DB provider url = env(""DATABASE_URL"") } model MessageLog { id String @id @default(cuid()) // Unique DB record ID messageUuid String @unique // Vonage message UUID channel String // 'sms' or 'whatsapp' direction Direction // 'inbound' or 'outbound' fromNumber String toNumber String text String? // Message content (optional for status updates) status String? // e.g._ 'submitted'_ 'delivered'_ 'failed'_ 'read' errorCode String? // Vonage error code if status is 'failed' errorReason String? // Vonage error reason if status is 'failed' clientRef String? // Optional client reference you sent webhookType String // 'inbound' or 'status' or 'api_send' createdAt DateTime @default(now()) updatedAt DateTime @updatedAt vonageTimestamp DateTime? // Timestamp from Vonage webhook @@index([status]) @@index([channel]) @@index([direction]) @@index([createdAt]) } enum Direction { inbound outbound }
-
Create Database Migration: Generate SQL migration files from your schema changes.
npx prisma migrate dev --name init-message-log
This applies the migration to your database.
-
Generate Prisma Client: Update the Prisma Client based on your schema.
npx prisma generate
-
Use Prisma Client in Your Code: Import and use the client to interact with the database.
// src/server.js (or a separate data layer file) const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); // Example: Logging an outbound message initiated via API // Inside the /api/send-message route's try block_ after successful send: /* try { const messageUuid = await sendVonageMessage({ channel_ to_ text }); await prisma.messageLog.create({ data: { messageUuid: messageUuid_ channel: channel_ direction: 'outbound'_ fromNumber: process.env.VONAGE_SMS_NUMBER || process.env.VONAGE_WHATSAPP_NUMBER_ // Adjust based on channel toNumber: to_ text: text_ // Store the sent text status: 'submitted'_ // Initial status webhookType: 'api_send'_ // vonageTimestamp: new Date() // Or use timestamp from Vonage if available later } }); logger.info({ messageUuid }_ 'Outbound message logged to database.'); res.status(202).json({ ... }); // Original response } catch (error) { ... } */ // Example: Logging an inbound message in /webhooks/inbound // Inside the try block_ after security verification: /* try { // ... verification ... const { message_uuid_ channel_ from_ text_ timestamp } = req.body; await prisma.messageLog.create({ data: { messageUuid: message_uuid_ channel: channel_ direction: 'inbound'_ fromNumber: from.number_ toNumber: req.body.to?.number || 'N/A'_ // Inbound webhook might have 'to' field text: text_ status: 'received'_ // Or derive from webhook if applicable webhookType: 'inbound'_ vonageTimestamp: timestamp ? new Date(timestamp) : new Date() } }); logger.info({ messageUuid }_ 'Inbound message logged to database.'); res.status(200).send('OK'); } catch (error) { ... } */ // Example: Updating message status in /webhooks/status // Inside the try block_ after security verification: /* try { // ... verification ... const { message_uuid_ status_ timestamp_ error_ client_ref } = req.body; await prisma.messageLog.update({ where: { messageUuid: message_uuid }_ data: { status: status_ errorCode: error?.code_ errorReason: error?.reason_ clientRef: client_ref_ // Update if present webhookType: 'status'_ vonageTimestamp: timestamp ? new Date(timestamp) : new Date()_ updatedAt: new Date() // Explicitly set update time } }); logger.info({ messageUuid_ status }_ 'Message status updated in database.'); res.status(200).send('OK'); } catch (error) { // Handle cases where the messageUuid might not exist yet (e.g._ race condition) if (error.code === 'P2025') { // Prisma code for record not found logger.warn({ messageUuid }_ 'Status update received for unknown message UUID.'); // Decide if you want to create a log entry here or just acknowledge } else { logger.error({ err: error_ webhook: 'status' }_ 'Error updating message status in DB'); } // Still send 200 OK to Vonage if possible after logging res.status(200).send('OK'); } */
Remember to handle potential database errors gracefully within your application logic.
7. Security Considerations
Securing your application_ especially endpoints handling credentials and webhooks_ is paramount.
- Webhook Verification (CRITICAL):
- Never trust incoming webhook data without verification. Anyone could send fake data to your endpoints.
- Use JWT Verification (Recommended for Messages API v2): Verify the
Authorization: Bearer <JWT>
header sent by Vonage using the public key associated with your Vonage Application. Libraries likejsonwebtoken
andjwks-rsa
(to fetch the public key dynamically) can help. Consult Vonage documentation for the exact JWT structure and verification process. - Use Signature Secret Verification (Older method): If using the shared signature secret, use the appropriate Vonage SDK function or manually implement the HMAC-SHA256 verification process as described in Vonage docs. This requires your
VONAGE_SIGNATURE_SECRET
. - Reject unverified requests immediately with a
401 Unauthorized
or403 Forbidden
status.
- Environment Variables:
- Store all secrets (API keys, secrets, private key paths, database URLs) in environment variables (
.env
locally, secure configuration management in production). - Never commit
.env
files or private keys to version control. Use.gitignore
.
- Store all secrets (API keys, secrets, private key paths, database URLs) in environment variables (
- Input Validation:
- Use libraries like
express-validator
to sanitize and validate all input from API requests (liketo
,text
,channel
). Prevent injection attacks and ensure data integrity. - Validate webhook payloads to ensure expected fields are present before processing.
- Use libraries like
- Rate Limiting:
- Implement rate limiting on your API endpoint (
/api/send-message
) using middleware likeexpress-rate-limit
to prevent abuse and brute-force attacks. - Consider rate limiting on webhooks if you anticipate high volume or potential denial-of-service vectors, though verification should be the primary defense.
- Implement rate limiting on your API endpoint (
- HTTPS:
- Always use HTTPS for your application in production. Use
ngrok
's HTTPS URL for development webhook testing. - Ensure Vonage webhook URLs are configured with HTTPS.
- Always use HTTPS for your application in production. Use
- Security Headers:
- Use middleware like
helmet
to set various HTTP headers (e.g.,X-Frame-Options
,Strict-Transport-Security
) to mitigate common web vulnerabilities.
- Use middleware like
- Dependency Management:
- Keep dependencies updated (
npm update
oryarn upgrade
) to patch known vulnerabilities. Use tools likenpm audit
oryarn audit
.
- Keep dependencies updated (
- Logging:
- Be careful not to log overly sensitive information (like full message content if subject to privacy regulations, or full API secrets). Log necessary identifiers (like
messageUuid
) and metadata.
- Be careful not to log overly sensitive information (like full message content if subject to privacy regulations, or full API secrets). Log necessary identifiers (like
8. Deployment Considerations
Moving from local development to a production environment requires careful planning.
-
Hosting Platform: Choose a suitable platform (e.g., Heroku, AWS EC2/ECS/Lambda, Google Cloud Run/App Engine, DigitalOcean App Platform, Vercel/Netlify for serverless functions).
-
Environment Variables: Configure environment variables securely on your hosting platform. Do not hardcode secrets in your deployment artifacts.
-
Database: Set up and configure a production database. Ensure the
DATABASE_URL
environment variable points to it. Run Prisma migrations (npx prisma migrate deploy
) as part of your deployment process. -
Process Management: Use a process manager like
pm2
or rely on the platform's built-in management (e.g., Heroku Dynos, systemd) to keep your Node.js application running, handle restarts, and manage logs. -
HTTPS/TLS: Configure TLS termination (HTTPS) either through your hosting platform's load balancer/proxy or directly in your application stack (e.g., using Nginx/Caddy as a reverse proxy).
-
Webhook URLs: Update the Inbound and Status URLs in your Vonage Application and WhatsApp Sandbox settings to point to your production server's public HTTPS endpoints (e.g.,
https://your-app.yourdomain.com/webhooks/inbound
). Removengrok
. -
Logging: Configure production logging. Pino's default JSON output is suitable for log aggregation services (e.g., Datadog, Logstash, CloudWatch Logs). Ensure log rotation or streaming to prevent disk space issues.
-
Monitoring & Alerting: Set up monitoring for application performance (CPU, memory), error rates, and API latency. Integrate with services like Sentry (using
@sentry/node
), Datadog APM, or Prometheus/Grafana. Configure alerts for critical errors or performance degradation. -
Build Process: If using TypeScript or a build step, ensure your deployment process includes compiling/building the application before starting it.
-
Graceful Shutdown: Implement graceful shutdown logic in your server to finish processing ongoing requests and close database connections before exiting, especially when deploying updates or scaling down.
// src/server.js - Example Graceful Shutdown const signals = { 'SIGINT': 2, 'SIGTERM': 15 }; function shutdown(signal, value) { logger.warn(`Received signal ${signal}. Shutting down gracefully...`); server.close(() => { logger.info('HTTP server closed.'); // Close database connection (if applicable) // prisma.$disconnect().then(() => logger.info('Prisma client disconnected.')); process.exit(128 + value); }); // Force shutdown after timeout if graceful fails setTimeout(() => { logger.error('Graceful shutdown timed out. Forcing exit.'); process.exit(128 + value); }, 5000).unref(); // 5 second timeout } Object.keys(signals).forEach((signal) => { process.on(signal, () => shutdown(signal, signals[signal])); });
9. Testing Strategies
Testing ensures your application works correctly and reliably.
- Unit Tests:
- Test individual functions in isolation (e.g.,
sendVonageMessage
, helper functions, validation logic). - Use mocking libraries (like
jest.mock
) to mock external dependencies (e.g., the Vonage SDK, Prisma client) so you're not making real API calls or database queries during unit tests. - Focus on testing different inputs, outputs, and error conditions for each unit.
- Test individual functions in isolation (e.g.,
- Integration Tests:
- Test the interaction between different parts of your application (e.g., API endpoint calling the service function, webhook handler interacting with the database).
- Use tools like
supertest
to make HTTP requests to your running Express application (often in a test environment). - May involve mocking external services (Vonage) or using a test database.
- Verify API responses (status codes, JSON structure) and database state changes.
- End-to-End (E2E) Tests:
- Simulate real user flows. In this case, it might involve:
- Making an API call to
/api/send-message
. - (Difficult/Expensive) Verifying the message is actually received on a real device (often skipped or done manually).
- Simulating Vonage sending a status webhook back to your
/webhooks/status
endpoint. - Verifying the application processed the status update correctly (e.g., checking database state).
- Making an API call to
- These are complex and often require a dedicated testing environment and potentially tools like Puppeteer or Cypress if a UI were involved. For this API/webhook-based app, integration tests often provide sufficient confidence.
- Simulate real user flows. In this case, it might involve:
- Webhook Testing:
- Use
ngrok
during development to receive real webhooks from Vonage. - Use
curl
or Postman to manually send simulated webhook payloads (copied from Vonage docs or real logs) to your local/webhooks/...
endpoints to test processing logic without relying on Vonage sending them. - Write integration tests using
supertest
that POST simulated webhook data to your app and verify the outcome.
- Use
Example Integration Test using Jest and Supertest:
// tests/api.test.js
const request = require('supertest');
const { app, server } = require('../src/server'); // Import your Express app and server instance
const { Vonage } = require('@vonage/server-sdk'); // Import to mock
// Mock the Vonage SDK's messages.send method
jest.mock('@vonage/server-sdk', () => {
const mockMessages = {
send: jest.fn(), // Mock the send function
};
return {
Vonage: jest.fn().mockImplementation(() => ({
messages: mockMessages, // Return the mocked messages object
})),
};
});
// Access the mocked send function easily
const mockSend = new Vonage().messages.send;
// Close the server after all tests run
afterAll((done) => {
server.close(done);
});
describe('POST /api/send-message', () => {
beforeEach(() => {
// Reset mocks before each test
mockSend.mockClear();
});
it('should return 202 and messageUuid on valid SMS request', async () => {
const mockUuid = 'mock-sms-uuid-123';
mockSend.mockResolvedValueOnce({ messageUuid: mockUuid }); // Simulate successful send
const response = await request(app)
.post('/api/send-message')
.send({
channel: 'sms',
to: '14155550101',
text: 'Test SMS message',
})
.expect('Content-Type', /json/)
.expect(202);
expect(response.body).toEqual({
message: 'SMS message queued for sending.',
messageUuid: mockUuid,
});
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({
channel: 'sms',
to: '14155550101',
text: 'Test SMS message',
}));
});
it('should return 202 and messageUuid on valid WhatsApp request', async () => {
const mockUuid = 'mock-whatsapp-uuid-456';
mockSend.mockResolvedValueOnce({ messageUuid: mockUuid });
const response = await request(app)
.post('/api/send-message')
.send({
channel: 'whatsapp',
to: '14155550102',
text: 'Test WhatsApp message',
})
.expect('Content-Type', /json/)
.expect(202);
expect(response.body).toEqual({
message: 'WHATSAPP message queued for sending.',
messageUuid: mockUuid,
});
expect(mockSend).toHaveBeenCalledTimes(1);
// Check if called with an instance of WhatsAppText or similar structure
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({
text: 'Test WhatsApp message',
to: '14155550102',
// from: process.env.VONAGE_WHATSAPP_NUMBER, // Check specific payload structure if needed
}));
});
it('should return 400 for invalid channel', async () => {
const response = await request(app)
.post('/api/send-message')
.send({
channel: 'email', // Invalid channel
to: '14155550103',
text: 'Test invalid channel',
})
.expect('Content-Type', /json/)
.expect(400);
expect(response.body.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'channel', msg: 'Invalid channel. Must be ""sms"" or ""whatsapp"".' })
])
);
expect(mockSend).not.toHaveBeenCalled();
});
it('should return 400 for invalid phone number format', async () => {
const response = await request(app)
.post('/api/send-message')
.send({
channel: 'sms',
to: 'invalid-number', // Invalid format
text: 'Test invalid number',
})
.expect('Content-Type', /json/)
.expect(400);
expect(response.body.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'to', msg: expect.stringContaining('Invalid ""to"" number format') })
])
);
expect(mockSend).not.toHaveBeenCalled();
});
it('should return 400 for empty text', async () => {
const response = await request(app)
.post('/api/send-message')
.send({
channel: 'whatsapp',
to: '14155550104',
text: ' ', // Empty after trim
})
.expect('Content-Type', /json/)
.expect(400);
expect(response.body.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'text', msg: 'Text cannot be empty and must be a string.' })
])
);
expect(mockSend).not.toHaveBeenCalled();
});
it('should return 500 if Vonage SDK throws an error', async () => {
const errorMessage = 'Vonage API error';
mockSend.mockRejectedValueOnce(new Error(errorMessage)); // Simulate SDK error
const response = await request(app)
.post('/api/send-message')
.send({
channel: 'sms',
to: '14155550105',
text: 'Test SDK error',
})
.expect('Content-Type', /json/)
.expect(500);
expect(response.body).toEqual({
errors: [{ msg: 'Failed to send message due to an internal server error.' }],
});
expect(mockSend).toHaveBeenCalledTimes(1);
});
});
// Add similar describe blocks for '/webhooks/inbound' and '/webhooks/status'
// testing different payload scenarios and verifying logging or DB interactions (using mocks)
Remember to configure Jest in your package.json
or a jest.config.js
file. Run tests using npm test
.