code examples
code examples
How to Build SMS Marketing Campaigns with Vonage Node.js Express
Learn how to send SMS marketing campaigns using Node.js, Express, and Vonage Messages API. Step-by-step tutorial covers bulk SMS sending, TCPA compliance, webhooks, rate limiting, and database integration for production systems.
Build SMS Marketing Campaigns with Vonage, Node.js & Express
Learn how to build a production-ready SMS marketing campaign system using Node.js, Express, and the Vonage Messages API. This comprehensive tutorial covers everything from initial setup to deployment, including database integration, TCPA compliance, webhook handling, and rate limiting strategies.
Critical Compliance Notice: SMS marketing in the United States is regulated by the Telephone Consumer Protection Act (TCPA). You must obtain prior express written consent (PEWC) from recipients before sending marketing messages. Required consent elements include: your business name, clear statement about receiving marketing texts, disclosure that consent is not required for purchase, message/data rate notices, opt-out instructions (e.g., "Reply STOP"), and message frequency. Process opt-outs within 10 business days. TCPA violations carry penalties of $500–$1,500 per message, plus potential class-action exposure. Only send messages between 8 AM–9 PM recipient local time. This guide implements technical opt-out mechanisms but does not cover consent collection—consult legal counsel before deploying SMS marketing campaigns. See FCC TCPA Guidelines.
By the end of this Node.js SMS tutorial, you will have a functional application capable of:
- Managing a contact list of SMS subscribers with subscription status tracking
- Creating and sending bulk SMS marketing campaigns to subscribed contacts
- Receiving incoming SMS messages and handling "STOP" requests for unsubscribes
- Tracking message delivery statuses via Vonage webhooks
This system addresses the common business need to engage customers via SMS for marketing promotions, alerts, or updates, while respecting user preferences for opting out.
What You'll Build: SMS Marketing System Overview
Build a robust backend service that powers SMS marketing efforts. Send personalized or bulk messages via Vonage and handle inbound messages for critical functions like unsubscribes.
Technologies Used:
- Node.js: A JavaScript runtime environment ideal for building scalable network applications and SMS APIs.
- Express: A minimal and flexible Node.js web application framework providing essential web features for building REST APIs.
- Vonage Messages API: A powerful API for sending and receiving messages across multiple channels, including SMS. We'll use its Node.js SDK (
@vonage/server-sdk). - ngrok: A tool to expose local servers to the internet for webhook testing during development.
- (Optional) Database: A persistent storage solution (e.g., PostgreSQL, MySQL, MongoDB) to store contact information, campaign details, and message logs. We'll illustrate concepts, but specific implementation might vary. For simplicity, initial examples might use in-memory storage, but database integration is crucial for production.
- (Optional) ORM: Tools like Prisma or Sequelize can simplify database interactions.
Important Technical Notes:
- SDK Version: This guide uses
@vonage/server-sdkv3.24.1 (latest as of January 2025). Version 3.x provides Promise-based APIs, TypeScript support, and improved error handling over v2.x. - SMS Character Limits: Single SMS messages using GSM-7 encoding support up to 160 characters. Multi-part messages use 153 characters per segment (7 characters reserved for concatenation headers). Unicode messages are limited to 70 characters (single) or 67 characters per segment (multi-part). Plan message content accordingly to control costs.
- Rate Limits: Vonage default throughput is 30 API requests per second (up to 2.6M messages/day). US 10DLC numbers have carrier-specific limits: T-Mobile uses brand trust scores, other carriers typically allow 600 texts per minute per number. Factor rate limiting into campaign design to avoid throttling or carrier filtering.
System Architecture:
The architecture involves an Admin/User interacting with an API Layer (Express). This layer communicates with Campaign and Contact Services, which interact with a Database and the Vonage SDK. The SDK sends SMS via the Vonage API to the User's Phone. Replies (like "STOP") and status updates from the User's Phone go back through the Vonage API, triggering Inbound and Status Webhooks handled by the Express application, which update the Contact Service and Database.
Prerequisites:
- A Vonage API account (Sign up at vonage.com)
- Node.js and npm (or yarn) installed on your development machine
- A Vonage virtual phone number capable of sending/receiving SMS
- ngrok installed and configured (a free account is sufficient)
- Basic understanding of JavaScript, Node.js, and REST APIs
- (Optional but recommended) A database system installed and running
Final Outcome:
A Node.js Express application with API endpoints to manage contacts and campaigns, logic to send SMS messages via Vonage Messages API, and webhook handlers to process inbound messages and status updates.
1. How to Set Up Your Node.js SMS Project
Let's initialize our Node.js project and install necessary dependencies for sending SMS with Vonage.
-
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
bashmkdir vonage-sms-campaigns cd vonage-sms-campaigns -
Initialize Node.js Project: This creates a
package.jsonfile to manage project dependencies and scripts.bashnpm init -y -
Install Dependencies: We need Express for the web server, the Vonage SDK for SMS functionality, and
dotenvfor managing environment variables securely.bashnpm install express @vonage/server-sdk dotenvexpress: Web framework for building REST APIs@vonage/server-sdk: Official Vonage SDK for Node.jsdotenv: Loads environment variables from a.envfile intoprocess.env
-
Project Structure: Create the following basic structure:
textvonage-sms-campaigns/ ├── src/ │ ├── services/ │ │ ├── vonage.service.js # Vonage SDK initialization and interaction logic │ │ └── campaign.service.js # Logic for sending campaigns │ ├── routes/ │ │ ├── api.js # Main API router │ │ └── webhooks.js # Webhook handlers │ ├── controllers/ │ │ ├── campaign.controller.js # API request handlers for campaigns │ │ └── contact.controller.js # API request handlers for contacts │ ├── models/ # (Optional) Database models/schemas │ │ └── contact.model.js │ └── app.js # Express application setup ├── .env # Environment variables (DO NOT COMMIT) ├── .gitignore # Files/folders to ignore in Git ├── package.json └── server.js # Main entry pointThis structure promotes separation of concerns, making the application easier to manage and scale.
-
Create
.gitignore: Prevent sensitive files and unnecessary folders from being committed to version control.plaintext# .gitignore node_modules/ .env npm-debug.log* yarn-debug.log* yarn-error.log* private.key -
Create
.envfile: Store sensitive credentials and configuration here. We'll populate this later.plaintext# .env PORT=3000 BASE_URL= # Will be set later using ngrok VONAGE_API_KEY= VONAGE_API_SECRET= VONAGE_APPLICATION_ID= VONAGE_PRIVATE_KEY_PATH=./private.key # Assuming private key is in project root VONAGE_NUMBER= # Your Vonage virtual number DATABASE_URL= # (Optional) Your database connection stringCrucially, ensure your
VONAGE_PRIVATE_KEY_PATHpoints to the actual location where you save theprivate.keyfile generated by Vonage (see Step 4). Addingprivate.keyto.gitignoreis vital for security. -
Basic Server Entry Point (
server.js): This file loads environment variables and starts the Express app.javascript// server.js require('dotenv').config(); // Load .env variables first const app = require('./src/app'); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); if (!process.env.BASE_URL) { console.warn('WARN: BASE_URL environment variable not set. Webhooks may not function correctly until ngrok is running and BASE_URL is updated.'); } // Log essential Vonage config to verify it's loaded (avoid logging secrets in production) console.log(`Vonage Number: ${process.env.VONAGE_NUMBER ? 'Loaded' : 'MISSING'}`); console.log(`Vonage App ID: ${process.env.VONAGE_APPLICATION_ID ? 'Loaded' : 'MISSING'}`); console.log(`Vonage API Key: ${process.env.VONAGE_API_KEY ? 'Loaded' : 'MISSING'}`); console.log(`Private Key Path: ${process.env.VONAGE_PRIVATE_KEY_PATH ? 'Loaded' : 'MISSING'}`); }); -
Basic Express App Setup (
src/app.js): Configure the Express application, middleware, and routes.javascript// src/app.js const express = require('express'); const apiRoutes = require('./routes/api'); const webhookRoutes = require('./routes/webhooks'); const app = express(); // Middleware app.use(express.json()); // Parse JSON request bodies app.use(express.urlencoded({ extended: true })); // Parse URL-encoded request bodies // Simple Logging Middleware (replace with a proper logger like Winston or Pino in production) app.use((req, res, next) => { console.log(`${new Date().toISOString()} - ${req.method} ${req.originalUrl}`); next(); }); // Routes app.get('/health', (req, res) => { res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() }); }); app.use('/api', apiRoutes); app.use('/webhooks', webhookRoutes); // Basic Error Handling Middleware (improve for production) app.use((err, req, res, next) => { console.error("Error:", err.stack || err.message || err); res.status(err.status || 500).json({ error: { message: err.message || 'Internal Server Error', }, }); }); module.exports = app;
This establishes the foundational structure and configuration for our SMS marketing application.
2. How to Send SMS Messages with Vonage Node.js SDK
Now, let's implement the core logic for sending SMS and handling Vonage interactions.
-
Vonage Service (
src/services/vonage.service.js): Initialize the Vonage SDK and create functions for sending messages.javascript// src/services/vonage.service.js const { Vonage } = require('@vonage/server-sdk'); const path = require('path'); // Ensure environment variables are loaded if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET || !process.env.VONAGE_APPLICATION_ID || !process.env.VONAGE_PRIVATE_KEY_PATH) { throw new Error('Vonage API credentials missing in environment variables.'); } // Resolve the private key path relative to the project root const privateKeyPath = path.resolve(process.cwd(), process.env.VONAGE_PRIVATE_KEY_PATH); const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET, // Note: Secret might not be needed for Messages API with private key auth applicationId: process.env.VONAGE_APPLICATION_ID, privateKey: privateKeyPath, // Use the resolved absolute path }); /** * Sends an SMS message using the Vonage Messages API. * @param {string} to - The recipient phone number (E.164 format). * @param {string} text - The message content. * @returns {Promise<string>} - The message UUID on success. * @throws {Error} - If sending fails. */ async function sendSms(to, text) { const from = process.env.VONAGE_NUMBER; if (!from) { throw new Error('VONAGE_NUMBER environment variable is not set.'); } if (!to || !text) { throw new Error('Recipient number (to) and message text are required.'); } console.log(`Attempting to send SMS from ${from} to ${to}`); try { // Use vonage.messages.send for the Messages API const resp = await vonage.messages.send({ message_type: 'text', text: text, to: to, from: from, channel: 'sms', }); console.log(`Message sent successfully to ${to}. UUID: ${resp.message_uuid}`); return resp.message_uuid; // Return the message UUID for tracking } catch (err) { console.error(`Error sending SMS to ${to}:`, err.response ? err.response.data : err.message); // Rethrow a more specific error or handle it as needed throw new Error(`Failed to send SMS via Vonage: ${err.message}`); } } module.exports = { sendSms, // Add other Vonage related functions here if needed };- Why Private Key? The Messages API primarily uses Application ID and Private Key for authentication, ensuring more secure communication than just API Key/Secret for sending.
- Why
vonage.messages.send? Research highlights using the newer Messages API (vonage.messages.send) over the older SMS API (vonage.message.sendSms) for broader channel support and features, even if we only use SMS here. Ensure your Vonage account is configured to use the Messages API as default for SMS (see Step 4). - Error Handling: The
try...catchblock is crucial for handling potential API errors (e.g., invalid number, insufficient funds, network issues). Logging the error details helps debugging.
-
Campaign Service (
src/services/campaign.service.js): This service will orchestrate sending a campaign to multiple contacts. For now, we'll use a simple in-memory array for contacts. Replace this with database interaction later (Step 6).javascript// src/services/campaign.service.js const { sendSms } = require('./vonage.service'); // --- In-Memory Store (Replace with Database in Step 6) --- let contacts = [ // Example: { number: '14155550100', status: 'subscribed' } ]; let messageLogs = []; // Store message attempts { contactNumber, campaignId, messageUuid, status, timestamp } // --- End In-Memory Store --- /** * Sends a campaign message to all subscribed contacts. * @param {string} campaignId - An identifier for the campaign. * @param {string} messageText - The text of the SMS message. * @param {number} delayMs - Optional delay between sending messages in milliseconds (for rate limiting). */ async function sendCampaign(campaignId, messageText, delayMs = 100) { // Add small default delay console.log(`Starting campaign: ${campaignId}`); const subscribedContacts = contacts.filter(c => c.status === 'subscribed'); if (subscribedContacts.length === 0) { console.log(`Campaign ${campaignId}: No subscribed contacts to send to.`); return; } console.log(`Campaign ${campaignId}: Sending to ${subscribedContacts.length} contacts.`); for (const contact of subscribedContacts) { try { const messageUuid = await sendSms(contact.number, messageText); console.log(`Campaign ${campaignId}: Successfully sent message to ${contact.number}, UUID: ${messageUuid}`); messageLogs.push({ contactNumber: contact.number, campaignId: campaignId, messageUuid: messageUuid, status: 'submitted', // Initial status from Vonage SDK timestamp: new Date(), }); // Optional: Add delay to avoid hitting rate limits if (delayMs > 0) { await new Promise(resolve => setTimeout(resolve, delayMs)); } } catch (error) { console.error(`Campaign ${campaignId}: Failed to send message to ${contact.number}: ${error.message}`); messageLogs.push({ contactNumber: contact.number, campaignId: campaignId, messageUuid: null, status: 'failed', error: error.message, timestamp: new Date(), }); // Decide if you want to stop the campaign on failure or continue // continue; } } console.log(`Campaign ${campaignId} finished.`); } // --- Contact Management Functions (Replace with DB in Step 6) --- function addContact(number) { if (!contacts.some(c => c.number === number)) { contacts.push({ number: number, status: 'subscribed' }); console.log(`Contact ${number} added and subscribed.`); return true; } console.log(`Contact ${number} already exists.`); return false; } function findContact(number) { return contacts.find(c => c.number === number); } function updateContactStatus(number, status) { const contact = findContact(number); if (contact) { contact.status = status; console.log(`Contact ${number} status updated to ${status}.`); return true; } return false; } function getContacts() { return [...contacts]; // Return a copy } function getMessageLogs() { return [...messageLogs]; } // --- End Contact Management --- module.exports = { sendCampaign, addContact, findContact, updateContactStatus, getContacts, getMessageLogs, // Function exposed for testing in-memory store reset __test_resetContacts: () => { contacts = []; messageLogs = []; } };- Rate Limiting: Vonage enforces a default limit of 30 API requests per second (up to 2.6M messages/day). US 10DLC numbers face additional carrier restrictions: most carriers limit throughput to 600 texts per minute per number, while T-Mobile uses brand trust scores. The 100ms default delay (
delayMs = 100) allows ~10 messages/second, staying well within limits. For large campaigns, implement job queues (Step 9) for systematic rate control and retry handling. - Asynchronous Operations: Sending SMS is asynchronous.
async/awaitis used to handle promises returned bysendSmscleanly within the loop. - State Management: The in-memory
contactsarray is temporary. A database is essential for persistence.
- Rate Limiting: Vonage enforces a default limit of 30 API requests per second (up to 2.6M messages/day). US 10DLC numbers face additional carrier restrictions: most carriers limit throughput to 600 texts per minute per number, while T-Mobile uses brand trust scores. The 100ms default delay (
3. Building API Endpoints for SMS Campaigns
We'll create Express routes and controllers to manage contacts and trigger SMS campaigns.
-
Contact Controller (
src/controllers/contact.controller.js): Handles API requests related to contacts.javascript// src/controllers/contact.controller.js const contactService = require('../services/campaign.service'); // Using campaign service for now // Basic validation (improve with libraries like Joi or express-validator) function isValidPhoneNumber(number) { // Simple check - improve for E.164 format enforcement return /^\+?[1-9]\d{1,14}$/.test(number); } exports.addContact = (req, res, next) => { const { number } = req.body; if (!number || !isValidPhoneNumber(number)) { return res.status(400).json({ error: 'Valid phone number is required. Please use E.164 format (e.g., +12015550101).' }); } try { const added = contactService.addContact(number); res.status(added ? 201 : 200).json({ message: added ? 'Contact added and subscribed.' : 'Contact already exists.', contact: contactService.findContact(number) }); } catch (error) { next(error); } }; exports.listContacts = (req, res, next) => { try { const contacts = contactService.getContacts(); res.status(200).json(contacts); } catch (error) { next(error); } }; exports.getContact = (req, res, next) => { const { number } = req.params; if (!number || !isValidPhoneNumber(number)) { return res.status(400).json({ error: 'Valid phone number parameter is required.' }); } try { const contact = contactService.findContact(number); if (contact) { res.status(200).json(contact); } else { res.status(404).json({ error: 'Contact not found.' }); } } catch (error) { next(error); } }; exports.unsubscribeContact = (req, res, next) => { // Usually handled by webhook, but allow manual unsubscribe via API const { number } = req.params; if (!number || !isValidPhoneNumber(number)) { return res.status(400).json({ error: 'Valid phone number parameter is required.' }); } try { const updated = contactService.updateContactStatus(number, 'unsubscribed'); if (updated) { res.status(200).json({ message: 'Contact unsubscribed successfully.' }); } else { res.status(404).json({ error: 'Contact not found.' }); } } catch (error) { next(error); } }; -
Campaign Controller (
src/controllers/campaign.controller.js): Handles API requests for sending campaigns and viewing logs.javascript// src/controllers/campaign.controller.js const campaignService = require('../services/campaign.service'); exports.sendCampaign = async (req, res, next) => { const { campaignId, messageText } = req.body; if (!campaignId || !messageText) { return res.status(400).json({ error: 'campaignId and messageText are required.' }); } try { // Trigger sending asynchronously - don't wait for all messages to finish campaignService.sendCampaign(campaignId, messageText); res.status(202).json({ message: `Campaign ${campaignId} accepted and is being processed.` }); } catch (error) { next(error); // Pass errors to the error handling middleware } }; exports.getLogs = (req, res, next) => { try { const logs = campaignService.getMessageLogs(); res.status(200).json(logs); } catch (error) { next(error); } };- Asynchronous Trigger: Notice
sendCampaignis called withoutawaitin the controller. This immediately returns a202 Acceptedresponse to the client, indicating the request is processing in the background. This prevents long-running requests for large campaigns. For production, a dedicated job queue (Step 9) is better.
- Asynchronous Trigger: Notice
-
API Router (
src/routes/api.js): Define the API endpoints and link them to controllers.javascript// src/routes/api.js const express = require('express'); const contactController = require('../controllers/contact.controller'); const campaignController = require('../controllers/campaign.controller'); const router = express.Router(); // Contact Routes router.post('/contacts', contactController.addContact); router.get('/contacts', contactController.listContacts); router.get('/contacts/:number', contactController.getContact); router.delete('/contacts/:number/unsubscribe', contactController.unsubscribeContact); // DELETE or POST for unsubscribe // Campaign Routes router.post('/campaigns/send', campaignController.sendCampaign); router.get('/campaigns/logs', campaignController.getLogs); module.exports = router; -
Testing API Endpoints (Examples):
-
Add Contact:
bashcurl -X POST http://localhost:3000/api/contacts \ -H "Content-Type: application/json" \ -d '{"number": "+14155550101"}' # Use a real test number formatExpected Response (201):
{"message":"Contact added and subscribed.","contact":{"number":"+14155550101","status":"subscribed"}} -
List Contacts:
bashcurl http://localhost:3000/api/contactsExpected Response (200):
[{"number":"+14155550101","status":"subscribed"}] -
Send Campaign:
bashcurl -X POST http://localhost:3000/api/campaigns/send \ -H "Content-Type: application/json" \ -d '{"campaignId": "spring-promo", "messageText": "Special offer just for you!"}'Expected Response (202):
{"message":"Campaign spring-promo accepted and is being processed."} -
View Logs:
bashcurl http://localhost:3000/api/campaigns/logsExpected Response (200):
[{"contactNumber":"+14155550101","campaignId":"spring-promo","messageUuid":"<some-uuid>","status":"submitted","timestamp":"..."}](Status might update later via webhooks)
-
4. Configuring Vonage Webhooks for SMS
This step connects our local application to the outside world using ngrok and configures Vonage to communicate with it.
-
Get Vonage Credentials:
-
Navigate to your Vonage API Dashboard.
-
Find your API Key and API Secret on the main dashboard page. Add these to your
.envfile:plaintext# .env VONAGE_API_KEY=YOUR_API_KEY VONAGE_API_SECRET=YOUR_API_SECRET -
Purchase a Vonage virtual number if you don't have one (Numbers -> Buy numbers). Ensure it supports SMS. Add it to your
.envfile (use E.164 format, e.g.,+12015550123):plaintext# .env VONAGE_NUMBER=YOUR_VONAGE_NUMBER
-
-
Create a Vonage Application: The Messages API requires a Vonage Application for authentication (using Application ID and Private Key) and webhook configuration.
-
Go to Your applications in the Vonage Dashboard.
-
Click "+ Create a new application".
-
Give it a name (e.g., "Node SMS Marketing App").
-
Click "Generate public and private key". Immediately save the
private.keyfile that downloads. Place it in your project's root directory (or the path specified inVONAGE_PRIVATE_KEY_PATHin.env). Addprivate.keyto your.gitignorefile! -
Enable the Messages capability.
-
You'll see fields for Inbound URL and Status URL. We need ngrok running to get these URLs. Leave them blank for now, but keep this page open.
-
Scroll down and click "Generate new application".
-
Copy the generated Application ID and add it to your
.envfile:plaintext# .env VONAGE_APPLICATION_ID=YOUR_APPLICATION_ID -
Link your Vonage Number: On the application page, find the "Link virtual numbers" section, click "Link", select your purchased Vonage number, and confirm. This directs incoming messages for that number to this application's webhooks.
-
-
Run ngrok: Expose your local server (running on port 3000) to the internet.
-
Open a new terminal window (keep your Node server running in the other).
-
Run:
bashngrok http 3000 -
ngrok will display forwarding URLs. Copy the
https://URL (e.g.,https://<random-string>.ngrok-free.app). This is your public base URL.textForwarding https://<random-string>.ngrok-free.app -> http://localhost:3000
-
-
Configure Webhooks in Vonage Dashboard:
- Go back to your Vonage Application settings page (Your applications -> Click your app name).
- Under the Messages capability:
- Set Inbound URL to:
YOUR_NGROK_HTTPS_URL/webhooks/inbound(e.g.,https://<random-string>.ngrok-free.app/webhooks/inbound) - Set Status URL to:
YOUR_NGROK_HTTPS_URL/webhooks/status(e.g.,https://<random-string>.ngrok-free.app/webhooks/status) - Ensure the HTTP Method for both is set to POST.
- Set Inbound URL to:
- Click Save changes.
-
Configure Webhooks in Vonage Account Settings: Ensure your account is set to use the Messages API for SMS webhooks. This is crucial as Vonage has two SMS APIs (the older SMS API and the newer Messages API) which use different webhook formats.
- Go to your Vonage Account Settings.
- Scroll down to API settings, then SMS settings.
- Ensure "Default SMS Setting" is set to Messages API.
- Click Save changes.
-
Set
BASE_URLEnvironment Variable: Update your.envfile with the ngrok URL so the application knows its public address if needed.plaintext# .env BASE_URL=https://<random-string>.ngrok-free.appRestart your Node.js server (
node server.js) after updating.envfor the changes to take effect.
Now, Vonage knows where to send inbound SMS messages and delivery status updates for your linked number, directing them through ngrok to your running Express application.
5. Implementing Error Handling and Logging
Production systems need robust error handling and visibility.
-
Consistent Error Handling: Our basic error middleware in
src/app.jscatches unhandled errors. Enhance it for clarity:javascript// src/app.js (Error Handling Middleware - Enhanced) app.use((err, req, res, next) => { // Log the full error stack for debugging console.error(`[${new Date().toISOString()}] ERROR: ${req.method} ${req.originalUrl}`); console.error(err.stack || err.message || err); // Avoid leaking stack traces in production const status = err.status || 500; const message = (process.env.NODE_ENV === 'production' && status === 500) ? 'Internal Server Error' : err.message || 'Internal Server Error'; // Show more detail in dev res.status(status).json({ error: { message: message, // Optionally include error code or type in non-production ...(process.env.NODE_ENV !== 'production' && { type: err.name, stack: err.stack }), }, }); });- Production vs. Development: This enhanced handler avoids sending detailed stack traces to the client in production (
NODE_ENV=production) for security. - Logging: It logs the full error details on the server regardless of environment.
- Production vs. Development: This enhanced handler avoids sending detailed stack traces to the client in production (
-
Structured Logging: Replace
console.log/console.errorwith a dedicated library like Pino (fast, JSON-based) or Winston.bashnpm install pino pino-httpUpdate
src/app.js:javascript// src/app.js const express = require('express'); const pino = require('pino'); const pinoHttp = require('pino-http'); // Configure logger (adjust level for production) const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); const httpLogger = pinoHttp({ logger }); const apiRoutes = require('./routes/api'); const webhookRoutes = require('./routes/webhooks'); const app = express(); // Middleware app.use(httpLogger); // Add pino-http logger for requests/responses app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Routes app.get('/health', (req, res) => { req.log.info('Health check accessed'); // Use req.log from pino-http res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() }); }); app.use('/api', apiRoutes); app.use('/webhooks', webhookRoutes); // Error Handling Middleware (using logger) app.use((err, req, res, next) => { req.log.error({ err }, `Error processing ${req.method} ${req.originalUrl}`); // Log error object const status = err.status || 500; const message = (process.env.NODE_ENV === 'production' && status === 500) ? 'Internal Server Error' : err.message || 'Internal Server Error'; res.status(status).json({ error: { message: message }, }); }); module.exports = app; // Export app, not starting server here // Update server.js to use the logger // server.js // require('dotenv').config(); // const app = require('./src/app'); // const logger = require('pino')(); // Or get logger instance appropriately // const PORT = process.env.PORT || 3000; // app.listen(PORT, () => { logger.info(`Server listening on port ${PORT}`); });- Benefits: Structured (JSON) logs are easily searchable and parsable by log aggregation tools (Datadog, Splunk, ELK Stack).
pino-httpautomatically logs request/response details.
- Benefits: Structured (JSON) logs are easily searchable and parsable by log aggregation tools (Datadog, Splunk, ELK Stack).
-
Retry Mechanisms:
- Vonage API Calls: Network issues or temporary Vonage outages can cause
sendSmsto fail. Implement retries with exponential backoff for transient errors (e.g., 5xx errors from Vonage, network timeouts). Libraries likeasync-retrycan help. - Webhook Processing: If processing a webhook fails (e.g., database temporarily unavailable), Vonage might retry sending it. Ensure your webhook handlers are idempotent – processing the same webhook multiple times should not cause incorrect side effects (e.g., unsubscribing an already unsubscribed user is fine). Use unique constraints (like
vonageMessageUuidin theMessagetable) to prevent duplicate record creation. - Complex Retries: For critical operations like sending campaign messages, using a job queue (see Step 9) is the most robust way to handle failures and retries systematically.
- Vonage API Calls: Network issues or temporary Vonage outages can cause
6. Database Integration for SMS Campaign Data
For a production system, storing contacts, campaigns, and message logs persistently is essential. We'll outline using Prisma as an example ORM with PostgreSQL, but you can adapt this to other databases (MySQL, MongoDB) or ORMs (Sequelize).
-
Install Prisma:
bashnpm install prisma --save-dev npm install @prisma/client -
Initialize Prisma: This creates a
prismadirectory with aschema.prismafile and updates.envwith aDATABASE_URL.bashnpx prisma init --datasource-provider postgresql # Or mysql, mongodb, sqliteUpdate the
DATABASE_URLin your.envfile with your actual database connection string.plaintext# .env DATABASE_URL="postgresql://user:password@host:port/database_name" -
Define Database Schema (
prisma/schema.prisma):prisma// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Contact { id Int @id @default(autoincrement()) phoneNumber String @unique @map("phone_number") status String @default("subscribed") // subscribed, unsubscribed subscribedAt DateTime @default(now()) @map("subscribed_at") unsubscribedAt DateTime? @map("unsubscribed_at") messages Message[] @@map("contacts") } model Campaign { id Int @id @default(autoincrement()) campaignId String @unique @map("campaign_id") messageText String @map("message_text") createdAt DateTime @default(now()) @map("created_at") messages Message[] @@map("campaigns") } model Message { id Int @id @default(autoincrement()) vonageMessageUuid String? @unique @map("vonage_message_uuid") contactId Int @map("contact_id") campaignId Int? @map("campaign_id") status String // submitted, delivered, failed, etc. errorMessage String? @map("error_message") sentAt DateTime @default(now()) @map("sent_at") deliveredAt DateTime? @map("delivered_at") contact Contact @relation(fields: [contactId], references: [id]) campaign Campaign? @relation(fields: [campaignId], references: [id]) @@map("messages") } -
Run Migrations: Generate database tables from the schema.
bashnpx prisma migrate dev --name init -
Generate Prisma Client:
bashnpx prisma generate -
Update Services to Use Database: Replace in-memory storage with Prisma queries. Example for
campaign.service.js:javascript// src/services/campaign.service.js (Database version) const { PrismaClient } = require('@prisma/client'); const { sendSms } = require('./vonage.service'); const prisma = new PrismaClient(); async function sendCampaign(campaignIdStr, messageText, delayMs = 100) { console.log(`Starting campaign: ${campaignIdStr}`); // Create campaign record let campaign = await prisma.campaign.findUnique({ where: { campaignId: campaignIdStr } }); if (!campaign) { campaign = await prisma.campaign.create({ data: { campaignId: campaignIdStr, messageText } }); } // Get subscribed contacts const subscribedContacts = await prisma.contact.findMany({ where: { status: 'subscribed' } }); if (subscribedContacts.length === 0) { console.log(`Campaign ${campaignIdStr}: No subscribed contacts.`); return; } for (const contact of subscribedContacts) { try { const messageUuid = await sendSms(contact.phoneNumber, messageText); await prisma.message.create({ data: { vonageMessageUuid: messageUuid, contactId: contact.id, campaignId: campaign.id, status: 'submitted' } }); if (delayMs > 0) { await new Promise(resolve => setTimeout(resolve, delayMs)); } } catch (error) { console.error(`Failed to send to ${contact.phoneNumber}:`, error.message); await prisma.message.create({ data: { contactId: contact.id, campaignId: campaign.id, status: 'failed', errorMessage: error.message } }); } } } async function addContact(phoneNumber) { const existing = await prisma.contact.findUnique({ where: { phoneNumber } }); if (existing) return false; await prisma.contact.create({ data: { phoneNumber } }); return true; } async function updateContactStatus(phoneNumber, status) { const contact = await prisma.contact.findUnique({ where: { phoneNumber } }); if (!contact) return false; await prisma.contact.update({ where: { phoneNumber }, data: { status, ...(status === 'unsubscribed' && { unsubscribedAt: new Date() }) } }); return true; } module.exports = { sendCampaign, addContact, updateContactStatus, getContacts: () => prisma.contact.findMany(), getMessageLogs: () => prisma.message.findMany({ include: { contact: true, campaign: true } }), findContact: (phoneNumber) => prisma.contact.findUnique({ where: { phoneNumber } }) };
7. How to Receive SMS Messages with Webhooks
Implement webhook handlers to receive incoming SMS messages and delivery status updates.
-
Create Webhook Routes (
src/routes/webhooks.js):javascript// src/routes/webhooks.js const express = require('express'); const router = express.Router(); const campaignService = require('../services/campaign.service'); // Inbound SMS webhook router.post('/inbound', async (req, res) => { console.log('Received inbound SMS:', JSON.stringify(req.body, null, 2)); const { from, text, message_type } = req.body; if (message_type === 'text' && text) { const normalizedText = text.trim().toUpperCase(); // Handle STOP/UNSUBSCRIBE requests if (normalizedText === 'STOP' || normalizedText === 'UNSUBSCRIBE') { try { await campaignService.updateContactStatus(from, 'unsubscribed'); console.log(`Contact ${from} unsubscribed via SMS`); } catch (error) { console.error(`Error unsubscribing ${from}:`, error); } } } res.status(200).send('OK'); }); // Status webhook for delivery receipts router.post('/status', async (req, res) => { console.log('Received status update:', JSON.stringify(req.body, null, 2)); const { message_uuid, status, error } = req.body; // Update message status in database try { const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); await prisma.message.update({ where: { vonageMessageUuid: message_uuid }, data: { status, ...(status === 'delivered' && { deliveredAt: new Date() }), ...(error && { errorMessage: JSON.stringify(error) }) } }); } catch (err) { console.error('Error updating message status:', err); } res.status(200).send('OK'); }); module.exports = router; -
Test Webhooks:
- Send an SMS to your Vonage number from your phone
- Check your server logs for the inbound webhook payload
- Try sending "STOP" to test unsubscribe functionality
Next Steps: Enhancing Your SMS Marketing System
Additional Features to Consider:
- Job Queues: Implement Bull or BullMQ for robust background job processing
- Scheduled Campaigns: Add campaign scheduling with node-cron or agenda
- Message Templates: Create reusable templates with variable substitution
- Analytics Dashboard: Track campaign performance and engagement metrics
- Sender ID Registration: Complete 10DLC registration for improved deliverability
- Contact Segmentation: Group contacts by attributes for targeted campaigns
- A/B Testing: Test different message variations for optimization
- Rate Limiting: Implement more sophisticated rate limiting for high-volume campaigns
Related Resources:
- TCPA Compliance Guide
- SMS Character Encoding Guide
- 10DLC Registration Process
- Vonage Messages API Documentation
Conclusion
You've successfully built a complete SMS marketing campaign system using Node.js, Express, and the Vonage Messages API. This tutorial covered project setup, sending SMS messages, webhook configuration, database integration, and compliance considerations.
The system you've created can manage contacts, send bulk SMS campaigns, handle unsubscribe requests, and track delivery status—all essential features for production SMS marketing applications.
Remember to always prioritize TCPA compliance, implement proper rate limiting, and thoroughly test your system before deploying to production. With this foundation, you can expand your SMS marketing capabilities to include advanced features like segmentation, scheduling, and analytics.