This guide provides a step-by-step walkthrough for building a production-ready SMS marketing campaign system using Node.js, the Express framework, and the Vonage Messages API. We'll cover everything from project setup and core sending/receiving logic to database integration, security considerations, and deployment.
By the end of this tutorial, you will have a functional application capable of:
- Managing a list of contacts (subscribers).
- Creating and sending bulk SMS marketing campaigns to subscribed contacts.
- Receiving incoming SMS messages, specifically handling ""STOP"" requests for unsubscribes.
- Tracking basic message statuses.
This system solves the common business need to engage customers via SMS for marketing promotions, alerts, or updates, while respecting user preferences for opting out.
Project Overview and Goals
We aim to build a robust backend service that powers SMS marketing efforts. This involves sending personalized or bulk messages via Vonage and handling inbound messages for critical functions like unsubscribes.
Technologies Used:
- Node.js: A JavaScript runtime environment ideal for building scalable network applications.
- Express: A minimal and flexible Node.js web application framework providing essential web features.
- 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.
System Architecture:
(Note: A Mermaid diagram illustrating the architecture was present in the original text but has been removed for standard Markdown compatibility.)
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.
- 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, and webhook handlers to process inbound messages and status updates.
1. Setting up the Project
Let's initialize our Node.js project and install necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir vonage-sms-campaigns cd vonage-sms-campaigns
-
Initialize Node.js Project: This creates a
package.json
file to manage project dependencies and scripts.npm init -y
-
Install Dependencies: We need Express for the web server, the Vonage SDK for SMS functionality, and
dotenv
for managing environment variables securely.npm install express @vonage/server-sdk dotenv
express
: Web framework.@vonage/server-sdk
: Official Vonage SDK for Node.js.dotenv
: Loads environment variables from a.env
file intoprocess.env
.
-
Project Structure: Create the following basic structure:
vonage-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 point
This 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.# .gitignore node_modules/ .env npm-debug.log* yarn-debug.log* yarn-error.log* private.key
-
Create
.env
file: Store sensitive credentials and configuration here. We'll populate this later.# .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 string
Crucially, ensure your
VONAGE_PRIVATE_KEY_PATH
points to the actual location where you save theprivate.key
file generated by Vonage (see Step 4). Addingprivate.key
to.gitignore
is vital for security. -
Basic Server Entry Point (
server.js
): This file loads environment variables and starts the Express app.// 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.// 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 application.
2. Implementing Core Functionality
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.// 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...catch
block 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).// 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: Sending many messages quickly can trigger Vonage rate limits (or carrier filtering). Adding a small delay (
delayMs
) between sends is a simple mitigation strategy. More robust solutions involve queues (Step 9). - Asynchronous Operations: Sending SMS is asynchronous.
async/await
is used to handle promises returned bysendSms
cleanly within the loop. - State Management: The in-memory
contacts
array is temporary. A database is essential for persistence.
- Rate Limiting: Sending many messages quickly can trigger Vonage rate limits (or carrier filtering). Adding a small delay (
3. Building a Complete API Layer
We'll create Express routes and controllers to manage contacts and trigger campaigns.
-
Contact Controller (
src/controllers/contact.controller.js
): Handles API requests related to contacts.// 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.' }); } 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.// 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
sendCampaign
is called withoutawait
in the controller. This immediately returns a202 Accepted
response 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.// 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:
curl -X POST http://localhost:3000/api/contacts \ -H ""Content-Type: application/json"" \ -d '{""number"": ""+14155550101""}' # Use a real test number format
Expected Response (201):
{""message"":""Contact added and subscribed."",""contact"":{""number"":""+14155550101"",""status"":""subscribed""}}
-
List Contacts:
curl http://localhost:3000/api/contacts
Expected Response (200):
[{""number"":""+14155550101"",""status"":""subscribed""}]
-
Send Campaign:
curl -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:
curl http://localhost:3000/api/campaigns/logs
Expected Response (200):
[{""contactNumber"":""+14155550101"",""campaignId"":""spring-promo"",""messageUuid"":""<some-uuid>"",""status"":""submitted"",""timestamp"":""...""}]
(Status might update later via webhooks)
-
4. Integrating with Necessary Third-Party Services (Vonage & ngrok)
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
.env
file:# .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
.env
file (use E.164 format, e.g.,+12015550123
):# .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.key
file that downloads. Place it in your project's root directory (or the path specified inVONAGE_PRIVATE_KEY_PATH
in.env
). Addprivate.key
to your.gitignore
file! - 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
.env
file:# .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:
ngrok 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.Forwarding 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_URL
Environment Variable: Update your.env
file with the ngrok URL so the application knows its public address if needed.# .env BASE_URL=https://<random-string>.ngrok-free.app
Restart your Node.js server (
node server.js
) after updating.env
for 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 Proper Error Handling, Logging, and Retry Mechanisms
Production systems need robust error handling and visibility.
-
Consistent Error Handling: Our basic error middleware in
src/app.js
catches unhandled errors. Enhance it for clarity:// 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.error
with a dedicated library like Pino (fast, JSON-based) or Winston.npm install pino pino-http
Update
src/app.js
:// 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-http
automatically 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
sendSms
to fail. Implement retries with exponential backoff for transient errors (e.g., 5xx errors from Vonage, network timeouts). Libraries likeasync-retry
can 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
vonageMessageUuid
in theMessage
table) 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. Creating a Database Schema and Data Layer
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:
npm install prisma --save-dev npm install @prisma/client
-
Initialize Prisma: This creates a
prisma
directory with aschema.prisma
file and updates.env
with aDATABASE_URL
.npx prisma init --datasource-provider postgresql # Or mysql, mongodb, sqlite
Update the
DATABASE_URL
in your.env
file with your actual database connection string.# .env DATABASE_URL=""postgresql://user:password@host:port/database_name""