This guide provides a step-by-step walkthrough for building an application to send basic SMS marketing campaigns using Node.js, Express, and the Vonage Messages API. We'll cover project setup, core sending logic, API creation, database integration, error handling, security considerations, and deployment strategies.
By the end of this tutorial, you will have a functional backend service capable of accepting a list of recipients and a message, then sending that message via SMS to each recipient using Vonage.
Important Note: This guide builds a foundational service. Key production features like robust API authentication, comprehensive opt-out handling via webhooks, and delivery receipt processing are discussed but not fully implemented in the provided code. These are critical additions for a true production deployment.
Project Overview and Goals
Goal: To create a simple, robust backend service that can programmatically send SMS messages to a list of phone numbers for marketing campaign purposes.
Problem Solved: Manually sending SMS messages to multiple recipients is inefficient and error-prone. This service automates the process, enabling scalable SMS outreach via an API.
Technologies Used:
Node.js: A JavaScript runtime built on Chrome's V8 engine, ideal for building fast, scalable network applications.
Express: A minimal and flexible Node.js web application framework providing a robust set of features for web and mobile applications.
Vonage Messages API: A powerful API for sending and receiving messages across various channels, including SMS. We'll use it for its reliability and features. We specifically use the Application ID and Private Key authentication method for enhanced security.
Prisma: A modern database toolkit for Node.js and TypeScript, simplifying database access with an intuitive ORM. We'll use it with SQLite for local development simplicity, but it easily adapts to PostgreSQL or MySQL for production.
dotenv: A zero-dependency module that loads environment variables from a .env file into process.env.
Node.js and npm (or yarn): Installed on your development machine. Download Node.js
Vonage API Account: Sign up for free at Vonage. You'll receive some initial free credit.
Vonage Application: You'll need to create a Vonage application and generate a private key.
Vonage Phone Number: You need a Vonage virtual number capable of sending SMS. You can purchase one from the Vonage dashboard.
(Optional) ngrok: Useful for testing webhook implementations (like opt-out replies or delivery receipts described in Step 8) locally, though not required for the basic sending functionality built here. Download ngrok
(Optional) Postman or curl: For testing the API endpoints.
1. Setting Up the Project
Let's initialize the project, install dependencies, and set up the basic structure.
1. Create Project Directory:
Open your terminal and create a new directory for your project.
Bash
mkdir vonage-sms-campaigner
cd vonage-sms-campaigner
2. Initialize Node.js Project:
This creates a package.json file.
Bash
npm init -y
3. Install Dependencies:
express: Web framework.
@vonage/server-sdk: The official Vonage Node.js SDK (we'll use the Messages API part).
prisma: The Prisma CLI for migrations and database management.
8. Configure Environment Variables (.env):
Create the .env file in the project root. We'll add Vonage credentials later. For now, set the database URL (Prisma already added this) and optionally the port. Replace the placeholder values after completing Step 4.
.env
# .env
# Database Configuration (Prisma default)
DATABASE_URL="file:./prisma/dev.db"
# Server Port
PORT=3000
# Vonage Credentials (Add these later in Step 4)
VONAGE_APPLICATION_ID=YOUR_VONAGE_APP_ID_FROM_DASHBOARD # Replace with actual ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root where you save the key
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER_E164 # Replace with actual Vonage number (e.g., +12015550123)
Why .env? Storing configuration like API keys and database URLs in environment variables keeps sensitive data out of your codebase and makes it easy to configure different deployment environments.
2. Implementing Core Functionality
We need services to interact with Vonage and manage the campaign sending logic.
1. Vonage Service (src/services/vonageService.js):
This service initializes the Vonage SDK and provides a function to send a single SMS.
JavaScript
// src/services/vonageService.jsconst { Vonage } = require('@vonage/server-sdk');
const path = require('path');
const fs = require('fs'); // Needed to check if key file exists// Resolve path from the current working directory where Node is startedconst privateKeyPath = process.env.VONAGE_PRIVATE_KEY_PATH
? path.resolve(process.cwd(), process.env.VONAGE_PRIVATE_KEY_PATH)
: null;
if (!privateKeyPath || !fs.existsSync(privateKeyPath)) {
console.error(`Error: Vonage private key file not found at path specified by VONAGE_PRIVATE_KEY_PATH: ${process.env.VONAGE_PRIVATE_KEY_PATH}`);
// Optionally exit or handle appropriately// process.exit(1);
}
const vonage = newVonage({
applicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: privateKeyPath, // Use the resolved absolute path
});
const fromNumber = process.env.VONAGE_NUMBER;
/**
* Sends a single SMS message using the Vonage Messages API.
* @param {string} to - The recipient's phone number (E.164 format recommended).
* @param {string} text - The message content.
* @returns {Promise<object>} - Promise resolving with the Vonage API response.
* @throws {Error} - Throws an error if sending fails.
*/asyncfunctionsendSms(to, text) {
if (!to || !text) {
thrownewError('Recipient phone number and message text are required.');
}
if (!process.env.VONAGE_APPLICATION_ID || !privateKeyPath || !fromNumber) {
thrownewError('Vonage credentials (Application ID, Private Key Path, Number) are missing or invalid in .env');
}
console.log(`Attempting to send SMS from ${fromNumber} to ${to}`);
try {
// Use standard double quotes for JSON string valuesconst resp = await vonage.messages.send({
message_type: ""text"",
text: text,
to: to,
from: fromNumber,
channel: ""sms""
});
console.log(`Message sent successfully to ${to}: ${resp.message_uuid}`);
return resp; // Contains message_uuid
} catch (err) {
// Log more detailed Vonage error if availableconst errorMessage = err.response?.data?.title || err.response?.data?.detail || err.message || 'Unknown Vonage error';
const errorStatus = err.response?.status || 'N/A';
console.error(`Error sending SMS to ${to}: Status ${errorStatus} - ${errorMessage}`);
// Rethrow a more informative errorthrownewError(`Failed to send SMS to ${to}. Vonage API Error: ${errorMessage} (Status: ${errorStatus})`);
}
}
module.exports = { sendSms };
Why Application ID/Private Key? This authentication method is generally more secure than API Key/Secret for server-to-server communication, as the private key never leaves your server. The SDK handles the JWT generation needed for authentication.
2. Campaign Service (src/services/campaignService.js):
This service orchestrates the sending process, fetching recipients and iterating through them to send messages via the vonageService.
JavaScript
// src/services/campaignService.jsconst { PrismaClient } = require('@prisma/client');
const { sendSms } = require('./vonageService');
const prisma = newPrismaClient();
/**
* Sends an SMS campaign to a list of recipients.
* @param {string[]} recipientNumbers - Array of phone numbers (E.164 format recommended).
* @param {string} message - The message text to send.
* @returns {Promise<object>} - Promise resolving with results (successes, failures).
*/asyncfunctionsendCampaign(recipientNumbers, message) {
if (!Array.isArray(recipientNumbers) || recipientNumbers.length === 0) {
thrownewError('Recipient numbers must be a non-empty array.');
}
if (!message) {
thrownewError('Message text cannot be empty.');
}
const results = {
successes: [],
failures: [],
};
// Sequentially send messages to avoid overwhelming the API rate limits initially// For higher volume, consider batching or parallel requests with delays & proper rate limitingfor (const number of recipientNumbers) {
try {
// Basic E.164 format check (can be improved with regex)if (!/^\+[1-9]\d{1,14}$/.test(number)) {
thrownewError(`Invalid phone number format: ${number}. Must be E.164 (e.g., +14155552671).`);
}
const response = awaitsendSms(number, message);
results.successes.push({ number: number, message_uuid: response.message_uuid });
// Optional: Add a small delay between sends if hitting rate limits// await new Promise(resolve => setTimeout(resolve, 200)); // 200ms delay
} catch (error) {
console.error(`Failed processing number ${number}: ${error.message}`);
results.failures.push({ number: number, error: error.message });
}
}
console.log(`Campaign finished. Success: ${results.successes.length}, Failures: ${results.failures.length}`);
return results;
}
// --- Example of fetching recipients from DB (Requires Schema Update - See Step 6) ---/**
* Sends an SMS campaign to recipients belonging to a specific group.
* @param {number} recipientGroupId - The ID of the RecipientGroup.
* @param {string} message - The message text to send.
* @returns {Promise<object>} - Promise resolving with results (successes, failures).
*/asyncfunctionsendCampaignToGroup(recipientGroupId, message) {
const groupWithRecipients = await prisma.recipientGroup.findUnique({
where: { id: recipientGroupId },
include: {
recipients: { // Only include recipients who haven't opted outwhere: { isOptedOut: { not: true } } // Requires 'isOptedOut' field in schema (Step 8)
}
},
});
if (!groupWithRecipients) {
thrownewError(`Recipient group with ID ${recipientGroupId} not found.`);
}
const recipientNumbers = groupWithRecipients.recipients.map(r => r.phoneNumber);
if (recipientNumbers.length === 0) {
console.warn(`Recipient group ${recipientGroupId} has no active recipients.`);
return { successes: [], failures: [] };
}
returnsendCampaign(recipientNumbers, message);
}
module.exports = { sendCampaign, sendCampaignToGroup }; // Export both functions
Design Choice: Sending messages sequentially is simpler to start with. For large campaigns, you'd implement parallel sending with rate limiting awareness (e.g., using libraries like p-limit or async.queue) and potentially handle Vonage's asynchronous responses via status webhooks for better tracking (See Step 8).
3. Building a Complete API Layer
We need an endpoint to trigger the campaign sending process.
1. Campaign Controller (src/controllers/campaignController.js):
Handles incoming API requests, validates input, calls the appropriate service, and sends back the response.
JavaScript
// src/controllers/campaignController.jsconst { sendCampaign, sendCampaignToGroup } = require('../services/campaignService');
/**
* POST /api/campaigns/send
* Sends a campaign to a provided list of numbers.
* Request body example: { "recipientNumbers": ["+15551234567", "+447700900123"], "message": "Hello!" }
*/asyncfunctionhandleSendCampaign(req, res, next) {
const { recipientNumbers, message } = req.body;
// Basic Validationif (!Array.isArray(recipientNumbers) || recipientNumbers.length === 0 || typeof message !== 'string' || message.trim() === '') {
return res.status(400).json({
error: 'Invalid input. Requires "recipientNumbers" (non-empty array of strings) and "message" (non-empty string).',
});
}
// Further validation (e.g., check number format within the service or here)try {
const results = awaitsendCampaign(recipientNumbers, message);
res.status(200).json({
message: 'Campaign processing completed.', // Changed from "initiated" as it's sequential for nowresults: results,
});
} catch (error) {
console.error('Error in handleSendCampaign:', error);
// Pass to the centralized error handlernext(error);
}
}
/**
* POST /api/campaigns/send-group
* Sends a campaign to recipients of a specific group ID.
* Request body example: { "recipientGroupId": 1, "message": "Group message!" }
*/asyncfunctionhandleSendCampaignToGroup(req, res, next) {
const { recipientGroupId, message } = req.body;
// Basic Validationconst groupId = parseInt(recipientGroupId, 10);
if (isNaN(groupId) || typeof message !== 'string' || message.trim() === '') {
return res.status(400).json({
error: 'Invalid input. Requires "recipientGroupId" (number) and "message" (non-empty string).',
});
}
try {
const results = awaitsendCampaignToGroup(groupId, message);
res.status(200).json({
message: `Campaign processing completed for group ${groupId}.`, // Changed from "initiated"results: results,
});
} catch (error) {
console.error('Error in handleSendCampaignToGroup:', error);
// Handle specific errors like "group not found" differently if neededif (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
// Pass other errors to the centralized handlernext(error);
}
}
module.exports = { handleSendCampaign, handleSendCampaignToGroup };
2. Campaign Routes (src/routes/campaignRoutes.js):
Defines the API endpoints and maps them to controller functions. Note: These endpoints currently lack authentication (See Step 7).
JavaScript
// src/routes/campaignRoutes.jsconst express = require('express');
const { handleSendCampaign, handleSendCampaignToGroup } = require('../controllers/campaignController');
// Rate limiting middleware will be introduced and implemented in Step 7const rateLimiter = require('../middleware/rateLimiter');
const router = express.Router();
// WARNING: These endpoints are currently PUBLIC. Add authentication middleware for production.// Apply rate limiting (introduced in Step 7) to campaign sending endpoints
router.post('/send', rateLimiter, handleSendCampaign);
router.post('/send-group', rateLimiter, handleSendCampaignToGroup);
// Optional: Endpoint to check basic Vonage configuration status
router.get('/status', (req, res) => {
// Basic check: Ensure Vonage credentials seem present in environmentif (process.env.VONAGE_APPLICATION_ID && process.env.VONAGE_PRIVATE_KEY_PATH && process.env.VONAGE_NUMBER) {
// Further check could involve ensuring the private key file exists
res.status(200).json({ status: 'Vonage credentials seem loaded from environment variables.'});
} else {
res.status(500).json({ status: 'Error: One or more required Vonage credentials missing in environment variables.' });
}
});
module.exports = router;
3. Testing the Endpoint (Example with curl):(Before running, ensure you've completed Step 4 for Vonage setup and Step 6 for Database setup if using /send-group)
Start your server: node src/server.js
Test /send endpoint:
Bash
curl -X POST http://localhost:3000/api/campaigns/send \
-H "Content-Type: application/json" \
-d '{
"recipientNumbers": ["+15551234567", "+447700900123"],
"message": "Hello from our Vonage Campaigner!"
}'# Replace numbers with your actual test numbers (whitelisted on trial accounts)
Navigate to ""Applications"" -> ""Create a new application"".
Give it a name (e.g., ""Node SMS Campaigner"").
Click ""Generate public and private key"". Immediately save the private.key file that downloads. Place this file in the root directory of your project (or the location specified by VONAGE_PRIVATE_KEY_PATH in your .env).
Enable ""Messages"" capability.
For the Inbound URL and Status URL, you need publicly accessible endpoints if you want to handle replies (opt-outs) or delivery receipts (See Step 8).
For local testing (if implementing webhooks):
Run ngrok http 3000 (assuming your server runs on port 3000).
Copy the HTTPS forwarding URL provided by ngrok (e.g., https://random-string.ngrok.io).
Enter YOUR_NGROK_URL/webhooks/inbound for Inbound URL (e.g., https://random-string.ngrok.io/webhooks/inbound).
Enter YOUR_NGROK_URL/webhooks/status for Status URL (e.g., https://random-string.ngrok.io/webhooks/status).
(Note: The webhook handlers themselves are not implemented in this guide's code).
For production: Use your deployed application's webhook URLs.
Click ""Generate new application"".
3. Note Your Application ID:
On the application details page, copy the Application ID.
4. Link a Vonage Number:
Go to ""Numbers"" -> ""Your numbers"".
If you don't have one, click ""Buy numbers"", find one with SMS capability in your desired country, and purchase it.
Go back to your Application details (""Applications"" -> Your App).
Under ""Link numbers"", find your virtual number and click ""Link"".
5. Configure .env:
Open your .env file and fill in the Vonage details with the values you obtained:
.env
# .env (Update these lines with your actual values)
# ... other variables ...
VONAGE_APPLICATION_ID=PASTE_YOUR_APPLICATION_ID_HERE # Replace with the ID from Vonage Dashboard
VONAGE_PRIVATE_KEY_PATH=./private.key # Ensure this path is correct relative to project root
VONAGE_NUMBER=PASTE_YOUR_VONAGE_NUMBER_HERE # Replace with your linked Vonage number in E.164 format (e.g., +14155552671)
VONAGE_APPLICATION_ID: The ID copied from the Vonage dashboard.
VONAGE_PRIVATE_KEY_PATH: The path relative to your project root where you saved the private.key file. ./private.key assumes it's directly in the root. The code (vonageService.js) resolves this relative to the current working directory.
VONAGE_NUMBER: The Vonage virtual number (must be in E.164 format, e.g., +14155552671) you linked to the application.
Security: NEVER commit your private.key file or your .env file containing secrets to Git. Ensure .gitignore includes them (add *.key and .env*).
5. Implementing Error Handling and Logging
Robust error handling and logging are crucial.
1. Consistent Error Handling:
Services (vonageService, campaignService) use try...catch and throw errors, sometimes re-throwing more specific ones.
The controller (campaignController.js) catches errors from services and either returns specific client errors (400, 404) or passes server errors (next(error)) to the central Express error handler.
Replace the basic error middleware in server.js with a more structured one.
Example: Enhance server.js Error Handler:
JavaScript
// src/server.js (Replace the simple error handler with this)// Remove or comment out the previous simple error handler:// app.use((err, req, res, next) => {// console.error(err.stack);// res.status(500).send({ error: 'Something went wrong!' });// });// More structured error handler (Place this AFTER your routes)
app.use((err, req, res, next) => {
// Log the error internally (replace console.error with a proper logger like Winston/Pino in production)console.error(`[ERROR] ${req.method}${req.path} - ${err.message}`);
// Log stack trace only in development or if needed for detailed debuggingif (process.env.NODE_ENV !== 'production') {
console.error(err.stack);
}
// Determine status code - check for custom status or default to 500// Use err.status for compatibility with some error libraries, fallback to statusCodeconst statusCode = err.status || err.statusCode || 500;
// Send structured error response to client - avoid leaking sensitive details
res.status(statusCode).json({
status: 'error',
// Only include specific error message in non-production environments for debugging// In production, use a generic message unless it's a specific client error (4xx) you want to exposemessage: (statusCode < 500 || process.env.NODE_ENV !== 'production') ? err.message : 'An internal server error occurred.',
});
});
2. Logging:
Currently using console.log and console.error. This is insufficient for production monitoring and debugging.
Recommendation: Integrate a structured logging library like Pino (very performant) or Winston.
Configure log levels (info, warn, error, debug).
Log to standard output (for containerized environments) or files. Consider sending logs to a centralized log management service (e.g., Datadog, Logtail, Sentry, ELK Stack).
Include timestamps, request IDs (using middleware like express-request-id), and relevant context (e.g., user ID if authenticated, campaign ID).
Network issues or temporary Vonage API rate limits can cause transient failures.
Simple Retry (Conceptual): Wrap the vonageService.sendSms call within the campaignService loop with delays (exponential backoff is recommended).
JavaScript
// Inside campaignService.js - Conceptual Retry Logic (Not fully implemented here)asyncfunctionsendSmsWithRetry(number, message, maxRetries = 2) {
let attempt = 0;
while (attempt <= maxRetries) {
try {
returnawaitsendSms(number, message); // Attempt sending
} catch (error) {
attempt++;
// Check if the error is potentially retryable (e.g., network error, 429 Too Many Requests)const isRetryable = error.message.includes('429') || error.message.includes('network'); // Basic checkif (!isRetryable || attempt > maxRetries) {
console.error(`Final attempt failed for ${number} after ${attempt} tries or error not retryable.`);
throw error; // Rethrow original error
}
// Exponential backoff: wait 1s, 2s, 4s...const delay = Math.pow(2, attempt - 1) * 1000 * (Math.random() + 0.5); // Add jitterconsole.warn(`Attempt ${attempt} failed for ${number}. Retrying in ${delay.toFixed(0)}ms... Error: ${error.message}`);
awaitnewPromise(resolve =>setTimeout(resolve, delay));
}
}
}
// You would then modify sendCampaign to call sendSmsWithRetry instead of sendSms directly.
Robust Retries: Use libraries like async-retry or leverage a background job queue system (Step 9) which often have built-in retry capabilities. For delivery confirmation, rely on Delivery Receipts via webhooks (Step 8).
6. Creating a Database Schema and Data Layer
We'll use Prisma to define a schema for recipients and groups.
1. Define Prisma Schema (prisma/schema.prisma):
Update the schema file to include models for Recipient and RecipientGroup. Add an isOptedOut flag for compliance (See Step 8).
Prisma
// prisma/schema.prisma
generator client {
provider = ""prisma-client-js""
}
datasource db {
provider = ""sqlite"" // Or ""postgresql"", ""mysql""
url = env(""DATABASE_URL"")
}
model Recipient {
id Int @id @default(autoincrement())
phoneNumber String @unique // E.164 format REQUIRED
firstName String?
lastName String?
isOptedOut Boolean @default(false) // For handling STOP replies
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relation to RecipientGroup (a recipient can belong to one group)
recipientGroup RecipientGroup? @relation(fields: [recipientGroupId], references: [id])
recipientGroupId Int?
@@index([recipientGroupId]) // Index for faster group lookups
}
model RecipientGroup {
id Int @id @default(autoincrement())
name String @unique // e.g., ""Newsletter Subscribers"", ""VIP Customers""
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relation to multiple recipients
recipients Recipient[]
}
Recipient: Stores individual phone numbers (enforce E.164 format), optional names, and the crucial isOptedOut flag.
RecipientGroup: Allows grouping recipients.
2. Apply Migrations:
Generate and apply the SQL migration to create/update the database tables.
Bash
# Create the migration files based on schema changes# Prisma will prompt you for a name, e.g., add_recipient_models_and_optout
npx prisma migrate dev
# This command will also apply the migration to your dev database (dev.db)
Prisma creates an SQL migration file in prisma/migrations/ and updates your dev.db.
3. (Optional) Seed the Database:
Create sample data for testing.
// package.json{// ... other package.json content ...""prisma"":{""seed"":""node prisma/seed.js""},""scripts"":{""start"":""node src/server.js"",""dev"":""nodemon src/server.js"",// If using nodemon""migrate:dev"":""npx prisma migrate dev"",""db:seed"":""npx prisma db seed""// Add this line// ... other scripts ...}// ... rest of package.json ...}
Run the seed script:
Bash
npx prisma db seed
This executes the prisma/seed.js file.
Frequently asked questions
Use Node.js with Express and the Vonage Messages API to build an application that can send SMS messages programmatically. This involves setting up routes to handle incoming requests, services to interact with the Vonage API, and database integration to manage recipients. The provided guide offers a step-by-step walkthrough for building this type of application.
The Vonage Messages API is a service that allows developers to send and receive messages across various channels, including SMS. It's used in this project to send SMS messages for marketing campaigns. It offers reliability and various features useful for developers.
This authentication method is preferred for server-side applications because the private key never leaves your server, offering enhanced security over API Key/Secret methods. The Vonage SDK manages JWT generation for authentication, simplifying implementation.
For production SMS marketing systems, webhooks are crucial for features like opt-out management via user replies and delivery receipt processing. Although discussed, the provided code doesn't fully implement webhooks. For basic campaign sending, it's not strictly required in the initial stages.
Yes, Prisma supports various databases like PostgreSQL and MySQL for production deployments. This guide uses SQLite for simplicity during local development, but you can easily adapt the schema and configuration to a different database provider.
Start by initializing a Node.js project (npm init -y), install dependencies (express, @vonage/server-sdk, prisma, @prisma/client, dotenv), set up Prisma (npx prisma init --datasource-provider sqlite), define your project structure (folders for controllers, services, routes), and configure your .gitignore file to exclude sensitive data.
Dotenv loads environment variables from a .env file into process.env. This is essential for storing sensitive data, such as API keys and database URLs, outside of your codebase and simplifying configuration across different environments.
The recommended project structure includes directories for controllers, services, and routes to separate concerns. The controllers handle API requests and validation, services encapsulate logic for Vonage API interactions, and routes define the API endpoints. The server.js file sets up the Express app and serves as the entry point.
Prisma is used as a database toolkit and ORM. It simplifies database operations with its client and schema management capabilities. The project uses SQLite for development, but Prisma can switch to other production databases like PostgreSQL and MySQL easily.
The vonageService.js file provides a sendSms function. This function initializes the Vonage SDK with your credentials and then uses the messages.send method to send the SMS message to the specified recipient number. Ensure your .env file is configured correctly with Vonage credentials.
The guide emphasizes using try...catch blocks in services and controllers to handle errors robustly. A centralized error handler in server.js catches any unhandled errors and sends a structured error response to the client, avoiding leaking sensitive information.
The project uses a database with Prisma to store recipient information. You can create recipient groups and add individual recipients, including optional details like names. The schema includes an 'isOptedOut' field to help manage opt-outs, essential for compliance.
E.164 is an international standard format for phone numbers, ensuring consistent formatting for global SMS delivery. The project emphasizes using this format (e.g., +14155552671) throughout the application, including the database schema and validation.
You'll need Node.js and npm (or yarn) installed, a Vonage API account with a Vonage application created, and a Vonage virtual number linked to your application. For local testing with webhooks, ngrok is recommended. Postman or curl are helpful for testing the API endpoints.
This guide provides a step-by-step walkthrough for building an application to send basic SMS marketing campaigns using Node.js, Express, and the Vonage Messages API. We'll cover project setup, core sending logic, API creation, database integration, error handling, security considerations, and deployment strategies.
By the end of this tutorial, you will have a functional backend service capable of accepting a list of recipients and a message, then sending that message via SMS to each recipient using Vonage.
Important Note: This guide builds a foundational service. Key production features like robust API authentication, comprehensive opt-out handling via webhooks, and delivery receipt processing are discussed but not fully implemented in the provided code. These are critical additions for a true production deployment.
Project Overview and Goals
Goal: To create a simple, robust backend service that can programmatically send SMS messages to a list of phone numbers for marketing campaign purposes.
Problem Solved: Manually sending SMS messages to multiple recipients is inefficient and error-prone. This service automates the process, enabling scalable SMS outreach via an API.
Technologies Used:
.envfile intoprocess.env.System Architecture:
Prerequisites:
1. Setting Up the Project
Let's initialize the project, install dependencies, and set up the basic structure.
1. Create Project Directory: Open your terminal and create a new directory for your project.
mkdir vonage-sms-campaigner cd vonage-sms-campaigner2. Initialize Node.js Project: This creates a
package.jsonfile.3. Install Dependencies:
express: Web framework.@vonage/server-sdk: The official Vonage Node.js SDK (we'll use the Messages API part).prisma: The Prisma CLI for migrations and database management.@prisma/client: The Prisma database client.dotenv: For managing environment variables.4. Initialize Prisma: Set up Prisma with SQLite for simplicity in development.
This creates:
prismadirectory with aschema.prismafile..envfile (if it doesn't exist) with aDATABASE_URLvariable.5. Define Project Structure: Create the following directories and files:
6. Configure
.gitignore: Ensure sensitive files and unnecessary directories aren't committed to version control. Create or edit.gitignore:7. Set up Basic Express Server (
src/server.js):// src/server.js require('dotenv').config(); // Load .env variables first const express = require('express'); const campaignRoutes = require('./routes/campaignRoutes'); const app = express(); const PORT = process.env.PORT || 3000; // Middleware app.use(express.json()); // Parse JSON request bodies app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies // Basic Root Route app.get('/', (req, res) => { res.send('Vonage SMS Campaigner API is running!'); }); // API Routes app.use('/api/campaigns', campaignRoutes); // Simple Error Handling Middleware (Will be enhanced in Step 5) app.use((err, req, res, next) => { console.error(err.stack); res.status(500).send({ error: 'Something went wrong!' }); }); app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); });8. Configure Environment Variables (
.env): Create the.envfile in the project root. We'll add Vonage credentials later. For now, set the database URL (Prisma already added this) and optionally the port. Replace the placeholder values after completing Step 4.Why
.env? Storing configuration like API keys and database URLs in environment variables keeps sensitive data out of your codebase and makes it easy to configure different deployment environments.2. Implementing Core Functionality
We need services to interact with Vonage and manage the campaign sending logic.
1. Vonage Service (
src/services/vonageService.js): This service initializes the Vonage SDK and provides a function to send a single SMS.// src/services/vonageService.js const { Vonage } = require('@vonage/server-sdk'); const path = require('path'); const fs = require('fs'); // Needed to check if key file exists // Resolve path from the current working directory where Node is started const privateKeyPath = process.env.VONAGE_PRIVATE_KEY_PATH ? path.resolve(process.cwd(), process.env.VONAGE_PRIVATE_KEY_PATH) : null; if (!privateKeyPath || !fs.existsSync(privateKeyPath)) { console.error(`Error: Vonage private key file not found at path specified by VONAGE_PRIVATE_KEY_PATH: ${process.env.VONAGE_PRIVATE_KEY_PATH}`); // Optionally exit or handle appropriately // process.exit(1); } const vonage = new Vonage({ applicationId: process.env.VONAGE_APPLICATION_ID, privateKey: privateKeyPath, // Use the resolved absolute path }); const fromNumber = process.env.VONAGE_NUMBER; /** * Sends a single SMS message using the Vonage Messages API. * @param {string} to - The recipient's phone number (E.164 format recommended). * @param {string} text - The message content. * @returns {Promise<object>} - Promise resolving with the Vonage API response. * @throws {Error} - Throws an error if sending fails. */ async function sendSms(to, text) { if (!to || !text) { throw new Error('Recipient phone number and message text are required.'); } if (!process.env.VONAGE_APPLICATION_ID || !privateKeyPath || !fromNumber) { throw new Error('Vonage credentials (Application ID, Private Key Path, Number) are missing or invalid in .env'); } console.log(`Attempting to send SMS from ${fromNumber} to ${to}`); try { // Use standard double quotes for JSON string values const resp = await vonage.messages.send({ message_type: ""text"", text: text, to: to, from: fromNumber, channel: ""sms"" }); console.log(`Message sent successfully to ${to}: ${resp.message_uuid}`); return resp; // Contains message_uuid } catch (err) { // Log more detailed Vonage error if available const errorMessage = err.response?.data?.title || err.response?.data?.detail || err.message || 'Unknown Vonage error'; const errorStatus = err.response?.status || 'N/A'; console.error(`Error sending SMS to ${to}: Status ${errorStatus} - ${errorMessage}`); // Rethrow a more informative error throw new Error(`Failed to send SMS to ${to}. Vonage API Error: ${errorMessage} (Status: ${errorStatus})`); } } module.exports = { sendSms };Why Application ID/Private Key? This authentication method is generally more secure than API Key/Secret for server-to-server communication, as the private key never leaves your server. The SDK handles the JWT generation needed for authentication.
2. Campaign Service (
src/services/campaignService.js): This service orchestrates the sending process, fetching recipients and iterating through them to send messages via thevonageService.// src/services/campaignService.js const { PrismaClient } = require('@prisma/client'); const { sendSms } = require('./vonageService'); const prisma = new PrismaClient(); /** * Sends an SMS campaign to a list of recipients. * @param {string[]} recipientNumbers - Array of phone numbers (E.164 format recommended). * @param {string} message - The message text to send. * @returns {Promise<object>} - Promise resolving with results (successes, failures). */ async function sendCampaign(recipientNumbers, message) { if (!Array.isArray(recipientNumbers) || recipientNumbers.length === 0) { throw new Error('Recipient numbers must be a non-empty array.'); } if (!message) { throw new Error('Message text cannot be empty.'); } const results = { successes: [], failures: [], }; // Sequentially send messages to avoid overwhelming the API rate limits initially // For higher volume, consider batching or parallel requests with delays & proper rate limiting for (const number of recipientNumbers) { try { // Basic E.164 format check (can be improved with regex) if (!/^\+[1-9]\d{1,14}$/.test(number)) { throw new Error(`Invalid phone number format: ${number}. Must be E.164 (e.g., +14155552671).`); } const response = await sendSms(number, message); results.successes.push({ number: number, message_uuid: response.message_uuid }); // Optional: Add a small delay between sends if hitting rate limits // await new Promise(resolve => setTimeout(resolve, 200)); // 200ms delay } catch (error) { console.error(`Failed processing number ${number}: ${error.message}`); results.failures.push({ number: number, error: error.message }); } } console.log(`Campaign finished. Success: ${results.successes.length}, Failures: ${results.failures.length}`); return results; } // --- Example of fetching recipients from DB (Requires Schema Update - See Step 6) --- /** * Sends an SMS campaign to recipients belonging to a specific group. * @param {number} recipientGroupId - The ID of the RecipientGroup. * @param {string} message - The message text to send. * @returns {Promise<object>} - Promise resolving with results (successes, failures). */ async function sendCampaignToGroup(recipientGroupId, message) { const groupWithRecipients = await prisma.recipientGroup.findUnique({ where: { id: recipientGroupId }, include: { recipients: { // Only include recipients who haven't opted out where: { isOptedOut: { not: true } } // Requires 'isOptedOut' field in schema (Step 8) } }, }); if (!groupWithRecipients) { throw new Error(`Recipient group with ID ${recipientGroupId} not found.`); } const recipientNumbers = groupWithRecipients.recipients.map(r => r.phoneNumber); if (recipientNumbers.length === 0) { console.warn(`Recipient group ${recipientGroupId} has no active recipients.`); return { successes: [], failures: [] }; } return sendCampaign(recipientNumbers, message); } module.exports = { sendCampaign, sendCampaignToGroup }; // Export both functionsDesign Choice: Sending messages sequentially is simpler to start with. For large campaigns, you'd implement parallel sending with rate limiting awareness (e.g., using libraries like
p-limitorasync.queue) and potentially handle Vonage's asynchronous responses via status webhooks for better tracking (See Step 8).3. Building a Complete API Layer
We need an endpoint to trigger the campaign sending process.
1. Campaign Controller (
src/controllers/campaignController.js): Handles incoming API requests, validates input, calls the appropriate service, and sends back the response.// src/controllers/campaignController.js const { sendCampaign, sendCampaignToGroup } = require('../services/campaignService'); /** * POST /api/campaigns/send * Sends a campaign to a provided list of numbers. * Request body example: { "recipientNumbers": ["+15551234567", "+447700900123"], "message": "Hello!" } */ async function handleSendCampaign(req, res, next) { const { recipientNumbers, message } = req.body; // Basic Validation if (!Array.isArray(recipientNumbers) || recipientNumbers.length === 0 || typeof message !== 'string' || message.trim() === '') { return res.status(400).json({ error: 'Invalid input. Requires "recipientNumbers" (non-empty array of strings) and "message" (non-empty string).', }); } // Further validation (e.g., check number format within the service or here) try { const results = await sendCampaign(recipientNumbers, message); res.status(200).json({ message: 'Campaign processing completed.', // Changed from "initiated" as it's sequential for now results: results, }); } catch (error) { console.error('Error in handleSendCampaign:', error); // Pass to the centralized error handler next(error); } } /** * POST /api/campaigns/send-group * Sends a campaign to recipients of a specific group ID. * Request body example: { "recipientGroupId": 1, "message": "Group message!" } */ async function handleSendCampaignToGroup(req, res, next) { const { recipientGroupId, message } = req.body; // Basic Validation const groupId = parseInt(recipientGroupId, 10); if (isNaN(groupId) || typeof message !== 'string' || message.trim() === '') { return res.status(400).json({ error: 'Invalid input. Requires "recipientGroupId" (number) and "message" (non-empty string).', }); } try { const results = await sendCampaignToGroup(groupId, message); res.status(200).json({ message: `Campaign processing completed for group ${groupId}.`, // Changed from "initiated" results: results, }); } catch (error) { console.error('Error in handleSendCampaignToGroup:', error); // Handle specific errors like "group not found" differently if needed if (error.message.includes('not found')) { return res.status(404).json({ error: error.message }); } // Pass other errors to the centralized handler next(error); } } module.exports = { handleSendCampaign, handleSendCampaignToGroup };2. Campaign Routes (
src/routes/campaignRoutes.js): Defines the API endpoints and maps them to controller functions. Note: These endpoints currently lack authentication (See Step 7).// src/routes/campaignRoutes.js const express = require('express'); const { handleSendCampaign, handleSendCampaignToGroup } = require('../controllers/campaignController'); // Rate limiting middleware will be introduced and implemented in Step 7 const rateLimiter = require('../middleware/rateLimiter'); const router = express.Router(); // WARNING: These endpoints are currently PUBLIC. Add authentication middleware for production. // Apply rate limiting (introduced in Step 7) to campaign sending endpoints router.post('/send', rateLimiter, handleSendCampaign); router.post('/send-group', rateLimiter, handleSendCampaignToGroup); // Optional: Endpoint to check basic Vonage configuration status router.get('/status', (req, res) => { // Basic check: Ensure Vonage credentials seem present in environment if (process.env.VONAGE_APPLICATION_ID && process.env.VONAGE_PRIVATE_KEY_PATH && process.env.VONAGE_NUMBER) { // Further check could involve ensuring the private key file exists res.status(200).json({ status: 'Vonage credentials seem loaded from environment variables.'}); } else { res.status(500).json({ status: 'Error: One or more required Vonage credentials missing in environment variables.' }); } }); module.exports = router;3. Testing the Endpoint (Example with
curl): (Before running, ensure you've completed Step 4 for Vonage setup and Step 6 for Database setup if using/send-group)Start your server:
node src/server.jsTest
/sendendpoint:curl -X POST http://localhost:3000/api/campaigns/send \ -H "Content-Type: application/json" \ -d '{ "recipientNumbers": ["+15551234567", "+447700900123"], "message": "Hello from our Vonage Campaigner!" }' # Replace numbers with your actual test numbers (whitelisted on trial accounts)Expected Response (Success):
{ "message": "Campaign processing completed.", "results": { "successes": [ { "number": "+15551234567", "message_uuid": "..." }, { "number": "+447700900123", "message_uuid": "..." } ], "failures": [] } }Test
/send-groupendpoint (after DB setup in Step 6):curl -X POST http://localhost:3000/api/campaigns/send-group \ -H "Content-Type: application/json" \ -d '{ "recipientGroupId": 1, "message": "Special offer for our valued group members!" }'Expected Response (Success):
{ "message": "Campaign processing completed for group 1.", "results": { "successes": [ /* ... list of successful sends ... */ ], "failures": [] } }4. Integrating with Vonage
This involves setting up your Vonage account, creating an application, and configuring your
.envfile.1. Sign Up/Log In to Vonage: Go to the Vonage API Dashboard.
2. Create a Vonage Application:
private.keyfile that downloads. Place this file in the root directory of your project (or the location specified byVONAGE_PRIVATE_KEY_PATHin your.env).ngrok http 3000(assuming your server runs on port 3000).https://random-string.ngrok.io).YOUR_NGROK_URL/webhooks/inboundfor Inbound URL (e.g.,https://random-string.ngrok.io/webhooks/inbound).YOUR_NGROK_URL/webhooks/statusfor Status URL (e.g.,https://random-string.ngrok.io/webhooks/status).3. Note Your Application ID: On the application details page, copy the Application ID.
4. Link a Vonage Number:
5. Configure
.env: Open your.envfile and fill in the Vonage details with the values you obtained:VONAGE_APPLICATION_ID: The ID copied from the Vonage dashboard.VONAGE_PRIVATE_KEY_PATH: The path relative to your project root where you saved theprivate.keyfile../private.keyassumes it's directly in the root. The code (vonageService.js) resolves this relative to the current working directory.VONAGE_NUMBER: The Vonage virtual number (must be in E.164 format, e.g.,+14155552671) you linked to the application.Security: NEVER commit your
private.keyfile or your.envfile containing secrets to Git. Ensure.gitignoreincludes them (add*.keyand.env*).5. Implementing Error Handling and Logging
Robust error handling and logging are crucial.
1. Consistent Error Handling:
vonageService,campaignService) usetry...catchand throw errors, sometimes re-throwing more specific ones.campaignController.js) catches errors from services and either returns specific client errors (400, 404) or passes server errors (next(error)) to the central Express error handler.server.jswith a more structured one.Example: Enhance
server.jsError Handler:// src/server.js (Replace the simple error handler with this) // Remove or comment out the previous simple error handler: // app.use((err, req, res, next) => { // console.error(err.stack); // res.status(500).send({ error: 'Something went wrong!' }); // }); // More structured error handler (Place this AFTER your routes) app.use((err, req, res, next) => { // Log the error internally (replace console.error with a proper logger like Winston/Pino in production) console.error(`[ERROR] ${req.method} ${req.path} - ${err.message}`); // Log stack trace only in development or if needed for detailed debugging if (process.env.NODE_ENV !== 'production') { console.error(err.stack); } // Determine status code - check for custom status or default to 500 // Use err.status for compatibility with some error libraries, fallback to statusCode const statusCode = err.status || err.statusCode || 500; // Send structured error response to client - avoid leaking sensitive details res.status(statusCode).json({ status: 'error', // Only include specific error message in non-production environments for debugging // In production, use a generic message unless it's a specific client error (4xx) you want to expose message: (statusCode < 500 || process.env.NODE_ENV !== 'production') ? err.message : 'An internal server error occurred.', }); });2. Logging:
console.logandconsole.error. This is insufficient for production monitoring and debugging.Pino(very performant) orWinston.express-request-id), and relevant context (e.g., user ID if authenticated, campaign ID).3. Retry Mechanisms:
Network issues or temporary Vonage API rate limits can cause transient failures.
Simple Retry (Conceptual): Wrap the
vonageService.sendSmscall within thecampaignServiceloop with delays (exponential backoff is recommended).// Inside campaignService.js - Conceptual Retry Logic (Not fully implemented here) async function sendSmsWithRetry(number, message, maxRetries = 2) { let attempt = 0; while (attempt <= maxRetries) { try { return await sendSms(number, message); // Attempt sending } catch (error) { attempt++; // Check if the error is potentially retryable (e.g., network error, 429 Too Many Requests) const isRetryable = error.message.includes('429') || error.message.includes('network'); // Basic check if (!isRetryable || attempt > maxRetries) { console.error(`Final attempt failed for ${number} after ${attempt} tries or error not retryable.`); throw error; // Rethrow original error } // Exponential backoff: wait 1s, 2s, 4s... const delay = Math.pow(2, attempt - 1) * 1000 * (Math.random() + 0.5); // Add jitter console.warn(`Attempt ${attempt} failed for ${number}. Retrying in ${delay.toFixed(0)}ms... Error: ${error.message}`); await new Promise(resolve => setTimeout(resolve, delay)); } } } // You would then modify sendCampaign to call sendSmsWithRetry instead of sendSms directly.Robust Retries: Use libraries like
async-retryor leverage a background job queue system (Step 9) which often have built-in retry capabilities. For delivery confirmation, rely on Delivery Receipts via webhooks (Step 8).6. Creating a Database Schema and Data Layer
We'll use Prisma to define a schema for recipients and groups.
1. Define Prisma Schema (
prisma/schema.prisma): Update the schema file to include models forRecipientandRecipientGroup. Add anisOptedOutflag for compliance (See Step 8).Recipient: Stores individual phone numbers (enforce E.164 format), optional names, and the crucialisOptedOutflag.RecipientGroup: Allows grouping recipients.2. Apply Migrations: Generate and apply the SQL migration to create/update the database tables.
# Create the migration files based on schema changes # Prisma will prompt you for a name, e.g., add_recipient_models_and_optout npx prisma migrate dev # This command will also apply the migration to your dev database (dev.db)Prisma creates an SQL migration file in
prisma/migrations/and updates yourdev.db.3. (Optional) Seed the Database: Create sample data for testing.
Create
prisma/seed.js:// prisma/seed.js const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); async function main() { console.log('Start seeding ...'); // Create recipient groups const group1 = await prisma.recipientGroup.upsert({ where: { name: 'Early Adopters' }, update: {}, create: { name: 'Early Adopters' }, }); const group2 = await prisma.recipientGroup.upsert({ where: { name: 'General Newsletter' }, update: {}, create: { name: 'General Newsletter' }, }); console.log(`Created/found groups: ${group1.name} (ID: ${group1.id}), ${group2.name} (ID: ${group2.id})`); // Create recipients, linking some to groups await prisma.recipient.upsert({ where: { phoneNumber: '+15550001111' }, // Use test numbers update: {}, create: { phoneNumber: '+15550001111', firstName: 'Alice', recipientGroupId: group1.id, }, }); await prisma.recipient.upsert({ where: { phoneNumber: '+447700900222' }, // Use test numbers update: {}, create: { phoneNumber: '+447700900222', firstName: 'Bob', lastName: 'Smith', recipientGroupId: group1.id, }, }); await prisma.recipient.upsert({ where: { phoneNumber: '+15559998888' }, // Use test numbers update: { isOptedOut: true }, // Example of an opted-out user create: { phoneNumber: '+15559998888', firstName: 'Charlie', isOptedOut: true, // Ensure they start opted out recipientGroupId: group2.id, }, }); await prisma.recipient.upsert({ where: { phoneNumber: '+15557776666' }, // Use test numbers update: {}, create: { phoneNumber: '+15557776666', firstName: 'Diana', // No group initially }, }); console.log('Seeding finished.'); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); });Add the seed script command to
package.json:// package.json { // ... other package.json content ... ""prisma"": { ""seed"": ""node prisma/seed.js"" }, ""scripts"": { ""start"": ""node src/server.js"", ""dev"": ""nodemon src/server.js"", // If using nodemon ""migrate:dev"": ""npx prisma migrate dev"", ""db:seed"": ""npx prisma db seed"" // Add this line // ... other scripts ... } // ... rest of package.json ... }Run the seed script:
This executes the
prisma/seed.jsfile.