code examples
code examples
Build SMS Marketing Campaigns with Node.js and Vonage Messages API
Complete tutorial for building production-ready SMS marketing campaigns using Node.js, Express.js, and Vonage Messages API. Learn webhook integration, delivery tracking, opt-out compliance, and database setup with step-by-step code examples.
Developer Guide: Building Node.js SMS Marketing Campaigns with Vonage
⚠️ CONTENT NOTE: This article's filename suggests MessageBird with Next.js and NextAuth, but the content actually covers Vonage (formerly Nexmo) with Express.js and basic API key authentication. If you're looking for MessageBird-specific or Next.js/NextAuth implementations, please refer to platform-specific documentation.
Learn how to build a production-ready SMS marketing campaign system using Node.js, Express.js, and the Vonage Messages API. This comprehensive guide covers everything from sending bulk SMS messages and tracking delivery status through webhooks to managing contact lists and handling opt-out compliance. You'll implement a complete backend application with database integration using Prisma and PostgreSQL, security best practices, and deployment-ready error handling for programmatic SMS marketing campaigns.
What You'll Build: SMS Marketing Campaign System Overview
Core Features:
This tutorial guides you through creating a backend system with these capabilities:
- Storing contact lists with phone numbers and subscription status
- Creating and managing SMS marketing campaigns
- Sending SMS messages reliably via Vonage Messages API
- Receiving real-time delivery status updates through webhooks
- Handling SMS opt-out requests (STOP keyword) for compliance
Real-World Use Cases:
This SMS marketing system solves common business needs for programmatic SMS communication:
- E-commerce promotional campaigns and flash sales
- Appointment reminders for healthcare and service businesses
- Transaction notifications and order status updates
- Customer engagement and retention campaigns
- Time-sensitive alerts and emergency notifications
Technology Stack:
- Node.js (v18+): JavaScript runtime for building scalable server-side applications
- Express.js: Lightweight web application framework for API endpoints and webhook handling
- Vonage Messages API: Enterprise-grade messaging platform for SMS delivery with detailed delivery receipts
@vonage/server-sdk(v3.24.1): Official Node.js SDK for Vonage API integration- PostgreSQL & Prisma: Production database with modern ORM for contacts, campaigns, and message tracking
dotenv: Secure environment variable managementngrok: Development tool for exposing local webhooks (includes 1 static domain since August 2023)
System Architecture:
+-----------------+ +-----------------+ +-----------------+ +-----------------+
| User / Admin | ---> | Express API | ---> | Vonage Messages | ---> | SMS Network |
| (via API call) | | (Node.js App) | | API | | (Recipient) |
+-----------------+ +-------+---------+ +--------+--------+ +--------+--------+
^ | ^ | ^
| | DB Interaction | | SMS Send | SMS Delivery
| v | v |
+---+-------------+ Webhook Callbacks +-------+---------+
| PostgreSQL DB | <------------------------------| Recipient Phone|
| (Contacts, | (Status, Inbound) +-----------------+
| Campaigns) |
+-----------------+What You'll Learn:
By completing this tutorial, you'll have a fully functional Node.js SMS marketing application with:
- RESTful API endpoints for contact and campaign management
- Vonage Messages API integration for SMS sending
- Webhook handlers for delivery tracking and inbound messages
- Database schema for persistent data storage
- Security middleware and authentication
Prerequisites:
Before starting this tutorial, ensure you have:
- Node.js and npm (or yarn): Version 18+ recommended (Download Node.js)
- Vonage API Account: Free account at Vonage Dashboard
- Vonage Application & Private Key: Messages API-enabled application (covered in setup)
- Vonage Phone Number: SMS-capable virtual number
- US 10DLC Registration: Required for US SMS traffic (mandatory as of 2024, allow 2-4 weeks for approval)
ngrok: Free tier with static domain (Download ngrok)- JavaScript/Node.js Knowledge: Familiarity with async/await patterns
- Command Line Skills: Basic terminal command experience
- PostgreSQL Database (Optional): Local instance via Docker or hosted service
Important: Create your Vonage API Account, Application, Private Key, and rent a virtual number before starting Section 1 (Project Setup).
1. Setting up the Project
Let's create the project structure and install necessary dependencies.
-
Create Project Directory:
bashmkdir nodejs-vonage-campaigns cd nodejs-vonage-campaigns -
Initialize Node.js Project:
bashnpm init -yThis creates a
package.jsonfile. -
Install Dependencies:
bashnpm install express @vonage/server-sdk dotenv npm install --save-dev nodemon # Optional: for auto-restarting server during developmentexpress: Web framework.@vonage/server-sdk: Vonage API client.dotenv: Loads environment variables.nodemon: Development utility to automatically restart the server on file changes.
-
Install Database Dependencies (Optional - using Prisma & PostgreSQL):
If you plan to use a database (highly recommended for managing contacts and campaigns):
bashnpm install @prisma/client npm install --save-dev prisma@prisma/client: The Prisma database client.prisma: Prisma CLI tool for migrations and generation.
-
Initialize Prisma (Optional):
bashnpx prisma init --datasource-provider postgresqlThis creates a
prismadirectory with aschema.prismafile and a.envfile (if one doesn't exist). -
Configure
.envFile:Create a file named
.envin the project root. Prisma might have created one already. Add the following placeholders. We'll fill these in later.dotenv# .env # Vonage Credentials (Messages API requires Application ID & Private Key) VONAGE_API_KEY=YOUR_VONAGE_API_KEY VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET # Note: Messages API primarily uses App ID & Private Key for sending. Key/Secret may be needed for signature verification or other Vonage APIs. VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root. See Section 12 for production handling. VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # The number you rented (e.g., 14155550100) # Application Settings PORT=3000 # Database (if using Prisma/PostgreSQL) # Example: postgresql://user:password@host:port/database?schema=public DATABASE_URL="YOUR_POSTGRESQL_CONNECTION_STRING" # Security (Simple API Key Example) INTERNAL_API_KEY=YOUR_SECRET_API_KEY_FOR_INTERNAL_USEImportant: Add
.envandprivate.keyto your.gitignorefile to avoid committing secrets. -
Create
private.keyPlaceholder:Create an empty file named
private.keyin the root. You will download the actual key from Vonage later and place its contents here. This file path method is used for setup; Section 12 discusses secure key handling for production. -
Project Structure:
Create the following directory structure:
textnodejs-vonage-campaigns/ ├── prisma/ # (If using Prisma) │ └── schema.prisma ├── src/ │ ├── controllers/ # Request handling logic │ ├── services/ # Business logic (Vonage interaction, DB logic) │ ├── routes/ # API route definitions │ ├── middleware/ # Custom middleware (e.g., authentication) │ └── utils/ # Helper functions ├── .env ├── .gitignore ├── package.json ├── package-lock.json ├── server.js # Main application entry point └── private.key # Your Vonage private key filesrc/controllers: Handle incoming HTTP requests, validate input, and call services.src/services: Contain the core business logic, interact with external APIs (Vonage), and the database.src/routes: Define API endpoints and link them to controllers.src/middleware: Functions that run before controllers (e.g., authentication, logging).src/utils: Reusable utility functions.server.js: Main application entry point.private.key: Your Vonage private key file.
-
Configure
package.jsonScripts:Add development scripts to your
package.json:json{ "scripts": { "start": "node server.js", "dev": "nodemon server.js", "test": "echo \"Error: no test specified\" && exit 1", "prisma:migrate": "npx prisma migrate dev", "prisma:generate": "npx prisma generate" } } -
Create Basic Server (
server.js):javascript// server.js require('dotenv').config(); // Load .env variables early const express = require('express'); const app = express(); const PORT = process.env.PORT || 3000; // Middleware app.use(express.json()); // Parses incoming JSON requests app.use(express.urlencoded({ extended: true })); // Parses urlencoded request bodies // Basic Route for Testing app.get('/health', (req, res) => { res.status(200).json({ status: 'OK', timestamp: new Date().toISOString() }); }); // --- Placeholder for API Routes --- // const contactRoutes = require('./src/routes/contactRoutes'); // const campaignRoutes = require('./src/routes/campaignRoutes'); // const webhookRoutes = require('./src/routes/webhookRoutes'); // app.use('/api/contacts', contactRoutes); // app.use('/api/campaigns', campaignRoutes); // app.use('/webhooks', webhookRoutes); // Global Error Handler (Basic Example) app.use((err, req, res, next) => { console.error('Global Error:', err.stack); res.status(500).json({ error: 'Something went wrong!' }); }); app.listen(PORT, () => { console.log(`Server listening at http://localhost:${PORT}`); // Log Vonage setup status (optional) if (!process.env.VONAGE_APPLICATION_ID || !process.env.VONAGE_PRIVATE_KEY_PATH) { console.warn('Vonage Application ID or Private Key Path not set in .env'); } if (!process.env.VONAGE_NUMBER) { console.warn('Vonage Number not set in .env'); } }); -
Run the Server (Development):
bashnpm run devIf everything is set up correctly, you should see
Server listening at http://localhost:3000. You can test the health check endpoint:curl http://localhost:3000/health.
2. Implementing Core Functionality (Sending SMS)
Let's integrate the Vonage SDK to send messages.
-
Create Vonage Service (
src/services/vonageService.js):This service will encapsulate all interactions with the Vonage SDK.
javascript// src/services/vonageService.js const { Vonage } = require('@vonage/server-sdk'); const { Message } = require('@vonage/messages'); const path = require('path'); const fs = require('fs'); // Validate essential Vonage environment variables if (!process.env.VONAGE_APPLICATION_ID) { throw new Error("VONAGE_APPLICATION_ID is not set in the environment variables."); } if (!process.env.VONAGE_PRIVATE_KEY_PATH) { throw new Error("VONAGE_PRIVATE_KEY_PATH is not set in the environment variables."); } if (!process.env.VONAGE_NUMBER) { throw new Error("VONAGE_NUMBER is not set in the environment variables."); } const privateKeyPath = path.resolve(process.env.VONAGE_PRIVATE_KEY_PATH); // Check if private key file exists if (!fs.existsSync(privateKeyPath)) { throw new Error(`Private key file not found at path: ${privateKeyPath}. Please ensure the VONAGE_PRIVATE_KEY_PATH in .env is correct and the file exists.`); } const vonage = new Vonage({ applicationId: process.env.VONAGE_APPLICATION_ID, privateKey: privateKeyPath, // Optional: Add apiHost, restHost if needed (e.g., for specific regions) }); const vonageNumber = process.env.VONAGE_NUMBER; /** * Sends a single SMS message using the Vonage Messages API. * @param {string} to - The recipient phone number in E.164 format (e.g., +14155550100). * @param {string} text - The message content. * @param {string} [clientRef] - Optional client reference for tracking. Max 40 chars. * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} - Result object. */ async function sendSms(to, text, clientRef) { console.log(`Attempting to send SMS to ${to} from ${vonageNumber}`); try { const resp = await vonage.messages.send( new Message({ to: to, from: vonageNumber, channel: 'sms', message_type: 'text', text: text, client_ref: clientRef // Optional: Link message to your campaign/contact }) ); console.log(`SMS submitted to Vonage for ${to}. Message UUID: ${resp.messageUuid}`); return { success: true, messageId: resp.messageUuid }; } catch (error) { console.error(`Error sending SMS to ${to}:`, error?.response?.data || error.message); // Extract more specific error if available from Vonage response let errorMessage = 'Failed to send SMS.'; if (error?.response?.data?.title) { errorMessage = `${error.response.data.title}: ${error.response.data.detail || error.message}`; } else if (error.message) { errorMessage = error.message; } return { success: false, error: errorMessage }; } } module.exports = { sendSms, // Add other Vonage related functions here (e.g., webhook handlers) };- Why
MessagesAPI? We usevonage.messages.sendwhich corresponds to the more modern Messages API. It supports multiple channels and provides detailed status webhooks (/webhooks/status), which are crucial for tracking campaign delivery. The oldervonage.message.sendSmsuses the SMS API, which has different webhook formats and capabilities. - Error Handling: The
try...catchblock captures errors during the API call. We log detailed errors and return a structured object indicating success or failure. We attempt to parse specific error details from the Vonage response if available. - Credentials: We initialize the SDK using the Application ID and Private Key path from environment variables, as required by the Messages API. We added checks to ensure these variables and the key file exist.
client_ref: This optional parameter is very useful. You can set it to a unique ID (e.g.,campaignId:contactId) to correlate status webhooks back to specific messages in your campaign.
- Why
-
Test Sending (Manual Trigger):
Let's add a temporary route in
server.jsto test sending a single SMS. Remember to remove or secure this later.javascript// server.js (add inside the main server setup, before app.listen) const { sendSms } = require('./src/services/vonageService'); // TEMPORARY TEST ROUTE - REMOVE OR SECURE LATER app.post('/test-send-sms', async (req, res, next) => { const { to, text } = req.body; if (!to || !text) { return res.status(400).json({ error: "Missing 'to' or 'text' in request body." }); } // Basic E.164 format check (Note: This regex is basic and may not cover all edge cases perfectly. Improve as needed.) if (!/^\+?[1-9]\d{1,14}$/.test(to)) { return res.status(400).json({ error: "Invalid 'to' phone number format. Use E.164 (e.g., +14155550100)." }); } try { const result = await sendSms(to, text, `test-${Date.now()}`); if (result.success) { res.status(200).json({ message: 'SMS submitted successfully!', messageId: result.messageId }); } else { // Use 502 Bad Gateway if the issue is likely upstream (Vonage) // Use 500 Internal Server Error for other unexpected issues res.status(502).json({ error: 'Failed to send SMS via provider.', details: result.error }); } } catch (err) { // Pass to global error handler next(err); } }); // ... rest of server.js
3. Building a Complete API Layer (Campaigns & Contacts)
We need API endpoints to manage contacts and initiate campaigns.
-
Simple In-Memory Store (or Database Service):
For simplicity initially, let's use an in-memory store. Replace this with database interactions (Section 6) for persistence.
javascript// src/services/dataStoreService.js (Temporary In-Memory) // WARNING: Data is lost when the server restarts. Replace with DB implementation. const contacts = new Map(); // { 'contactId': { id: '...', phone: '+1...', status: 'active' } } const campaigns = new Map(); // { 'campaignId': { id: '...', name: '...', message: '...', status: 'pending/sending/completed', contacts: ['contactId1'], results: [] } } let nextContactId = 1; let nextCampaignId = 1; async function addContact(phone) { const id = `c${nextContactId++}`; // Basic validation/normalization (improve as needed) const normalizedPhone = phone.startsWith('+') ? phone : `+${phone.replace(/\D/g, '')}`; const contact = { id, phone: normalizedPhone, status: 'active' }; // 'active', 'opted-out' contacts.set(id, contact); console.log('Added contact:', contact); return contact; } async function getContact(id) { return contacts.get(id); } async function getAllContacts(statusFilter = null) { const all = Array.from(contacts.values()); if (statusFilter) { return all.filter(c => c.status === statusFilter); } return all; } async function updateContactStatus(phone, status) { for (const [id, contact] of contacts.entries()) { if (contact.phone === phone) { contact.status = status; console.log(`Updated contact ${id} (${phone}) status to ${status}`); return contact; } } console.warn(`Contact not found for phone ${phone} to update status.`); return null; // Contact not found } async function createCampaign(name, message, contactIds) { const id = `cmp${nextCampaignId++}`; // Validate contactIds exist? For now, assume they do. const campaign = { id, name, message, contactIds: [...contactIds], // Store IDs targeted by the campaign status: 'pending', // pending, sending, completed, failed createdAt: new Date().toISOString(), results: [] // Store individual message results { contactId, messageId, status, error } }; campaigns.set(id, campaign); console.log('Created campaign:', campaign); return campaign; } async function getCampaign(id) { return campaigns.get(id); } async function updateCampaignStatus(id, status) { const campaign = campaigns.get(id); if (campaign) { campaign.status = status; console.log(`Updated campaign ${id} status to ${status}`); } } async function addCampaignResult(campaignId, result) { const campaign = campaigns.get(campaignId); if (campaign) { campaign.results.push(result); } } module.exports = { addContact, getContact, getAllContacts, updateContactStatus, // Added for opt-out createCampaign, getCampaign, updateCampaignStatus, addCampaignResult }; -
Authentication Middleware (Simple API Key):
Protect our internal endpoints.
javascript// src/middleware/authMiddleware.js const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY; if (!INTERNAL_API_KEY) { console.warn('SECURITY WARNING: INTERNAL_API_KEY is not set in .env. API endpoints are currently unprotected. This is highly insecure for production.'); } function requireApiKey(req, res, next) { // Allow bypass if no key is configured (intended for initial local development ONLY, highly insecure otherwise) if (!INTERNAL_API_KEY) { return next(); } const apiKey = req.headers['x-api-key']; if (apiKey && apiKey === INTERNAL_API_KEY) { next(); // API key is valid } else { console.warn('Blocked request due to missing or invalid API Key.'); res.status(401).json({ error: 'Unauthorized: Invalid or missing API Key.' }); } } module.exports = { requireApiKey }; -
Contact Controller (
src/controllers/contactController.js):javascript// src/controllers/contactController.js const dataStore = require('../services/dataStoreService'); // Replace with DB service later async function createContact(req, res, next) { const { phone } = req.body; if (!phone) { return res.status(400).json({ error: "Missing 'phone' in request body." }); } // Add more robust validation (E.164 format) here if needed try { const contact = await dataStore.addContact(phone); res.status(201).json(contact); } catch (err) { next(err); } } async function listContacts(req, res, next) { const { status } = req.query; // Optional filter e.g., /api/contacts?status=active try { const contacts = await dataStore.getAllContacts(status); res.status(200).json(contacts); } catch (err) { next(err); } } module.exports = { createContact, listContacts }; -
Campaign Controller (
src/controllers/campaignController.js):This controller will orchestrate fetching contacts and sending messages.
javascript// src/controllers/campaignController.js const dataStore = require('../services/dataStoreService'); // Replace with DB service const vonageService = require('../services/vonageService'); // const logger = require('../utils/logger'); // Assuming a logger setup exists // --- Helper function for sending logic --- async function processCampaignSending(campaign) { console.log(`Starting to process campaign: ${campaign.id} (${campaign.name})`); // Use console or logger await dataStore.updateCampaignStatus(campaign.id, 'sending'); let successCount = 0; let failureCount = 0; const totalContacts = campaign.contactIds.length; // Introduce delay to respect rate limits (adjust as needed) const delayBetweenMessages = 1100; // Milliseconds (slightly over 1 sec for 1 MPS limit) for (const contactId of campaign.contactIds) { const contact = await dataStore.getContact(contactId); if (!contact || contact.status !== 'active') { console.warn(`Skipping contact ${contactId} (not found or not active) for campaign ${campaign.id}`); // Use console or logger await dataStore.addCampaignResult(campaign.id, { contactId, phone: contact?.phone || 'N/A', status: 'skipped', error: contact ? `Contact status: ${contact.status}` : 'Contact not found' }); failureCount++; continue; // Skip inactive or non-existent contacts } const clientRef = `${campaign.id}:${contact.id}`; // Unique reference for tracking const result = await vonageService.sendSms(contact.phone, campaign.message, clientRef); await dataStore.addCampaignResult(campaign.id, { contactId, phone: contact.phone, messageId: result.messageId, // Store Vonage message ID status: result.success ? 'submitted' : 'failed', error: result.error // Store error message if failed }); if (result.success) { successCount++; } else { failureCount++; } // Delay before sending the next message if (campaign.contactIds.indexOf(contactId) < totalContacts - 1) { console.log(`Waiting ${delayBetweenMessages}ms before next message...`); // Use console or logger await new Promise(resolve => setTimeout(resolve, delayBetweenMessages)); } } const finalStatus = failureCount > 0 && successCount === 0 ? 'failed' : 'completed'; await dataStore.updateCampaignStatus(campaign.id, finalStatus); console.log(`Campaign ${campaign.id} processing finished. Status: ${finalStatus}. Success: ${successCount}, Failed/Skipped: ${failureCount}`); // Use console or logger } // --- Controller Functions --- async function createAndSendCampaign(req, res, next) { const { name, message, contactIds } = req.body; if (!name || !message || !Array.isArray(contactIds) || contactIds.length === 0) { return res.status(400).json({ error: 'Missing required fields: name, message, contactIds (must be a non-empty array).' }); } try { // 1. Create campaign record (status: pending) const campaign = await dataStore.createCampaign(name, message, contactIds); // 2. Respond immediately to the client (acknowledge request) res.status(202).json({ message: 'Campaign accepted for processing.', campaignId: campaign.id }); // 3. Process sending asynchronously (don't block the API response) // DANGER: Using setImmediate is NOT suitable for production! It offers no persistence or proper error handling if the server restarts. Replace with a robust job queue (e.g., BullMQ with Redis) for any real-world application. setImmediate(() => { processCampaignSending(campaign).catch(err => { console.error(`Unhandled error during campaign processing ${campaign.id}:`, err); // Use console or logger dataStore.updateCampaignStatus(campaign.id, 'failed').catch(console.error); // Attempt to mark as failed }); }); } catch (err) { next(err); } } async function getCampaignStatus(req, res, next) { const { id } = req.params; try { const campaign = await dataStore.getCampaign(id); if (campaign) { res.status(200).json(campaign); } else { res.status(404).json({ error: 'Campaign not found.' }); } } catch (err) { next(err); } } module.exports = { createAndSendCampaign, getCampaignStatus };- Asynchronous Processing: Sending many SMS messages can take time. The
createAndSendCampaignfunction responds202 Acceptedimmediately after creating the campaign record. The actual sending logic inprocessCampaignSendingis triggered asynchronously usingsetImmediate. Crucially, for production, replacesetImmediatewith a robust background job queue (like BullMQ with Redis) to handle failures, retries, and concurrency properly. - Rate Limiting: A simple
setTimeoutdelay is added between sending messages to comply with Vonage's default rate limits (often 1 message per second per long code number). AdjustdelayBetweenMessagesbased on your number type and account limits. See Section 11 for more on rate limits. - Client Reference: We construct a
clientReflikecampaignId:contactIdto uniquely identify each message sent. This is vital for correlating status webhooks later. - Status Tracking: The campaign status (
pending,sending,completed,failed) and individual message results are stored.
- Asynchronous Processing: Sending many SMS messages can take time. The
-
API Routes (
src/routes/contactRoutes.js,src/routes/campaignRoutes.js):javascript// src/routes/contactRoutes.js const express = require('express'); const contactController = require('../controllers/contactController'); const { requireApiKey } = require('../middleware/authMiddleware'); const router = express.Router(); // Apply API key protection to all contact routes router.use(requireApiKey); router.post('/', contactController.createContact); router.get('/', contactController.listContacts); module.exports = router;javascript// src/routes/campaignRoutes.js const express = require('express'); const campaignController = require('../controllers/campaignController'); const { requireApiKey } = require('../middleware/authMiddleware'); const router = express.Router(); // Apply API key protection to all campaign routes router.use(requireApiKey); router.post('/', campaignController.createAndSendCampaign); router.get('/:id', campaignController.getCampaignStatus); module.exports = router; -
Wire up Routes in
server.js:Uncomment and add the route wiring in
server.js:javascript// server.js (inside the main setup) // ... other middleware ... const contactRoutes = require('./src/routes/contactRoutes'); const campaignRoutes = require('./src/routes/campaignRoutes'); // const webhookRoutes = require('./src/routes/webhookRoutes'); // Add later app.use('/api/contacts', contactRoutes); app.use('/api/campaigns', campaignRoutes); // app.use('/webhooks', webhookRoutes); // Add later // ... Global Error Handler and app.listen ... -
Testing API Endpoints:
Restart your server (
npm run dev). Usecurlor Postman:-
Add Contact:
bashcurl -X POST http://localhost:3000/api/contacts \ -H "Content-Type: application/json" \ -H "X-API-Key: YOUR_SECRET_API_KEY_FOR_INTERNAL_USE" \ -d '{ "phone": "+12015550123" }' # Use a real test number # Response: { "id": "c1", "phone": "+12015550123", "status": "active" }Add a couple more contacts. Note their IDs (e.g.,
c1,c2). -
List Contacts:
bashcurl http://localhost:3000/api/contacts \ -H "X-API-Key: YOUR_SECRET_API_KEY_FOR_INTERNAL_USE" # Response: [ { "id": "c1", ... }, { "id": "c2", ... } ] -
Create & Send Campaign:
bashcurl -X POST http://localhost:3000/api/campaigns \ -H "Content-Type: application/json" \ -H "X-API-Key: YOUR_SECRET_API_KEY_FOR_INTERNAL_USE" \ -d '{ "name": "Spring Sale Test", "message": "Hi! Check out our amazing Spring Sale!", "contactIds": ["c1", "c2"] }' # Response: 202 Accepted { "message": "Campaign accepted for processing.", "campaignId": "cmp1" }Check your server logs – you should see messages about processing the campaign and sending SMS. Check the target phones.
-
Get Campaign Status:
bashcurl http://localhost:3000/api/campaigns/cmp1 \ -H "X-API-Key: YOUR_SECRET_API_KEY_FOR_INTERNAL_USE" # Response: { "id": "cmp1", ..., "status": "completed", "results": [...] }
-
4. Integrating Vonage Webhooks for SMS Delivery Tracking
To track delivery status and handle opt-outs, we need to configure Vonage webhooks and create corresponding endpoints in our app.
-
Obtain Vonage Credentials & Configure Application:
- API Key & Secret: Find these at the top of your Vonage API Dashboard. Add them to your
.envfile (VONAGE_API_KEY,VONAGE_API_SECRET). While the Messages API primarily uses Application ID/Private Key for sending, the Key/Secret might be needed for other API interactions or webhook signature verification depending on the method chosen (though JWT is often preferred with Messages API webhooks). - Application ID & Private Key: If you haven't already, create a Vonage Application in the dashboard:
- Go to "Your applications" -> "Create a new application".
- Give it a name (e.g., "Node SMS Campaign App").
- Enable "Messages" capability.
- Under Messages, configure the Status URL and Inbound URL. These URLs must be publicly accessible. During development, you'll use
ngrokURLs here (e.g.,https://YOUR_NGROK_SUBDOMAIN.ngrok.io/webhooks/statusandhttps://YOUR_NGROK_SUBDOMAIN.ngrok.io/webhooks/inbound). - Generate a Public/Private Key Pair and download the
private.keyfile. Place its contents into theprivate.keyfile in your project root. Do not commit this key. - Note the Application ID generated for this application. Add it to your
.envfile (VONAGE_APPLICATION_ID). - Link your Vonage Number(s) to this application under the "Link numbers" section.
- Vonage Number: Ensure you have rented an SMS-capable number and added it to
.env(VONAGE_NUMBER). Make sure this number is linked to the Vonage Application you created.
- API Key & Secret: Find these at the top of your Vonage API Dashboard. Add them to your
-
Expose Local Server with
ngrok:If your server is running locally on port 3000:
bashngrok http 3000ngrokwill provide a public HTTPS URL (e.g.,https://abcdef123456.ngrok.io). Use this base URL when configuring the webhook URLs in the Vonage dashboard. Remember to update the Vonage Application settings with the correctngrokURL each time you restartngrok, as the subdomain usually changes on the free tier. -
Create Webhook Controller (
src/controllers/webhookController.js):This handles incoming POST requests from Vonage.
javascript// src/controllers/webhookController.js const dataStore = require('../services/dataStoreService'); // Replace with DB service // const logger = require('../utils/logger'); // Assuming logger setup const { Vonage } = require('@vonage/server-sdk'); // Needed for JWT validation // --- Webhook Security (JWT Validation - Recommended) --- // Initialize Vonage temporarily just for JWT validation method access // Note: This assumes API Key/Secret are available for signature verification if needed, // but JWT validation primarily relies on the Application ID and public key implicitly. const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET, applicationId: process.env.VONAGE_APPLICATION_ID, // privateKey: process.env.VONAGE_PRIVATE_KEY_PATH // Not strictly needed for JWT validation itself }); async function handleStatusWebhook(req, res, next) { const status = req.body; console.log('Received Status Webhook:', JSON.stringify(status, null, 2)); // Use console or logger // --- Security Check: Validate JWT --- // It's CRUCIAL to verify the webhook originates from Vonage. // The Vonage SDK doesn't have a direct JWT validation helper readily exposed for webhooks in v3 as of writing this. // You would typically use a library like 'jsonwebtoken' and Vonage's public key associated with your App ID. // Placeholder for JWT validation logic: const isValidJwt = true; // Replace with actual JWT validation! // Example using hypothetical vonage.accounts.verifySignature(req.headers, req.body) or similar JWT check. // Refer to Vonage documentation for the current recommended method. if (!isValidJwt) { console.warn('Invalid JWT received on status webhook.'); // Use console or logger return res.status(401).send('Invalid signature'); } // --- End Security Check --- // Extract relevant info const messageId = status.message_uuid; const messageStatus = status.status; const timestamp = status.timestamp; const clientRef = status.client_ref; // Expected format: "campaignId:contactId" const error = status.error; // Present if status is 'failed' or 'rejected' if (!clientRef) { console.warn(`Status webhook received without client_ref. Cannot correlate. Message ID: ${messageId}`); // Use console or logger return res.status(200).send('OK'); // Acknowledge receipt even if we can't process } try { const [campaignId, contactId] = clientRef.split(':'); if (!campaignId || !contactId) { console.warn(`Could not parse campaignId/contactId from client_ref: ${clientRef}. Message ID: ${messageId}`); // Use console or logger return res.status(200).send('OK'); } // Find the existing result and update its status const campaign = await dataStore.getCampaign(campaignId); if (campaign && campaign.results) { const resultIndex = campaign.results.findIndex(r => r.messageId === messageId && r.contactId === contactId); if (resultIndex !== -1) { campaign.results[resultIndex].status = messageStatus; campaign.results[resultIndex].timestamp = timestamp; if (error) { campaign.results[resultIndex].error = error; } console.log(`Updated status for msg ${messageId} (Campaign: ${campaignId}, Contact: ${contactId}) to: ${messageStatus}`); // Use console or logger } else { console.warn(`Could not find matching result entry for client_ref: ${clientRef}, messageId: ${messageId}`); // Use console or logger } } else { console.warn(`Could not find campaign ${campaignId} referenced by client_ref: ${clientRef}`); // Use console or logger } res.status(200).send('OK'); // Always respond 200 OK to Vonage quickly } catch (err) { // Don't pass to global handler, just log and respond OK to Vonage console.error(`Error processing status webhook for client_ref ${clientRef}:`, err); // Use console or logger res.status(200).send('OK'); // Ensure Vonage gets a 200 OK } } async function handleInboundWebhook(req, res, next) { const inboundMsg = req.body; console.log('Received Inbound Webhook:', JSON.stringify(inboundMsg, null, 2)); // Use console or logger // --- Security Check: Validate JWT (Similar to Status Webhook) --- const isValidJwt = true; // Replace with actual JWT validation! if (!isValidJwt) { console.warn('Invalid JWT received on inbound webhook.'); // Use console or logger return res.status(401).send('Invalid signature'); } // --- End Security Check --- // Process only inbound SMS messages if (inboundMsg.channel !== 'sms' || inboundMsg.message_type !== 'text') { return res.status(200).send('OK'); // Ignore non-SMS or non-text messages } const fromNumber = inboundMsg.from; // End user's number const toNumber = inboundMsg.to; // Your Vonage number const text = inboundMsg.text?.trim().toUpperCase(); // Normalize text const messageId = inboundMsg.message_uuid; try { // Basic Opt-Out Logic (STOP keyword) if (text === 'STOP') { console.log(`Processing STOP request from ${fromNumber}`); // Use console or logger const updatedContact = await dataStore.updateContactStatus(fromNumber, 'opted-out'); if (updatedContact) { console.log(`Contact ${fromNumber} status updated to opted-out.`); // Use console or logger // Optionally send a confirmation message back (requires another sendSms call) // await vonageService.sendSms(fromNumber, "You have been opted out. Reply START to resubscribe."); } else { console.warn(`Received STOP from ${fromNumber}, but contact was not found in the data store.`); // Use console or logger } } // Add logic for other keywords like START, HELP if needed res.status(200).send('OK'); // Always respond 200 OK quickly } catch (err) { console.error(`Error processing inbound webhook from ${fromNumber}:`, err); // Use console or logger res.status(200).send('OK'); // Ensure Vonage gets a 200 OK } } module.exports = { handleStatusWebhook, handleInboundWebhook };- JWT Validation: Added placeholders and comments emphasizing the critical need to validate incoming webhooks using JWT or another signature verification method provided by Vonage. This is essential for security. The exact implementation depends on Vonage's current SDK capabilities and recommendations.
- Status Handling: Parses the
client_refto link the status update back to the specific campaign and contact message result stored earlier. Updates the status (delivered,failed,rejected, etc.) in the data store. - Inbound Handling: Parses incoming SMS messages. Includes basic opt-out logic by checking for the "STOP" keyword (case-insensitive) and updating the contact's status in the data store.
- Response: Both handlers always respond with
200 OKquickly, even if internal processing fails. This prevents Vonage from retrying the webhook unnecessarily. Errors are logged internally.
-
Webhook Routes (
src/routes/webhookRoutes.js):javascript// src/routes/webhookRoutes.js const express = require('express'); const webhookController = require('../controllers/webhookController'); const router = express.Router(); // Vonage sends POST requests for status and inbound messages // Note: These endpoints should NOT have the API key middleware applied, // as they need to be publicly accessible by Vonage. Security is handled // via JWT/signature validation within the controller. router.post('/status', webhookController.handleStatusWebhook); router.post('/inbound', webhookController.handleInboundWebhook); // Vonage might send GET requests initially to verify the URL endpoint exists. // Respond with 200 OK. router.get('/status', (req, res) => res.sendStatus(200)); router.get('/inbound', (req, res) => res.sendStatus(200)); module.exports = router; -
Wire up Webhook Routes in
server.js:Make sure the webhook routes are correctly required and used in
server.js(as shown previously, uncomment the relevant lines). Ensure they are not placed behind therequireApiKeymiddleware if you applied it globally earlier.javascript// server.js (ensure these lines are active and correctly placed) // ... other middleware like express.json() ... const contactRoutes = require('./src/routes/contactRoutes'); const campaignRoutes = require('./src/routes/campaignRoutes'); const webhookRoutes = require('./src/routes/webhookRoutes'); // Ensure this is required // API routes (protected) app.use('/api/contacts', contactRoutes); // Assumes requireApiKey is inside contactRoutes app.use('/api/campaigns', campaignRoutes); // Assumes requireApiKey is inside campaignRoutes // Webhook routes (publicly accessible, security via JWT/signature) app.use('/webhooks', webhookRoutes); // ... Global Error Handler and app.listen ... -
Testing Webhooks:
- Ensure
ngrokis running and the Vonage Application webhook URLs are updated. - Restart your Node.js server (
npm run dev). - Send a campaign using the API (
/api/campaigns). - Watch your server logs. You should see:
- "Attempting to send SMS..." logs from
vonageService. - "Received Status Webhook:" logs in
webhookControlleras messages get delivered or fail. Check if the status is updated correctly in your (in-memory) data store by calling the/api/campaigns/:idendpoint again.
- "Attempting to send SMS..." logs from
- Send an SMS message containing "STOP" from your test phone to your Vonage number.
- Watch your server logs. You should see:
- "Received Inbound Webhook:" log.
- "Processing STOP request..." log.
- "Contact ... status updated to opted-out." log.
- Verify the contact's status by calling
/api/contacts. The contact should now havestatus: 'opted-out'. Subsequent campaigns should skip this contact.
- Ensure
Next Steps and Production Considerations
This completes the core setup for sending SMS and handling basic status and inbound messages. The next steps would involve replacing the in-memory store with a database, adding more robust error handling, implementing a proper job queue, and preparing for deployment.
Critical Production Requirements:
- Replace in-memory data store with PostgreSQL/Prisma database implementation
- Implement BullMQ or similar job queue for reliable campaign processing
- Add comprehensive error handling and structured logging (Winston/Pino)
- Implement JWT webhook validation for security
- Add rate limiting middleware to prevent API abuse
- Set up monitoring and observability (DataDog, New Relic, or similar)
- Implement proper retry logic with exponential backoff
- Add comprehensive test coverage (unit, integration, E2E)
- Complete TCPA compliance requirements and consent management
- Set up environment-specific configurations (dev/staging/production)
Related SMS Integration Tutorials:
For developers interested in other messaging platforms and frameworks:
- MessageBird SMS integration with Node.js
- Next.js SMS marketing with NextAuth authentication
- Twilio SMS API integration for Node.js
- SMS delivery tracking and webhook handling best practices
Frequently Asked Questions
How to send SMS messages with Node.js and Vonage?
Use the Vonage Messages API with the official Node.js SDK (@vonage/server-sdk). The `vonage.messages.send` method handles sending, supporting various message channels and providing robust delivery receipts. You'll need a Vonage API account, application, and rented number.
What is the Vonage Messages API used for?
The Vonage Messages API is a versatile tool for sending and receiving messages across different channels, including SMS. It's used in this project for its reliable delivery tracking and webhook features, enabling real-time status updates and inbound message handling.
Why use Application ID and Private Key with Vonage Messages API?
The Application ID and Private Key are required for authenticating with the Vonage Messages API and sending messages securely. This method is preferred over API Key/Secret for sending, although the latter might be needed for other Vonage API interactions or webhook signature verification.
When should I use ngrok for Vonage webhooks?
Use ngrok during development to create a publicly accessible URL for your local server so Vonage can reach your webhook endpoints. Remember that ngrok URLs change frequently on the free tier, requiring updates in your Vonage Application settings.
Can I store contacts and campaigns in memory?
While a simple in-memory store (like a Map) can be used for initial development, it's not suitable for production. Data will be lost on server restarts. Use a persistent database like PostgreSQL with Prisma for reliable data storage in a real-world application.
How to handle Vonage SMS delivery statuses?
Set up a status webhook URL in your Vonage Application settings. Vonage will send POST requests to this endpoint with delivery updates. Use the `client_ref` parameter when sending messages to correlate statuses back to specific campaigns and contacts.
What is the client_ref parameter used for in Vonage?
The `client_ref` is a custom identifier you can attach to outgoing messages. It's crucial for tracking message status and correlating webhooks back to your internal data. A good format is 'campaignId:contactId'.
How to handle SMS opt-out requests with Vonage?
Configure an inbound webhook URL in your Vonage Application settings. Implement logic in your webhook handler to process inbound SMS messages containing keywords like "STOP". Update the contact's status to "opted-out" to prevent future messages.
Why does Vonage recommend JWT for webhook validation?
JWT (JSON Web Token) is a secure method for verifying the authenticity of webhooks. It ensures that incoming requests genuinely originate from Vonage, preventing unauthorized access or malicious activity. The specific Vonage JWT/signature check implementation depends on the current SDK guidance.
When should I implement a job queue for SMS campaigns?
A job queue (like BullMQ with Redis) is essential for production SMS campaign sending. It handles asynchronous processing reliably, manages retries in case of failures, and enables proper concurrency control, unlike setImmediate which is unsafe and non-persistent
How to comply with Vonage SMS rate limits?
Vonage often has default rate limits (e.g., 1 message per second per long code). Implement delays between sending messages using `setTimeout` or similar methods. Adjust the delay based on your number type and account-specific limits. See Vonage documentation for details.
What are the prerequisites for this Node.js SMS project?
You'll need Node.js, npm (or yarn), a Vonage API account, a Vonage Application enabled for the Messages API with a Private Key, a rented Vonage Number, ngrok for local development, and basic JavaScript/Node.js knowledge, including async/await.
How to set up a Vonage application for SMS campaigns?
Create a new application in the Vonage dashboard, enable the "Messages" capability, configure Status and Inbound webhook URLs, generate and download the private key, note the Application ID, and link your Vonage Number to the application.
What is the role of Prisma in the project?
Prisma is an Object-Relational Mapper (ORM) that simplifies database interactions in Node.js. It makes it easier to work with PostgreSQL or other databases by providing a type-safe and convenient way to query and manage data.