code examples
code examples
Build SMS Marketing Campaigns with Node.js, Express & Vonage Messages API (2025 Guide)
Complete tutorial for building SMS marketing campaigns using Node.js, Express.js, and Vonage Messages API. Includes two-way messaging, webhook integration, TCPA compliance, JWT security, and marketing campaign best practices.
Build SMS Marketing Campaigns with Node.js, Express & Vonage Messages API
Build a complete SMS marketing campaign system using Node.js, Express.js, and the Vonage Messages API. This guide provides a step-by-step walkthrough for creating applications that send and receive SMS messages, forming the foundation for SMS marketing campaigns, notifications, or two-factor authentication.
Learn how to implement two-way SMS messaging, handle webhooks for incoming messages, ensure TCPA compliance with new 2025 opt-out rules, and secure your application with JWT signature verification. You'll build a production-ready system capable of handling thousands of messages while respecting carrier rate limits and regulatory requirements.
What You'll Build:
- Send SMS messages from a Vonage virtual number to specified destination numbers
- Receive incoming SMS messages via webhook endpoints
- Implement STOP/HELP keyword handling for TCPA compliance
- Secure webhooks with JWT signature verification
- Track messages in a database using Prisma ORM
- Handle errors with automatic retry logic
Prerequisites: Basic knowledge of Node.js, Express.js, APIs, webhooks, and environment variable management. Familiarity with async/await patterns and RESTful APIs recommended.
Project Overview and Goals
Goal: To create a robust Node.js service that leverages the Vonage Messages API for sending outgoing SMS and handling incoming SMS messages via webhooks, providing a reusable base for SMS-driven applications.
Problem Solved: This guide addresses the need for developers to integrate SMS capabilities into their Node.js applications reliably and efficiently, handling both sending and receiving workflows required for interactive communication or campaign responses.
Technologies Used:
- Node.js: A JavaScript runtime environment ideal for building scalable network applications.
- Express: A minimal and flexible Node.js web application framework used here to create webhook endpoints.
- Vonage Messages API: A unified API for sending and receiving messages across multiple channels, including SMS. We specifically use this for its flexibility and features compared to older Vonage SMS APIs.
- @vonage/server-sdk: The official Vonage Node.js SDK for interacting with Vonage APIs.
- ngrok: A tool to expose local development servers to the internet, necessary for testing webhooks.
- dotenv: A module to load environment variables from a
.envfile intoprocess.env.
System Architecture:
+-----------------+ +------------------------+ +-------------+ +---------+
| User's Phone |<---->| Carrier Network |<---->| Vonage API |<---->| Carrier |<----> User's Phone
| (Recipient for | | | | (Messages | | Network | (Sender/Recipient)
| Outbound SMS, | | | | API) | | |
| Sender for | | | +-------------+ +---------+
| Inbound SMS) | | | (Webhook POST: Inbound/Status)
+-----------------+ +------------------------+ | |
(API Call: Send SMS) | v
+--------------------+
| Node.js/Express App|
| (Your Server) |
| - Sends API Req |
| - Receives Webhook|
+--------------------+
^ | (Local Dev via ngrok)
| v
+----------+
| ngrok |
+----------+Prerequisites:
- Node.js and npm (or yarn): Installed on your development machine. Use Node.js 22.x (Active LTS until October 2025) or Node.js 20.x (maintenance mode) for production. Download Node.js
- Vonage API Account: Create a free account to get API credentials and a virtual number. Sign up for Vonage
- Vonage Virtual Number: Purchase an SMS-capable number through the Vonage dashboard or CLI.
- ngrok: Installed and authenticated (a free account is sufficient). Download ngrok. Note: Alternatives include Pinggy, Localtunnel, Cloudflare Tunnel, and Zrok for webhook testing.
- Vonage CLI (Optional but Recommended): For managing Vonage resources from your terminal. Install via npm:
npm install -g @vonage/cli - Express.js: This guide uses Express.js 5.1.0 (latest version), which requires Node.js 18+.
1. Setting Up the Project
Let's initialize our Node.js project and install the necessary dependencies.
1. Create Project Directory: Open your terminal and create a new directory for your project, then navigate into it.
mkdir vonage-sms-app
cd vonage-sms-app2. Initialize Node.js Project:
This creates a package.json file to manage dependencies and project metadata.
npm init -y3. Install Dependencies:
We need Express for the web server, the Vonage SDK to interact with the API, and dotenv for managing environment variables securely.
npm install express @vonage/server-sdk dotenv4. Create Project Files: Create the main files for our application logic and environment variables.
touch index.js server.js .env .gitignoreindex.js: Will contain the logic for sending SMS messages.server.js: Will contain the Express server logic for receiving SMS messages via webhooks..env: Will store sensitive credentials like Application ID, Private Key path/content, and phone numbers. Never commit this file to version control..gitignore: Specifies intentionally untracked files that Git should ignore.
5. Configure .gitignore:
Add node_modules and .env to your .gitignore file to prevent committing them.
# .gitignore
node_modules/
.env
*.log
private.key # Or wherever you store your private key locally6. Project Structure: Your project directory should now look like this:
vonage-sms-app/
├── node_modules/
├── .env
├── .gitignore
├── index.js
├── package-lock.json
├── package.json
└── server.jsThis basic structure separates the sending logic (index.js, often run as a one-off script) from the receiving server logic (server.js, typically run as a long-running process) for clarity and modularity, although they could be combined in a single file for simpler applications. Using .env ensures credentials are kept out of the codebase.
2. Implementing Core Functionality: Sending SMS
We'll start by implementing the code to send an SMS message using the Vonage Messages API.
1. Configure Environment Variables:
Open the .env file and add placeholders for your Vonage Application credentials and numbers. You will obtain these values in Section 4 (Integrating with Vonage). Note that the Vonage Node SDK uses Application ID and Private Key for authentication with the Messages API.
# .env
# Vonage Application Credentials (Generated when creating a Vonage Application)
# These are typically used by the Vonage SDK for Messages API authentication.
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
VONAGE_APPLICATION_PRIVATE_KEY_PATH=./private.key # Path to your downloaded private key (for local dev)
# VONAGE_PRIVATE_KEY_CONTENT=""-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"" # Optional: For deployments, store key *content* here (ensure newlines are escaped if needed)
# Phone Numbers
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # The Vonage number you purchased
TO_NUMBER=RECIPIENT_PHONE_NUMBER # The number you want to send the SMS to (E.164 format, e.g., 14155550100)2. Write Sending Logic (index.js):
Edit index.js to initialize the Vonage SDK and use the messages.send() method.
// index.js
require('dotenv').config(); // Load environment variables from .env file
const { Vonage } = require('@vonage/server-sdk');
const { SMS } = require('@vonage/messages');
const fs = require('fs'); // Needed to read the private key file if using path
// --- Configuration ---
// Ensure required environment variables are set
const requiredEnv = [
'VONAGE_APPLICATION_ID',
'VONAGE_NUMBER',
'TO_NUMBER'
];
// Either private key path or content must be provided
const privateKeyPath = process.env.VONAGE_APPLICATION_PRIVATE_KEY_PATH;
const privateKeyContent = process.env.VONAGE_PRIVATE_KEY_CONTENT;
if (!privateKeyPath && !privateKeyContent) {
console.error('Error: Environment variable VONAGE_APPLICATION_PRIVATE_KEY_PATH or VONAGE_PRIVATE_KEY_CONTENT must be set.');
process.exit(1);
}
if (privateKeyPath && !privateKeyContent && !fs.existsSync(privateKeyPath)) {
console.error(`Error: Private key file not found at path: ${privateKeyPath}`);
process.exit(1);
}
for (const variable of requiredEnv) {
if (!process.env[variable]) {
console.error(`Error: Environment variable ${variable} is not set.`);
process.exit(1); // Exit if essential config is missing
}
}
// Initialize Vonage SDK using Application ID and Private Key
// Prefers key content ENV var if present, otherwise uses path
const vonage = new Vonage({
applicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: privateKeyContent || privateKeyPath,
});
const fromNumber = process.env.VONAGE_NUMBER;
const toNumber = process.env.TO_NUMBER;
const messageText = `Hello from Vonage and Node.js! Sent on ${new Date().toLocaleTimeString()}`;
// --- Send SMS Function ---
async function sendSms() {
console.log(`Attempting to send SMS from ${fromNumber} to ${toNumber}`);
console.log(`Message: ""${messageText}""`);
try {
const resp = await vonage.messages.send(
new SMS({
to: toNumber,
from: fromNumber,
text: messageText,
}),
);
console.log('Message sent successfully!');
console.log('Message UUID:', resp.messageUuid); // Log the unique ID for tracking
} catch (err) {
console.error('Error sending SMS:');
// Log detailed error information if available
if (err.response && err.response.data) {
console.error('Status:', err.response.status);
console.error('Data:', JSON.stringify(err.response.data, null, 2));
} else {
console.error(err);
}
// Consider adding more robust error handling or retry logic here (See Section 5)
}
}
// --- Execute Sending ---
sendSms();Explanation:
require('dotenv').config();: Loads the variables from your.envfile intoprocess.env.- Configuration Check: Basic validation ensures critical environment variables (Application ID, numbers, and either private key path or content) are present before proceeding. It also checks if the key file exists if the path is provided and content is not.
new Vonage({...}): Initializes the SDK using your Application ID and the private key. The logic prioritizes using the key content fromVONAGE_PRIVATE_KEY_CONTENTif set (common for deployments), otherwise it uses the file path fromVONAGE_APPLICATION_PRIVATE_KEY_PATH. The Messages API primarily uses Application ID and Private Key for authentication when using the SDK.vonage.messages.send(...): This is the core function for sending messages.new SMS({...}): We create an SMS message object specifying theto,from, andtextparameters. The SDK handles structuring the request correctly for the Messages API.- Async/Await: We use
async/awaitfor cleaner handling of the asynchronous API call. - Response/Error Handling: The code logs the
messageUuidon success. Thecatchblock logs detailed error information if the request fails, which is crucial for debugging.
3. Building an API Layer: Receiving SMS via Webhook
To receive SMS messages, Vonage needs a publicly accessible URL (a webhook) to send HTTP POST requests to whenever your virtual number receives a message. We'll use Express to create this endpoint.
1. Write Server Logic (server.js):
Edit server.js to set up an Express server listening for POST requests on a specific path.
// server.js
require('dotenv').config(); // Load environment variables
const express = require('express');
const { json, urlencoded } = express;
const app = express();
const PORT = process.env.PORT || 3000; // Use environment variable for port or default to 3000
// --- Middleware ---
// Parse incoming JSON requests (Vonage webhook format is JSON)
app.use(json());
// Parse URL-encoded requests (less common for Vonage webhooks, but good practice)
app.use(urlencoded({ extended: true }));
// --- Webhook Endpoint ---
// Define the route Vonage will POST to. Match this path in your Vonage Application settings.
const inboundWebhookPath = '/webhooks/inbound';
app.post(inboundWebhookPath, (req, res) => {
console.log('-----------------------------------------');
console.log(`Received Inbound SMS at ${new Date().toISOString()}`);
console.log('Request Body:');
console.log(JSON.stringify(req.body, null, 2)); // Log the entire incoming payload
// --- Process the Incoming Message ---
// Basic validation: Check for expected properties
if (req.body.from && req.body.to && req.body.text && req.body.message_uuid) {
console.log(`From: ${req.body.from.number || req.body.from.id}`); // Handle potential variations in 'from' structure
console.log(`To: ${req.body.to.number || req.body.to.id}`);
console.log(`Text: ${req.body.text}`);
console.log(`Message UUID: ${req.body.message_uuid}`);
// TODO: Add your business logic here (See Section 6 & 8)
// - Store the message in a database
// - Check for keywords (e.g., STOP, HELP)
// - Trigger automated replies
// - Enqueue tasks for processing
} else {
console.warn('Received unexpected webhook format:', req.body);
}
// --- Respond to Vonage ---
// IMPORTANT: Always send a 200 OK status back to Vonage quickly.
// Failure to do so will cause Vonage to retry sending the webhook,
// potentially leading to duplicate processing.
res.status(200).send('OK');
// Alternatively, use res.status(200).end(); if no body is needed.
console.log('Sent 200 OK response to Vonage.');
console.log('-----------------------------------------');
});
// --- Optional: Status Webhook Endpoint ---
// Vonage sends delivery receipts (DLRs) and other status updates here.
const statusWebhookPath = '/webhooks/status';
app.post(statusWebhookPath, (req, res) => {
console.log('=========================================');
console.log(`Received Status Update at ${new Date().toISOString()}`);
console.log('Request Body:');
console.log(JSON.stringify(req.body, null, 2));
// TODO: Process the status update (See Section 6 & 8)
// - Update message status in your database using message_uuid
// - Handle 'delivered', 'failed', 'rejected' statuses
res.status(200).send('OK');
console.log('Sent 200 OK response to Vonage.');
console.log('=========================================');
});
// --- Health Check Endpoint (Good Practice) ---
app.get('/health', (req, res) => {
res.status(200).send('Server is healthy');
});
// --- Start Server ---
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
console.log(`Inbound SMS Webhook expected at: POST ${inboundWebhookPath}`);
console.log(`Status Webhook expected at: POST ${statusWebhookPath}`);
console.log(`Health check available at: GET /health`);
});Explanation:
- Middleware:
express.json()is essential for parsing the JSON payload sent by Vonage webhooks.express.urlencoded()is included for general robustness. - Webhook Route (
/webhooks/inbound): This specific path (POST) listens for incoming messages. The path name can be customized, but it must match the Inbound URL configured in your Vonage application. - Logging: The entire request body (
req.body) is logged. This is crucial for understanding the data structure Vonage sends. Specific fields like sender (from), recipient (to), message text (text), andmessage_uuidare logged individually. - Status Webhook Route (
/webhooks/status): A separate endpoint to receive delivery receipts and status updates for outgoing messages you sent. This is vital for tracking message delivery. res.status(200).send('OK'): This is critical. You must respond to Vonage with a 2xx status code (usually 200 OK) quickly to acknowledge receipt of the webhook. If Vonage doesn't receive this, it will assume the delivery failed and retry, leading to duplicate processing on your end.- Health Check: A simple
/healthendpoint is good practice for monitoring systems to check if the server is running. - Port: The server listens on the port specified by the
PORTenvironment variable or defaults to 3000.
4. Integrating with Vonage: Configuration Steps
Now, let's configure your Vonage account and link it to the application code.
1. Purchase a Vonage Virtual Number:
- Log in to your Vonage API Dashboard.
- Navigate to
Numbers>Buy numbers. - Search for numbers with SMS capability in your desired country.
- Purchase a number.
- Copy the purchased number (in E.164 format, e.g.,
14155550100) into theVONAGE_NUMBERvariable in your.envfile.
2. Create a Vonage Application: This application links your number, credentials, and webhook URLs together.
- Navigate to
Applications>Create a new application. - Give your application a meaningful name (e.g.,
Node SMS App - Dev). - Generate Public/Private Key: Click the
Generate public and private keybutton. This will automatically download theprivate.keyfile. Save this file securely in your project directory (or another secure location for local development). UpdateVONAGE_APPLICATION_PRIVATE_KEY_PATHin your.envfile to point to the correct path (e.g.,./private.keyif it's in the root). The public key is stored by Vonage. (For deployment, you'll likely use the key content viaVONAGE_PRIVATE_KEY_CONTENT- see Section 12 if available, or adapt based on deployment strategy). - Capabilities: Toggle on the
Messagescapability. - Configure Webhooks:
- Inbound URL: Enter
YOUR_NGROK_HTTPS_URL/webhooks/inbound(You'll getYOUR_NGROK_HTTPS_URLin the next step). Set the method toPOST. - Status URL: Enter
YOUR_NGROK_HTTPS_URL/webhooks/status. Set the method toPOST.
- Inbound URL: Enter
- Click
Generate new application. - Copy Application ID: After creation, you'll see the Application ID. Copy this value into the
VONAGE_APPLICATION_IDvariable in your.envfile.
3. Link Your Number to the Application:
- Go back to
Numbers>Your numbers. - Find the number you purchased.
- Click the
Linkbutton (or edit icon) next to the number. - Select the application you just created (
Node SMS App - Dev) from theForward to Applicationdropdown under theMessagescapability. - Click
Save.
4. Set Default SMS API (Crucial):
- Navigate to your main Dashboard Settings.
- Scroll down to
API settings. - Find the
Default SMS Settingsection. - Select
Messages APIas the default handler for SMS. This ensures incoming messages use the Messages API format and webhooks. - Click
Save changes.
5. Run ngrok: Now that the server code is ready, expose your local server to the internet using ngrok so Vonage can reach your webhooks. Run this in a separate terminal window.
# Make sure your Express server (server.js) is running or will run on port 3000
ngrok http 3000- ngrok will display output including a
ForwardingURL usinghttps. It looks something likehttps://<random_string>.ngrok.io. - Copy this HTTPS URL.
- Update Vonage Application: Go back to your Vonage Application settings (
Applications> Your App Name > Edit). Paste the copied ngrok HTTPS URL into theInbound URLandStatus URLfields, making sure to append the correct paths (/webhooks/inboundand/webhooks/status).- Example Inbound URL:
https://a1b2c3d4e5f6.ngrok.io/webhooks/inbound - Example Status URL:
https://a1b2c3d4e5f6.ngrok.io/webhooks/status
- Example Inbound URL:
- Save the changes to your Vonage Application.
Your local development environment is now configured to communicate with Vonage.
5. Implementing Error Handling, Logging, and Retry Mechanisms
Robust applications need proper error handling and logging.
Error Handling:
-
Sending (
index.js): Thetry...catchblock already handles errors during thevonage.messages.send()call. It logs detailed error information fromerr.response.dataif available. For production, consider:- Specific Error Codes: Check
err.response.statusor error codes withinerr.response.datato handle specific issues differently (e.g., invalid number format, insufficient funds). - Retry Logic: For transient network errors or Vonage service issues (e.g., 5xx status codes), implement a retry mechanism with exponential backoff using libraries like
async-retryorp-retry. Caution: Do not retry errors like invalid number format (4xx errors) indefinitely.
First, install the library:
bashnpm install async-retryThen, you can implement retry logic like this:
javascript// Example using async-retry in index.js const retry = require('async-retry'); // Assume vonage, SMS, fromNumber, toNumber, messageText are defined as before async function sendSmsWithRetry() { const smsDetails = new SMS({ to: toNumber, from: fromNumber, text: messageText, }); try { await retry( async bail => { console.log('Attempting to send SMS...'); try { const resp = await vonage.messages.send(smsDetails); console.log('Message sent successfully!', resp.messageUuid); // If successful, return the response or true to stop retry return resp; } catch (err) { console.error('Attempt failed:', err.message); // Don't retry on non-recoverable errors (e.g., 4xx client errors) if (err.response && err.response.status >= 400 && err.response.status < 500) { console.error('Non-retriable error:', JSON.stringify(err.response.data, null, 2)); bail(new Error('Non-retriable error encountered')); // Stop retrying return; // Exit async function } // For other errors (network, 5xx), throw to trigger retry throw err; } }, { retries: 3, // Number of retries minTimeout: 1000, // Initial delay ms factor: 2, // Exponential backoff factor onRetry: (error, attempt) => { console.warn(`Retrying sendSMS (Attempt ${attempt}). Error: ${error.message}`); } } ); } catch (error) { console.error('Failed to send SMS after multiple retries:', error.message); // Handle final failure (e.g., log to monitoring, notify admin) } } // Replace the simple sendSms() call in index.js with sendSmsWithRetry() // sendSmsWithRetry(); // Uncomment to use retry logic - Specific Error Codes: Check
-
Receiving (
server.js):- Wrap the core logic inside the webhook handlers (
app.post) in atry...catchblock to handle unexpected errors during message processing (e.g., database errors). - Crucially, ensure the
res.status(200).send('OK')happens outside the maintry...catchor within afinallyblock if you need to guarantee it, even if your internal processing fails. This prevents Vonage retries for issues unrelated to receiving the webhook itself.
javascript// Example in server.js inbound webhook app.post(inboundWebhookPath, (req, res) => { try { console.log('-----------------------------------------'); console.log(`Received Inbound SMS at ${new Date().toISOString()}`); console.log('Request Body:'); console.log(JSON.stringify(req.body, null, 2)); // --- Start of Your Processing Logic --- if (req.body.from && req.body.to && req.body.text && req.body.message_uuid) { console.log(`Processing message from ${req.body.from.number}...`); // Your database interaction, keyword checking, etc. // Simulate potential error: // if (Math.random() > 0.8) throw new Error(""Simulated DB Error!""); } else { console.warn('Received unexpected webhook format'); } // --- End of Your Processing Logic --- } catch (error) { console.error('!!! Error processing inbound webhook:', error); // Log error to your tracking system (e.g., Sentry, Datadog) } finally { // Always acknowledge receipt to Vonage res.status(200).send('OK'); console.log('Sent 200 OK response to Vonage.'); console.log('-----------------------------------------'); } }); - Wrap the core logic inside the webhook handlers (
Logging:
-
The current
console.logis suitable for development. -
For production, use a structured logging library like Pino or Winston:
- Structured Format (JSON): Easier for log aggregation tools (Datadog, Splunk, ELK stack) to parse.
- Log Levels: Differentiate between
info,warn,error,debug. - Log Destinations: Output to files, standard output, or external logging services.
Install Pino:
bashnpm install pino pino-pretty # pino-pretty for dev readabilityIntegrate Pino:
javascript// Example integration in server.js const pino = require('pino'); const logger = pino({ level: process.env.LOG_LEVEL || 'info', // Pretty print for development, JSON for production transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined, }); // Replace console.log with logger methods: // console.log(...) -> logger.info(...) // console.warn(...) -> logger.warn(...) // console.error(...) -> logger.error(...) // Example in inbound webhook app.post(inboundWebhookPath, (req, res) => { const logData = { webhook: 'inbound', body: req.body }; logger.info(logData, 'Received Inbound SMS'); try { // ... processing ... if (req.body.from && req.body.text && req.body.message_uuid) { logger.info(`Processing message from ${req.body.from.number}`); } else { logger.warn(logData, 'Unexpected webhook format'); } } catch (error) { logger.error({ err: error, webhook: 'inbound' }, 'Error processing inbound webhook'); } finally { res.status(200).send('OK'); logger.info({ webhook: 'inbound' }, 'Sent 200 OK response'); } }); // Similar replacements in status webhook and server start logging app.listen(PORT, () => { logger.info(`Server listening on port ${PORT}`); logger.info(`Inbound SMS Webhook expected at: POST ${inboundWebhookPath}`); logger.info(`Status Webhook expected at: POST ${statusWebhookPath}`); logger.info(`Health check available at: GET /health`); });
Retry Mechanisms:
- Vonage Webhook Retries: Vonage automatically retries sending webhooks if it doesn't receive a
2xxresponse within a certain timeout (usually a few seconds). It uses an exponential backoff strategy. This handles temporary network issues between Vonage and your server. Your primary responsibility is to respond200 OKpromptly. - Application-Level Retries (Sending): As shown above with
async-retry, implement this for sending SMS if you need resilience against temporary API failures on the Vonage side or network issues from your server to Vonage.
6. Creating a Database Schema and Data Layer (Conceptual)
While this basic guide doesn't implement a database, a real-world application (especially for campaigns) needs one.
Purpose:
- Log sent and received messages for audit trails and debugging.
- Track message delivery status using updates from the Status Webhook.
- Manage recipient lists and opt-out status for campaigns.
- Store campaign definitions and track progress.
Conceptual Schema (using Prisma as an example ORM):
// prisma/schema.prisma
generator client {
provider = ""prisma-client-js""
}
datasource db {
provider = ""postgresql"" // Or mysql, sqlite, etc.
url = env(""DATABASE_URL"")
}
model Message {
id String @id @default(cuid())
vonageMessageUuid String? @unique // From sending response or status/inbound webhook
direction Direction // INBOUND or OUTBOUND
fromNumber String
toNumber String
text String?
status String? // e.g., 'submitted', 'delivered', 'failed', 'read', 'received'
vonageStatus String? // Raw status code from Vonage (e.g., from status webhook)
errorCode String? // Error code if status is 'failed' or 'rejected' (from status webhook)
price Decimal? // Cost from Vonage status/usage data
currency String?
submittedAt DateTime @default(now()) // When our system processed/sent it
receivedAt DateTime? // When our system received it (for inbound)
vonageTimestamp DateTime? // Timestamp from Vonage webhook
lastUpdatedAt DateTime @updatedAt
@@index([vonageMessageUuid])
@@index([toNumber])
@@index([fromNumber])
@@index([status])
@@index([submittedAt])
}
enum Direction {
INBOUND
OUTBOUND
}
// Add models for Campaigns, Recipients, OptOuts etc. as neededImplementation Steps (High-Level):
- Choose ORM/Driver: Select Prisma, Sequelize, TypeORM, or a database driver (
pg,mysql2). - Install Dependencies:
npm install @prisma/clientandnpm install -D prisma. - Initialize Prisma:
npx prisma init. - Define Schema: Create models in
prisma/schema.prisma. - Set
DATABASE_URL: Add your database connection string to.env. - Run Migrations:
npx prisma migrate dev --name initto create tables. - Generate Client:
npx prisma generate. - Use Prisma Client: Import and use the client in your
index.jsandserver.jsto interact with the database.
// Example usage in server.js webhook handler
// Assumes logger is defined as in Section 5
// Assumes Prisma client is initialized
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient(); // Initialize Prisma Client
// Inside app.post('/webhooks/inbound', async (req, res) => { ... });
// Make the handler async to use await
app.post(inboundWebhookPath, async (req, res) => { // Note: async handler
const logData = { webhook: 'inbound', body: req.body };
// Use logger if integrated, otherwise console.log
// logger.info(logData, 'Received Inbound SMS');
console.log('Received Inbound SMS:', logData);
try {
if (req.body.from && req.body.text && req.body.to && req.body.message_uuid) {
const newMessage = await prisma.message.create({
data: {
vonageMessageUuid: req.body.message_uuid,
direction: 'INBOUND',
fromNumber: req.body.from.number,
toNumber: req.body.to.number,
text: req.body.text,
status: 'received', // Initial status for inbound
vonageTimestamp: req.body.timestamp ? new Date(req.body.timestamp) : new Date(), // Use Vonage timestamp if available
receivedAt: new Date(), // Record when our server got it
}
});
// logger.info({ messageId: newMessage.id }, 'Inbound message saved to DB');
console.log('Inbound message saved to DB:', newMessage.id);
// Check for STOP keyword (Example opt-out logic)
if (req.body.text.trim().toUpperCase() === 'STOP') {
// Add logic to mark fromNumber as opted-out in your recipient/opt-out table
// logger.warn({ number: req.body.from.number }, 'Received STOP keyword - Processing opt-out');
console.warn('Received STOP keyword - Processing opt-out for:', req.body.from.number);
// await prisma.optOut.upsert(...) or similar
}
// TODO: Add other keyword handling (HELP, etc.) or business logic
// **IMPORTANT: TCPA Compliance for Marketing Campaigns**
//
// If you're building SMS marketing campaigns, [new TCPA opt-out rules effective April 11, 2025](https://www.bclplaw.com/en-US/events-insights-news/the-tcpas-new-opt-out-rules-take-effect-on-april-11-2025-what-does-this-mean-for-businesses.html) require significant changes:
//
// * **Expanded Opt-Out Methods:** You must honor opt-out requests through ["any reasonable means,"](https://activeprospect.com/blog/tcpa-opt-out-requirements/) not just "STOP" – including emails, voicemails, or informal messages like "Leave me alone."
// * **10-Business-Day Deadline:** [You have just 10 business days to stop all SMS communications](https://www.textmymainnumber.com/blog/sms-compliance-in-2025-your-tcpa-text-message-compliance-checklist) after receiving an opt-out request (reduced from up to 30 days).
// * **STOP Keyword Required:** Always include opt-out instructions with every message sent. [Implement 'STOP' response capabilities](https://www.textedly.com/sms-compliance-guide/tcpa-compliance-checklist) where customers can text "STOP" to cease communications immediately.
// * **HELP Keyword:** Include "Text HELP for more information" in your initial opt-in message alongside other disclosures.
// * **Confirmation Messages:** You're allowed to send a one-time follow-up message to confirm opt-out preferences, but [only if sent within 5 minutes](https://activeprospect.com/blog/tcpa-opt-out-requirements/) and containing no promotional content.
// * **Prior Express Written Consent:** [Obtain "prior express written consent"](https://www.textedly.com/sms-compliance-guide/tcpa-compliance-checklist) before sending marketing texts, with clear disclosures about message type, charges ("message and data rates may apply"), and opt-out options.
// * **Record-Keeping:** [Retain documentation of opt-out requests for at least 4 years](https://www.carltonfields.com/insights/publications/2025/mastering-the-new-tcpa-opt-out-regulations) (TCPA statute of limitations).
// * **Penalties:** TCPA violations carry [$500-1,500 per violation](https://activeprospect.com/blog/tcpa-text-messages/) with no requirement to prove actual injury.
//
// **Best Practices for Marketing Campaigns:**
// * [Maintain 95%+ deliverability rates](https://www.infobip.com/blog/sms-campaign-best-practices) as an indicator of program health
// * [Send 2-4 messages per month maximum](https://www.omnisend.com/blog/sms-marketing/) to avoid high opt-out rates
// * [Follow 8 AM to 9 PM local time guidelines](https://www.klaviyo.com/products/sms-marketing/best-practices) per carrier requirements
// * [Register for 10DLC (10-Digit Long Code)](https://www.infobip.com/blog/sms-campaign-best-practices) for US messaging to improve deliverability (up to 60 msg/sec vs. 1 msg/sec unverified)
} else {
// logger.warn(logData, 'Received unexpected webhook format');
console.warn('Received unexpected webhook format:', logData);
}
} catch (error) {
// logger.error({ err: error, webhook: 'inbound' }, 'Error processing/saving inbound webhook');
console.error('Error processing/saving inbound webhook:', error);
// Even if DB fails, we should still acknowledge receipt to Vonage
} finally {
res.status(200).send('OK');
// logger.info({ webhook: 'inbound' }, 'Sent 200 OK response');
console.log('Sent 200 OK response to Vonage.');
}
});
// Similar logic needed for the status webhook:
// Find the message by vonageMessageUuid and update its status, errorCode, price etc.
// Example:
// app.post(statusWebhookPath, async (req, res) => {
// try {
// const { message_uuid, status, timestamp, error, price, currency } = req.body;
// if (message_uuid) {
// await prisma.message.update({
// where: { vonageMessageUuid: message_uuid },
// data: {
// status: status, // Update with the received status
// vonageStatus: status, // Store raw Vonage status if needed
// errorCode: error ? error.code : null, // Store error code if present
// price: price ? parseFloat(price) : null,
// currency: currency,
// vonageTimestamp: timestamp ? new Date(timestamp) : new Date(),
// lastUpdatedAt: new Date(), // Explicitly set or rely on @updatedAt
// }
// });
// console.log(`Updated status for message ${message_uuid} to ${status}`);
// } else {
// console.warn('Received status webhook without message_uuid:', req.body);
// }
// } catch (error) {
// console.error('Error processing status webhook:', error);
// } finally {
// res.status(200).send('OK');
// }
// });Note: These code snippets assume you have fully set up Prisma (schema, database connection, migrations, client generation) as outlined in the high-level steps above. They will not run correctly without that setup.
7. Adding Security Features
Securing your application and webhook endpoints is crucial.
- Webhook Security:
- Signed Webhooks (JWT Verification - Recommended): The Vonage Messages API supports webhook signing using JSON Web Tokens (JWT) for verification. It is strongly recommended to implement JWT signature verification on your webhook endpoints to ensure requests genuinely originate from Vonage. Consult the Vonage Server SDK documentation or Messages API security guides for details on how to validate the incoming JWT, typically found in the
Authorizationheader (e.g.,Bearer <token>). The@vonage/server-sdkmay offer helper functions for this (check its documentation forverifySignatureor similar methods). - Obscurity: Use long, unguessable paths for your webhook URLs (less secure than signing, but better than simple paths like
/webhook). - IP Whitelisting (Less Flexible): If Vonage publishes a list of egress IP addresses for webhooks, you could configure your firewall/load balancer to only accept requests from those IPs. This is often brittle as IPs can change without notice. Signature verification is generally preferred.
- Signed Webhooks (JWT Verification - Recommended): The Vonage Messages API supports webhook signing using JSON Web Tokens (JWT) for verification. It is strongly recommended to implement JWT signature verification on your webhook endpoints to ensure requests genuinely originate from Vonage. Consult the Vonage Server SDK documentation or Messages API security guides for details on how to validate the incoming JWT, typically found in the
- Input Validation:
- Always validate data received from webhooks before processing or storing it (e.g., check expected data types, lengths, formats). Libraries like
joiorzodcan help define schemas for validation. - Sanitize any data that might be displayed back to users or used in database queries to prevent injection attacks (though ORMs often handle SQL injection).
- Always validate data received from webhooks before processing or storing it (e.g., check expected data types, lengths, formats). Libraries like
- Environment Variables: Keep sensitive information (API keys, private keys, database URLs) out of your codebase using environment variables and a
.envfile (added to.gitignore). For production deployments, use secure environment variable management provided by your hosting platform (e.g., AWS Secrets Manager, Google Secret Manager, Heroku Config Vars). - Rate Limiting: Implement rate limiting on your webhook endpoints (using middleware like
express-rate-limit) to prevent abuse or denial-of-service attacks. - HTTPS: Always use HTTPS for your webhook URLs (ngrok provides this for local testing; production deployments must have valid SSL/TLS certificates).
Frequently Asked Questions About SMS Marketing with Node.js and Vonage
How do I handle STOP keyword opt-outs for TCPA compliance?
Implement keyword detection in your webhook handler by checking if req.body.text.trim().toUpperCase() === 'STOP'. When detected, immediately mark the sender's number as opted-out in your database (using Prisma's upsert or similar). New TCPA rules effective April 11, 2025 require you to stop all communications within 10 business days and honor opt-outs through "any reasonable means," not just the STOP keyword. Send a confirmation message within 5 minutes containing no promotional content.
What Node.js version should I use for this Express SMS application?
Use Node.js 22.x (Active LTS until October 2025) or Node.js 20.x (maintenance mode) for production deployments. This guide uses Express.js 5.1.0, which requires Node.js 18+. Node.js 18.x reaches end-of-life on April 30, 2025, so upgrade to Node.js 22 for extended support (maintenance until April 2027).
How do I verify Vonage webhook signatures with JWT?
Vonage uses JWT Bearer Authorization (HMAC-SHA256) for webhook signing. Extract the JWT from the Authorization: Bearer <token> header, decode it using your signature secret from the dashboard (minimum 32 bits recommended), verify the payload_hash claim matches an SHA-256 hash of the request body to prevent replay attacks, and check the iat (issued at) timestamp to reject stale tokens. The @vonage/server-sdk may offer helper functions like verifySignature.
What are the E.164 phone number format requirements for Vonage?
E.164 format requires: no leading + or 00, starts with country code, maximum 15 digits, and no trunk prefix 0 after country code. Example: US number 212 123 1234 becomes 14155552671. Vonage requires proper E.164 formatting for all API calls. Use validation libraries like google-libphonenumber or implement regex validation before sending.
What are the alternatives to ngrok for webhook testing?
Top ngrok alternatives in 2025 include: Pinggy (unlimited bandwidth, UDP support, no signup required), Localtunnel (npm package, quick testing), Cloudflare Tunnel (no bandwidth limits on free plan), Zrok (open-source, zero-trust networking), and Tunnelmole (open-source, works with Node, Docker, Python). For CI/CD webhook testing, consider InstaTunnel or LocalXpose.
How do I handle SMS marketing campaign compliance in 2025?
Follow TCPA compliance requirements: obtain prior express written consent before sending marketing messages, include clear disclosures about message type and charges ("message and data rates may apply"), provide opt-out instructions with every message, process opt-outs within 10 business days, maintain 95%+ deliverability rates, send 2-4 messages per month maximum, follow 8 AM to 9 PM local time guidelines, and register for 10DLC for US messaging to improve deliverability (up to 60 msg/sec vs. 1 msg/sec unverified).
What Prisma version should I use for message tracking?
Use Prisma ORM 6.16.0 or later (latest as of 2025). The Rust-free architecture is production-ready with ~90% smaller bundle size, faster queries, and lower CPU footprint. The new Query Compiler replaces legacy query engine with TypeScript implementation. For edge runtimes like Vercel Edge, use the prisma-client generator with engineType: "client". Prisma 6.16.0 stabilized driverAdapters and queryCompiler features.
How do I implement retry logic for failed SMS sends?
Install async-retry (npm install async-retry) and wrap vonage.messages.send() with retry logic: set retries: 3 for 3 attempts, minTimeout: 1000 for initial 1-second delay, factor: 2 for exponential backoff. Important: Don't retry 4xx client errors (invalid number format, insufficient funds) – only retry network errors and 5xx server errors. Check err.response.status to determine if error is retriable. Use bail() to stop retrying non-recoverable errors.
What database indexes should I create for SMS message tracking?
Create indexes on frequently queried columns in your Prisma schema: @@index([vonageMessageUuid]) for webhook lookups, @@index([toNumber]) and @@index([fromNumber]) for recipient/sender queries, @@index([status]) for filtering by delivery status (submitted, delivered, failed), and @@index([submittedAt]) for time-based queries. Add composite index @@index([toNumber, status]) for efficient opt-out status checks.
How do I handle SMS delivery receipts from Vonage?
Create a separate webhook endpoint (e.g., /webhooks/status) to receive Delivery Receipts (DLRs). Extract message_uuid, status (delivered, failed, rejected), timestamp, error.code, price, and currency from the request body. Use prisma.message.update() with where: { vonageMessageUuid: message_uuid } to update the corresponding database record. Always respond with 200 OK quickly to prevent Vonage retries. Track statuses: submitted → delivered/failed/rejected.
Next Steps for Production SMS Marketing Campaigns
Now that you've built your SMS foundation, enhance it with these production features:
-
Implement JWT Webhook Verification – Secure your
/webhooks/inboundand/webhooks/statusendpoints with JWT signature validation using the HMAC-SHA256 secret from your dashboard. Verifypayload_hashandiatclaims to prevent replay attacks. -
Add TCPA-Compliant Opt-Out System – Create
OptOutPrisma model withphoneNumber,optOutDate, andmethodfields. Process not just "STOP" but "any reasonable means" including informal messages. Implement 10-business-day deadline enforcement and 4-year record retention. -
Implement 10DLC Registration – Register your 10DLC Brand and Campaign through the Vonage dashboard to improve US deliverability rates (60 msg/sec vs. 1 msg/sec unverified). Provide business verification, campaign use case, and sample messages for carrier approval.
-
Add Structured Logging with Pino – Replace
console.logwith Pino for JSON-structured logs:logger.info({ webhook: 'inbound', from: number }, 'Received SMS'). Configure log levels (info,warn,error) and usepino-prettyfor development readability. -
Create Campaign Management System – Build Prisma models for
Campaign,Recipient, andCampaignMessage. Track campaign progress, schedule sends, segment recipients, and monitor performance metrics (sent, delivered, failed, opt-out rates). -
Implement Rate Limiting – Use
express-rate-limitmiddleware to protect webhook endpoints:app.use('/webhooks', rateLimit({ windowMs: 60000, max: 100 })). Prevent abuse and DoS attacks while allowing legitimate Vonage traffic. -
Add Delivery Analytics Dashboard – Query Prisma for aggregate metrics: total sent, delivery rate (
status: 'delivered'), failure rate, average delivery time, opt-out percentage. Group by date range, campaign, or recipient segment. -
Configure Webhook Retry Handling – Vonage automatically retries failed webhooks with exponential backoff. Ensure idempotency by checking
vonageMessageUuiduniqueness before creating database records. Useprisma.message.upsert()to handle duplicate webhook deliveries. -
Implement HELP Keyword Auto-Response – Detect "HELP" keyword in inbound messages and automatically reply with support information: "For help, visit example.com/support or call 555-0100. Reply STOP to unsubscribe. Msg&data rates may apply."
-
Add Environment-Specific Configurations – Create separate
.env.development,.env.staging, and.env.productionfiles. Use different Vonage numbers, database URLs, and logging levels per environment. Implementdotenv-flowfor automatic environment loading.
Additional Resources:
- Vonage Messages API Documentation – Official API reference and authentication guides
- TCPA Compliance Checklist 2025 – Complete guide to SMS marketing compliance
- Express.js 5.x Documentation – Latest Express.js features and migration guide
- Prisma Best Practices – Query optimization and connection pooling
- SMS Marketing Best Practices 2025 – Deliverability optimization and campaign strategies
- Vonage Webhook Security Guide – JWT verification implementation
Frequently Asked Questions
How to send SMS with Node.js and Vonage?
Use the Vonage Messages API with the Node.js SDK. Initialize the SDK with your API credentials, then use `vonage.messages.send()` with `to`, `from`, and `text` parameters. This sends an SMS from your Vonage number to the specified recipient.
What is the Vonage Messages API?
The Vonage Messages API is a unified API for sending and receiving messages across various channels, including SMS. It offers more flexibility and features compared to older Vonage SMS APIs and is used in this guide for two-way SMS communication.
Why does Vonage need a webhook URL for SMS?
Vonage uses webhooks (publicly accessible URLs) to send real-time notifications to your application whenever an SMS is received on your Vonage virtual number or when delivery status updates. This allows you to process incoming messages and track delivery efficiently.
When should I use the Vonage Messages API?
Use the Vonage Messages API for projects requiring SMS capabilities, such as building SMS marketing campaigns, setting up automated notifications, implementing two-factor authentication, or creating interactive SMS services.
Can I receive SMS with this Node.js setup?
Yes, create a webhook endpoint in your Node.js/Express application. When your Vonage number receives an SMS, Vonage sends an HTTP POST request to this endpoint. Ensure the path you define in your server matches the Inbound URL configured in your Vonage application.
How to set up a Vonage application for SMS?
In the Vonage Dashboard, create a new application, enable the Messages capability, generate public/private keys (securely store the private key), then configure Inbound and Status URLs pointing to your application's webhooks.
What is ngrok used for with Vonage?
ngrok creates a secure tunnel to your local development server, providing a temporary public HTTPS URL. This is essential for testing webhooks locally because Vonage needs to reach your server, which would otherwise be inaccessible from the internet.
How to handle inbound SMS webhooks in Node.js?
Use Express.js to create a POST route that matches the Inbound URL in your Vonage application settings. Log the request body and process incoming message data, ensuring a quick '200 OK' response to prevent Vonage retries.
What is the purpose of the status webhook?
The status webhook provides updates on the delivery status of outbound SMS messages. It sends information like 'delivered', 'failed', 'rejected', allowing you to track message delivery success or troubleshoot issues.
How to implement retry logic for sending SMS?
Use a library like `async-retry` to handle temporary network issues or Vonage API failures. Configure the retry attempts, delay, and exponential backoff. Ensure non-recoverable errors (like incorrect number formats) do not trigger infinite retries.
Why is responding '200 OK' to Vonage webhooks important?
A 200 OK response tells Vonage that your server successfully received the webhook. Without it, Vonage will retry sending the request, potentially leading to duplicate message processing and errors in your application.
What security considerations are there with Vonage SMS integration?
Use signed webhooks (JWT verification) for secure communication between Vonage and your application. Validate all user inputs and use secure storage for your API keys. Also, implement rate limiting to prevent abuse.
How do I manage Vonage API keys and credentials?
Store your Vonage Application ID and private key securely. During development, you can store these in a .env file (listed in .gitignore) and load them with the dotenv library. For production, use your platform's secure secret storage.
What database schema should I use for SMS records?
A suitable schema should log inbound/outbound messages, track delivery statuses (using the message_uuid), manage recipients (for campaigns), and store campaign details. Using an ORM like Prisma simplifies database interactions.