This guide provides a complete walkthrough for building a robust backend API to manage and send SMS marketing campaigns using Node.js, Express, and the Vonage Messages API. We'll cover everything from project setup and core functionality to database integration, security, error handling, deployment, and monitoring.
By the end of this tutorial, you will have a functional API capable of:
- Managing a list of contacts with opt-out status.
- Creating SMS marketing campaigns targeting specific contacts.
- Sending SMS messages reliably via the Vonage Messages API.
- Receiving and processing message status updates (delivery receipts).
- Handling inbound messages for opt-out requests (e.g., ""STOP"").
- Basic security, logging, and error handling.
This guide aims to provide a solid foundation for a production-ready system, going beyond simple send/receive examples.
Target Audience: Developers familiar with Node.js and basic web concepts looking to build SMS communication features.
Technologies Used:
- Node.js: JavaScript runtime environment.
- Express: Minimalist web framework for Node.js.
- Vonage Messages API: Unified API for sending and receiving messages across various channels (we'll focus on SMS).
- PostgreSQL: Robust relational database for storing contacts, campaigns, and message logs.
@vonage/server-sdk
: Official Vonage Node.js library.pg
: Node.js PostgreSQL client.dotenv
: Module for loading environment variables.uuid
: For generating unique identifiers.ngrok
: For exposing local development server to the internet for webhook testing.
System Architecture:
+-----------------+ +---------------------+ +----------------+
| Admin / User |----->| Node.js/Express API |----->| PostgreSQL |
| (e.g., Postman) | | (Backend) |<-----| Database |
+-----------------+ +---------------------+ +----------------+
| ^
| | (Webhook Callbacks)
v |
+-----------------+
| Vonage Messages |
| API |
+-----------------+
| ^
| (SMS) | (SMS)
v |
+-----------------+
| User's Phone |
+-----------------+
Prerequisites:
- Node.js (LTS version recommended) and npm installed.
- A Vonage API account.
- A Vonage virtual phone number capable of sending/receiving SMS.
- Access to a PostgreSQL database (local or cloud-based).
ngrok
installed and authenticated (a free account is sufficient).- Basic familiarity with REST APIs and SQL.
1. Setting up the Project
Let's start by creating the project structure_ installing dependencies_ and configuring the environment.
1.1 Create Project Directory and Initialize npm
Open your terminal and run the following commands:
# Create the project directory
mkdir vonage-sms-campaign-api
cd vonage-sms-campaign-api
# Initialize the Node.js project
npm init -y
1.2 Install Dependencies
Install the necessary libraries:
npm install express @vonage/server-sdk dotenv pg uuid express-rate-limit
express
: Web framework.@vonage/server-sdk
: Vonage SDK for Node.js.dotenv
: Loads environment variables from a.env
file.pg
: PostgreSQL client library.uuid
: Generates unique IDs (e.g._ for messages).express-rate-limit
: Basic rate limiting middleware.
For development_ you might also want nodemon
to automatically restart the server on file changes:
npm install --save-dev nodemon
Add a script to your package.json
for easy development startup:
// package.json
{
// ... other settings
"scripts": {
"start": "node src/server.js"_
"dev": "nodemon src/server.js"_
"test": "echo \"Error: no test specified\" && exit 1"
}_
// ... other settings
}
1.3 Project Structure
Create the following directory structure within your project root (vonage-sms-campaign-api/
):
.
├── src/
│ ├── config/
│ │ ├── db.js # Database connection setup
│ │ └── vonage.js # Vonage client initialization
│ ├── controllers/
│ │ ├── contactController.js
│ │ ├── campaignController.js
│ │ └── webhookController.js
│ ├── middleware/ # New directory for middleware
│ │ └── auth.js # Authentication middleware
│ ├── models/
│ │ ├── contactModel.js
│ │ ├── campaignModel.js
│ │ └── messageModel.js
│ ├── routes/
│ │ ├── api.js # Main API router
│ │ ├── contacts.js
│ │ ├── campaigns.js
│ │ └── webhooks.js
│ ├── services/
│ │ ├── vonageService.js # Logic for interacting with Vonage API
│ │ └── campaignService.js # Business logic for campaigns
│ ├── utils/
│ │ ├── logger.js # Simple logger utility
│ │ └── errorHandler.js # Global error handler middleware
│ ├── server.js # Express server entry point
│ └── app.js # Express app configuration
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore # Git ignore file
├── package.json
└── package-lock.json
.env
)
1.4 Environment Configuration (Create a file named .env
in the project root. This file will store sensitive credentials and configuration settings. Never commit this file to version control.
# .env
# Vonage API Credentials
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
# SECURITY NOTE: Storing keys directly in the project folder is convenient for dev but not recommended for production.
# Consider storing it outside the project root or using a secrets manager. Update path accordingly.
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root (or absolute path outside project)
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # The number used for sending SMS
# Database Configuration
DATABASE_URL=postgresql://USER:PASSWORD@HOST:PORT/DATABASE_NAME
# Server Configuration
PORT=3000
# Basic API Key Authentication (Example)
API_KEY=your-secret-api-key # Change this to a strong_ random key
- Obtaining Vonage Credentials:
VONAGE_API_KEY
andVONAGE_API_SECRET
: Found on your Vonage API Dashboard homepage.VONAGE_APPLICATION_ID
andVONAGE_PRIVATE_KEY_PATH
:- Go to Your applications in the Vonage Dashboard.
- Click "Create a new application".
- Give it a name (e.g._ "SMS Campaign API").
- Click "Generate public and private key". Save the
private.key
file that downloads. Security Best Practice: Store this file securely_ ideally outside your project directory or using a dedicated secrets management system_ especially in production. Update theVONAGE_PRIVATE_KEY_PATH
in your.env
file to point to its location (e.g._../keys/private.key
or an absolute path/etc/secrets/vonage/private.key
). For simplicity in this guide_ we assume it's placed in the project root (./private.key
)_ but be aware of the security implications. - Under "Capabilities"_ toggle Messages on.
- You'll need temporary
ngrok
URLs for the Inbound and Status fields initially (we'll set these up later). For now_ you can leave them blank or use placeholders likehttp://localhost:3000/webhooks/inbound
andhttp://localhost:3000/webhooks/status
. - Click "Generate new application".
- Copy the Application ID displayed and paste it into your
.env
file. - Link your Vonage virtual number to this application under the "Linked numbers" section.
VONAGE_NUMBER
: The Vonage virtual phone number you purchased and linked to the application.DATABASE_URL
: The connection string for your PostgreSQL database.API_KEY
: A simple key for basic API authentication (replace with a secure_ generated key).
.gitignore
)
1.5 Git Ignore (Create a .gitignore
file in the project root to prevent committing sensitive files and unnecessary folders:
# .gitignore
# Dependencies
node_modules/
# Environment variables
.env
.env.*
!.env.example
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Private key file (if stored in project - strongly recommended NOT to commit)
private.key
# OS generated files
.DS_Store
Thumbs.db
# Build output (if applicable)
dist/
build/
2. Implementing Core Functionality
Now_ let's set up the Express server_ database connection_ Vonage client_ and basic logging.
src/utils/logger.js
)
2.1 Basic Logger (A simple logger for now. Production applications should use more robust libraries like winston
or pino
.
// src/utils/logger.js
const logger = {
info: (message_ ...args) => {
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
},
error: (message, ...args) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
},
warn: (message, ...args) => {
console.warn(`[WARN] ${new Date().toISOString()} - ${message}`, ...args);
}
};
module.exports = logger;
src/config/db.js
)
2.2 Database Configuration (Set up the connection pool for PostgreSQL.
// src/config/db.js
const { Pool } = require('pg');
const dotenv = require('dotenv');
const logger = require('../utils/logger');
dotenv.config();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
// Recommended for production environments with SSL
// ssl: {
// rejectUnauthorized: false // Adjust based on your provider's requirements
// }
});
pool.on('connect', () => {
logger.info('Database connection established');
});
pool.on('error', (err) => {
logger.error('Unexpected error on idle client', err);
process.exit(-1); // Exit if the pool errors out
});
module.exports = {
query: (text, params) => pool.query(text, params),
pool // Export pool if direct access is needed
};
src/config/vonage.js
)
2.3 Vonage Client Initialization (Initialize the Vonage SDK using credentials from the .env
file.
// src/config/vonage.js
const { Vonage } = require('@vonage/server-sdk');
const dotenv = require('dotenv');
const path = require('path');
const fs = require('fs');
const logger = require('../utils/logger');
dotenv.config();
// Resolve path relative to project root or use absolute path if provided
const privateKeyPath = path.resolve(process.env.VONAGE_PRIVATE_KEY_PATH || './private.key');
// Ensure the private key file exists before attempting to initialize
if (!fs.existsSync(privateKeyPath)) {
logger.error(`Vonage private key file not found at path: ${privateKeyPath}`);
logger.error('Please ensure the VONAGE_PRIVATE_KEY_PATH environment variable is set correctly and the file exists.');
logger.error('Remember security best practices: avoid storing keys directly in the project folder in production.');
// In a real app, you might handle this more gracefully, but exiting is clear during setup
process.exit(1);
}
const vonage = new Vonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET,
applicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: privateKeyPath // Use the resolved path
}, {
// Optional: Add custom logger or other options
// logger: logger // You can potentially pass your custom logger here if needed
});
logger.info('Vonage client initialized.');
module.exports = vonage;
src/app.js
)
2.4 Express App Configuration (Set up the main Express application, including middleware like JSON parsing, URL encoding, rate limiting, and request logging.
// src/app.js
const express = require('express');
const dotenv = require('dotenv');
const rateLimit = require('express-rate-limit');
const apiRouter = require('./routes/api');
const errorHandler = require('./utils/errorHandler');
const logger = require('./utils/logger');
dotenv.config();
const app = express();
// Middleware
app.use(express.json()); // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies
// Simple request logger middleware
app.use((req, res, next) => {
logger.info(`${req.method} ${req.path}`);
next();
});
// Apply rate limiting to API routes (excluding webhooks initially)
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs for API routes
message: 'Too many requests from this IP, please try again after 15 minutes'
});
// Apply limiter specifically to routes managed by apiRouter that are NOT webhooks
// Note: This requires apiRouter structure where webhooks are separate
// (as implemented in Section 4.2)
app.use('/api/v1', (req, res, next) => {
// Check if the path starts with /webhooks to exclude them from this limiter
if (req.path.startsWith('/webhooks')) {
return next(); // Skip API limiter for webhooks
}
apiLimiter(req, res, next); // Apply limiter to other API routes
});
// Consider adding a separate, potentially more lenient, limiter for webhooks if needed
// Mount API routes
app.use('/api/v1', apiRouter); // Prefix routes with /api/v1
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});
// Global error handler - Must be the last middleware
app.use(errorHandler);
module.exports = app;
src/server.js
)
2.5 Server Entry Point (Start the Express server.
// src/server.js
const app = require('./app');
const logger = require('./utils/logger');
const db = require('./config/db'); // Import to potentially test connection on start
const PORT = process.env.PORT || 3000;
// Optional: Test database connection on startup
db.query('SELECT NOW()')
.then(res => logger.info(`Database connectivity confirmed at: ${res.rows[0].now}`))
.catch(err => {
logger.error('Database connection failed on startup:', err);
process.exit(1); // Exit if DB connection fails
});
const server = app.listen(PORT, () => {
logger.info(`Server running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
logger.info(`API available at http://localhost:${PORT}/api/v1`);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
logger.error('UNHANDLED REJECTION! Shutting down...');
logger.error(err.name, err.message);
server.close(() => {
process.exit(1);
});
});
// Handle SIGTERM signal (e.g., from Docker or Heroku)
process.on('SIGTERM', () => {
logger.info('SIGTERM RECEIVED. Shutting down gracefully...');
server.close(() => {
logger.info('Process terminated.');
});
});
3. Creating a Database Schema and Data Layer
We need tables to store contacts, campaigns, and individual message statuses.
3.1 Database Schema (SQL)
Execute these SQL commands in your PostgreSQL database to create the necessary tables. Note: gen_random_uuid()
requires the pgcrypto
extension. Ensure it's enabled in your database.
-- Enable pgcrypto extension if not already enabled
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- contacts table
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone_number VARCHAR(20) UNIQUE NOT NULL, -- E.164 format recommended (e.g., +14155552671)
first_name VARCHAR(100),
last_name VARCHAR(100),
opted_out BOOLEAN DEFAULT FALSE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);
-- campaigns table
CREATE TABLE campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
message_text TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
scheduled_at TIMESTAMPTZ, -- For future scheduling feature
status VARCHAR(20) DEFAULT 'draft' NOT NULL -- e.g., draft, sending, completed, failed
);
-- messages table (to track individual SMS status)
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID REFERENCES campaigns(id) ON DELETE SET NULL, -- Allow campaign deletion without losing message history
contact_id UUID REFERENCES contacts(id) ON DELETE CASCADE, -- If contact is deleted, remove their messages
vonage_message_uuid VARCHAR(100) UNIQUE, -- UUID provided by Vonage on send
status VARCHAR(20) DEFAULT 'submitted' NOT NULL, -- e.g., submitted, delivered, expired, failed, rejected, accepted, unknown
status_timestamp TIMESTAMPTZ, -- Timestamp from Vonage status update
error_code INTEGER,
error_message TEXT,
sent_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);
-- Add indexes for performance
CREATE INDEX idx_contacts_phone_number ON contacts(phone_number);
CREATE INDEX idx_messages_vonage_uuid ON messages(vonage_message_uuid);
CREATE INDEX idx_messages_campaign_id ON messages(campaign_id);
CREATE INDEX idx_messages_contact_id ON messages(contact_id);
-- Optional: Add triggers to automatically update updated_at timestamps
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_contacts_updated_at BEFORE UPDATE
ON contacts FOR EACH ROW EXECUTE FUNCTION
update_updated_at_column();
CREATE TRIGGER update_messages_updated_at BEFORE UPDATE
ON messages FOR EACH ROW EXECUTE FUNCTION
update_updated_at_column();
src/models/
)
3.2 Data Models (Create simple model files to handle database interactions for each entity.
// src/models/contactModel.js
const db = require('../config/db');
const Contact = {
create: async ({ phone_number, first_name, last_name, opted_out = false }) => { // Allow setting initial opt-out
const query = `
INSERT INTO contacts (phone_number, first_name, last_name, opted_out)
VALUES ($1, $2, $3, $4)
RETURNING *;
`;
const values = [phone_number, first_name, last_name, opted_out];
const { rows } = await db.query(query, values);
return rows[0];
},
findById: async (id) => {
const { rows } = await db.query('SELECT * FROM contacts WHERE id = $1', [id]);
return rows[0];
},
findByPhoneNumber: async (phone_number) => {
const { rows } = await db.query('SELECT * FROM contacts WHERE phone_number = $1', [phone_number]);
return rows[0];
},
getAll: async () => {
const { rows } = await db.query('SELECT * FROM contacts ORDER BY created_at DESC');
return rows;
},
updateOptOut: async (id, opted_out) => {
const query = `
UPDATE contacts
SET opted_out = $1, updated_at = NOW()
WHERE id = $2
RETURNING *;
`;
const { rows } = await db.query(query, [opted_out, id]);
return rows[0];
},
// Add delete function if needed
};
module.exports = Contact;
// src/models/campaignModel.js
const db = require('../config/db');
const Campaign = {
create: async ({ name, message_text }) => {
const query = `
INSERT INTO campaigns (name, message_text)
VALUES ($1, $2)
RETURNING *;
`;
const values = [name, message_text];
const { rows } = await db.query(query, values);
return rows[0];
},
findById: async (id) => {
const { rows } = await db.query('SELECT * FROM campaigns WHERE id = $1', [id]);
return rows[0];
},
getAll: async () => {
const { rows } = await db.query('SELECT * FROM campaigns ORDER BY created_at DESC');
return rows;
},
updateStatus: async (id, status) => {
const query = `
UPDATE campaigns
SET status = $1
WHERE id = $2
RETURNING *;
`;
const { rows } = await db.query(query, [status, id]);
return rows[0];
},
// Add update, delete functions if needed
};
module.exports = Campaign;
// src/models/messageModel.js
const db = require('../config/db');
const Message = {
create: async ({ campaign_id, contact_id, vonage_message_uuid, status = 'submitted', error_code = null, error_message = null }) => {
const query = `
INSERT INTO messages (campaign_id, contact_id, vonage_message_uuid, status, error_code, error_message, sent_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
RETURNING *;
`;
const values = [campaign_id, contact_id, vonage_message_uuid, status, error_code, error_message];
const { rows } = await db.query(query, values);
return rows[0];
},
findByVonageUuid: async (vonage_message_uuid) => {
const { rows } = await db.query('SELECT * FROM messages WHERE vonage_message_uuid = $1', [vonage_message_uuid]);
return rows[0];
},
updateStatusByVonageUuid: async (vonage_message_uuid, status, status_timestamp, error_code = null, error_message = null) => {
const query = `
UPDATE messages
SET status = $1,
status_timestamp = $2,
error_code = $3,
error_message = $4,
updated_at = NOW()
WHERE vonage_message_uuid = $5
RETURNING *;
`;
const values = [status, status_timestamp, error_code, error_message, vonage_message_uuid];
const { rows } = await db.query(query, values);
return rows[0];
},
getMessagesByCampaignId: async (campaign_id) => {
const query = `
SELECT m.*, c.phone_number
FROM messages m
JOIN contacts c ON m.contact_id = c.id
WHERE m.campaign_id = $1
ORDER BY m.sent_at ASC;
`;
const { rows } = await db.query(query, [campaign_id]);
return rows;
}
};
module.exports = Message;
4. Building the API Layer (Routes and Controllers)
Define the RESTful API endpoints for managing contacts, campaigns, and handling webhooks.
4.1 Basic Authentication Middleware
Create the src/middleware
directory if it doesn't exist, then add the authentication middleware file.
// src/middleware/auth.js (Create this new file)
const logger = require('../utils/logger');
const authenticateApiKey = (req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
logger.warn('Authentication failed: No API key provided');
return res.status(401).json({ message: 'Unauthorized: API key required' });
}
if (apiKey !== process.env.API_KEY) {
logger.warn(`Authentication failed: Invalid API key provided: ${apiKey}`);
return res.status(403).json({ message: 'Forbidden: Invalid API key' });
}
next(); // API key is valid
};
module.exports = authenticateApiKey;
src/routes/api.js
)
4.2 Main API Router (// src/routes/api.js
const express = require('express');
const contactRoutes = require('./contacts');
const campaignRoutes = require('./campaigns');
const webhookRoutes = require('./webhooks');
const authenticateApiKey = require('../middleware/auth'); // Import the middleware
const router = express.Router();
// Apply API key authentication to all campaign and contact routes
router.use('/contacts', authenticateApiKey, contactRoutes);
router.use('/campaigns', authenticateApiKey, campaignRoutes);
// Webhook routes should generally NOT be authenticated with the API key,
// as they are called by Vonage. Security for webhooks is handled differently
// (e.g., signature verification if available, IP filtering).
router.use('/webhooks', webhookRoutes);
module.exports = router;
src/routes/contacts.js
, src/controllers/contactController.js
)
4.3 Contact Routes & Controller (// src/routes/contacts.js
const express = require('express');
const contactController = require('../controllers/contactController');
// TODO: Add input validation middleware (e.g., using Joi or express-validator)
const router = express.Router();
router.post('/', contactController.createContact);
router.get('/', contactController.getAllContacts);
router.get('/:id', contactController.getContactById);
// Add routes for update, delete, opt-out update if needed
module.exports = router;
// src/controllers/contactController.js
const Contact = require('../models/contactModel');
const logger = require('../utils/logger');
exports.createContact = async (req, res, next) => {
try {
// Basic validation (implement more robust validation later, see Section 7 & 8)
const { phone_number, first_name, last_name } = req.body;
if (!phone_number) {
return res.status(400).json({ message: 'Phone number is required' });
}
// TODO: Implement E.164 phone number format validation here (e.g., using libphonenumber-js)
const existingContact = await Contact.findByPhoneNumber(phone_number);
if (existingContact) {
return res.status(409).json({ message: 'Contact with this phone number already exists' });
}
const contact = await Contact.create({ phone_number, first_name, last_name });
logger.info(`Contact created: ${contact.id}`);
res.status(201).json(contact);
} catch (error) {
next(error); // Pass error to the global error handler
}
};
exports.getAllContacts = async (req, res, next) => {
try {
const contacts = await Contact.getAll();
res.status(200).json(contacts);
} catch (error) {
next(error);
}
};
exports.getContactById = async (req, res, next) => {
try {
const contact = await Contact.findById(req.params.id);
if (!contact) {
return res.status(404).json({ message: 'Contact not found' });
}
res.status(200).json(contact);
} catch (error) {
next(error);
}
};
// Implement other handlers (update, delete) as needed
src/routes/campaigns.js
, src/controllers/campaignController.js
)
4.4 Campaign Routes & Controller (// src/routes/campaigns.js
const express = require('express');
const campaignController = require('../controllers/campaignController');
// TODO: Add input validation middleware
const router = express.Router();
router.post('/', campaignController.createCampaign);
router.get('/', campaignController.getAllCampaigns);
router.get('/:id', campaignController.getCampaignById);
router.post('/:id/send', campaignController.sendCampaign); // Endpoint to trigger sending
router.get('/:id/messages', campaignController.getCampaignMessages); // Endpoint to get message statuses for a campaign
module.exports = router;
// src/controllers/campaignController.js
const Campaign = require('../models/campaignModel');
const Message = require('../models/messageModel');
const CampaignService = require('../services/campaignService');
const logger = require('../utils/logger');
exports.createCampaign = async (req, res, next) => {
try {
// Basic validation (implement more robust validation later, see Section 7)
const { name, message_text } = req.body;
if (!name || !message_text) {
return res.status(400).json({ message: 'Campaign name and message text are required' });
}
const campaign = await Campaign.create({ name, message_text });
logger.info(`Campaign created: ${campaign.id}`);
res.status(201).json(campaign);
} catch (error) {
next(error);
}
};
exports.getAllCampaigns = async (req, res, next) => {
try {
const campaigns = await Campaign.getAll();
res.status(200).json(campaigns);
} catch (error) {
next(error);
}
};
exports.getCampaignById = async (req, res, next) => {
try {
const campaign = await Campaign.findById(req.params.id);
if (!campaign) {
return res.status(404).json({ message: 'Campaign not found' });
}
res.status(200).json(campaign);
} catch (error) {
next(error);
}
};
exports.sendCampaign = async (req, res, next) => {
try {
const campaignId = req.params.id;
// Optional: Specify target contact IDs in request body, or send to all non-opted-out contacts
// const { targetContactIds } = req.body;
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
return res.status(404).json({ message: 'Campaign not found' });
}
if (campaign.status !== 'draft') {
return res.status(400).json({ message: `Campaign cannot be sent, status is: ${campaign.status}` });
}
logger.info(`Initiating sending for campaign: ${campaignId}`);
// Execute sending asynchronously (don't block the request)
// Note: This function handles updating the campaign status internally
CampaignService.sendCampaignToAll(campaignId)
.then(() => logger.info(`Campaign ${campaignId} processing kicked off.`)) // Log kickoff
.catch(err => logger.error(`Error during async campaign ${campaignId} processing:`, err)); // Log async errors
// Immediately respond to the client, indicating the process has started
res.status(202).json({ message: 'Campaign sending process initiated.' });
} catch (error) {
// Handle synchronous errors during setup/validation
next(error);
}
};
exports.getCampaignMessages = async (req, res, next) => {
try {
const campaignId = req.params.id;
const messages = await Message.getMessagesByCampaignId(campaignId);
if (!messages || messages.length === 0) {
// Check if campaign exists at all to differentiate
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
return res.status(404).json({ message: 'Campaign not found' });
}
// Campaign exists, but no messages (yet or none sent)
return res.status(200).json([]);
}
res.status(200).json(messages);
} catch (error) {
next(error);
}
};
src/routes/webhooks.js
, src/controllers/webhookController.js
)
4.5 Webhook Routes & Controller (// src/routes/webhooks.js
const express = require('express');
const webhookController = require('../controllers/webhookController');
const router = express.Router();
// These routes are called BY Vonage, typically using POST for the Messages API
router.post('/inbound', webhookController.handleInboundSms);
router.post('/status', webhookController.handleMessageStatus);
module.exports = router;
// src/controllers/webhookController.js
const logger = require('../utils/logger');
const Contact = require('../models/contactModel');
const Message = require('../models/messageModel');
const vonageService = require('../services/vonageService'); // We'll create this next
exports.handleInboundSms = async (req, res) => {
const params = req.body;
logger.info('Received inbound SMS:', JSON.stringify(params, null, 2));
// Vonage expects a 200 OK response quickly to prevent retries
res.status(200).end();
// Process the inbound message asynchronously
try {
// Basic validation for SMS via Messages API webhook format
if (params.channel === 'sms' && params.message_type === 'text' && params.from?.number && params.text) {
const fromNumber = params.from.number; // E.164 format
const messageText = params.text.trim().toUpperCase();
// Handle Opt-Out Keywords
if (['STOP', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT'].includes(messageText)) {
logger.info(`Opt-out request received from ${fromNumber}`);
let contact = await Contact.findByPhoneNumber(fromNumber);
if (contact) {
if (!contact.opted_out) {
await Contact.updateOptOut(contact.id, true);
logger.info(`Contact ${contact.id} (${fromNumber}) marked as opted-out.`);
// Optionally send confirmation SMS (use vonageService)
// await vonageService.sendSms(fromNumber, process.env.VONAGE_NUMBER, 'You have been unsubscribed.');
} else {
logger.info(`Contact ${contact.id} (${fromNumber}) was already opted-out.`);
}
} else {
// Optionally create a contact record even if they opt-out immediately
// await Contact.create({ phone_number: fromNumber, opted_out: true });
logger.warn(`Opt-out request from unknown number: ${fromNumber}. Consider creating a record.`);
}
} else {
// Handle other inbound messages (e.g., replies, help requests)
logger.info(`Received other inbound text from ${fromNumber}: ""${params.text}""`);
// Add logic here if needed (e.g., forward to support, auto-reply)
}
} else {
logger.warn('Received non-SMS or malformed inbound message webhook:', params);
}
} catch (error) {
logger.error('Error processing inbound SMS webhook:', error);
// Don't send error response to Vonage here, as we already sent 200 OK
}
};
exports.handleMessageStatus = async (req, res) => {
const params = req.body;
logger.info('Received message status update:', JSON.stringify(params, null, 2));
// Vonage expects a 200 OK response quickly
res.status(200).end();
// Process the status update asynchronously
try {
// Basic validation for Messages API status webhook format
if (params.message_uuid && params.status && params.timestamp) {
const vonage_message_uuid = params.message_uuid;
const status = params.status.toLowerCase(); // Normalize status
const status_timestamp = params.timestamp;
const error_code = params.error?.code;
const error_message = params.error?.reason;
const updatedMessage = await Message.updateStatusByVonageUuid(
vonage_message_uuid,
status,
status_timestamp,
error_code,
error_message
);
if (updatedMessage) {
logger.info(`Updated status for message ${vonage_message_uuid} to ${status}.`);
// Optional: Add logic here based on status (e.g., update campaign status if all messages failed/delivered)
} else {
logger.warn(`Received status update for unknown message UUID: ${vonage_message_uuid}`);
}
} else {
logger.warn('Received malformed message status webhook:', params);
}
} catch (error) {
logger.error('Error processing message status webhook:', error);
// Don't send error response to Vonage here, as we already sent 200 OK
}
};