code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

Building a Node.js Express SMS Marketing Sender with Vonage

A step-by-step guide to creating a backend service using Node.js, Express, Prisma, and the Vonage Messages API to send SMS marketing campaigns.

This guide provides a step-by-step walkthrough for building an application to send basic SMS marketing campaigns using Node.js, Express, and the Vonage Messages API. We'll cover project setup, core sending logic, API creation, database integration, error handling, security considerations, and deployment strategies.

By the end of this tutorial, you will have a functional backend service capable of accepting a list of recipients and a message, then sending that message via SMS to each recipient using Vonage.

Important Note: This guide builds a foundational service. Key production features like robust API authentication, comprehensive opt-out handling via webhooks, and delivery receipt processing are discussed but not fully implemented in the provided code. These are critical additions for a true production deployment.

Project Overview and Goals

Goal: To create a simple, robust backend service that can programmatically send SMS messages to a list of phone numbers for marketing campaign purposes.

Problem Solved: Manually sending SMS messages to multiple recipients is inefficient and error-prone. This service automates the process, enabling scalable SMS outreach via an API.

Technologies Used:

  • Node.js: A JavaScript runtime built on Chrome's V8 engine, ideal for building fast, scalable network applications.
  • Express: A minimal and flexible Node.js web application framework providing a robust set of features for web and mobile applications.
  • Vonage Messages API: A powerful API for sending and receiving messages across various channels, including SMS. We'll use it for its reliability and features. We specifically use the Application ID and Private Key authentication method for enhanced security.
  • Prisma: A modern database toolkit for Node.js and TypeScript, simplifying database access with an intuitive ORM. We'll use it with SQLite for local development simplicity, but it easily adapts to PostgreSQL or MySQL for production.
  • dotenv: A zero-dependency module that loads environment variables from a .env file into process.env.

System Architecture:

+-----------------+ +---------------------+ +----------------+ +-----------------+ | API Client |----->| Node.js/Express |----->| Prisma Client |----->| Database | | (e.g., Postman) | | API Layer | | (Data Access) | | (e.g., SQLite) | +-----------------+ +----------+----------+ +----------------+ +-----------------+ | | (Vonage SDK) v +-----------------+ | Vonage Messages | | API | +-----------------+ | v +-----------------+ | SMS Recipient | +-----------------+

Prerequisites:

  • Node.js and npm (or yarn): Installed on your development machine. Download Node.js
  • Vonage API Account: Sign up for free at Vonage. You'll receive some initial free credit.
  • Vonage Application: You'll need to create a Vonage application and generate a private key.
  • Vonage Phone Number: You need a Vonage virtual number capable of sending SMS. You can purchase one from the Vonage dashboard.
  • (Optional) ngrok: Useful for testing webhook implementations (like opt-out replies or delivery receipts described in Step 8) locally, though not required for the basic sending functionality built here. Download ngrok
  • (Optional) Postman or curl: For testing the API endpoints.

1. Setting Up the Project

Let's initialize the project, install dependencies, and set up the basic structure.

1. Create Project Directory: Open your terminal and create a new directory for your project.

bash
mkdir vonage-sms-campaigner
cd vonage-sms-campaigner

2. Initialize Node.js Project: This creates a package.json file.

bash
npm init -y

3. Install Dependencies:

  • express: Web framework.
  • @vonage/server-sdk: The official Vonage Node.js SDK (we'll use the Messages API part).
  • prisma: The Prisma CLI for migrations and database management.
  • @prisma/client: The Prisma database client.
  • dotenv: For managing environment variables.
bash
npm install express @vonage/server-sdk @prisma/client dotenv
npm install prisma --save-dev

4. Initialize Prisma: Set up Prisma with SQLite for simplicity in development.

bash
npx prisma init --datasource-provider sqlite

This creates:

  • A prisma directory with a schema.prisma file.
  • A .env file (if it doesn't exist) with a DATABASE_URL variable.

5. Define Project Structure: Create the following directories and files:

text
vonage-sms-campaigner/
├── prisma/
│   ├── schema.prisma
│   └── dev.db        # (Will be created by Prisma)
├── src/
│   ├── controllers/
│   │   └── campaignController.js
│   ├── services/
│   │   ├── vonageService.js
│   │   └── campaignService.js
│   ├── middleware/    # (Add this for Step 7)
│   │   └── rateLimiter.js
│   ├── routes/
│   │   └── campaignRoutes.js
│   └── server.js
├── .env              # For environment variables (DO NOT COMMIT)
├── .gitignore
└── package.json

6. Configure .gitignore: Ensure sensitive files and unnecessary directories aren't committed to version control. Create or edit .gitignore:

text
# Dependencies
node_modules/

# Prisma
prisma/dev.db
prisma/dev.db-journal

# Environment Variables
.env*

# Build Outputs (if applicable)
dist/
build/

# OS files
.DS_Store
Thumbs.db

# Vonage Private Key
private.key
*.key

7. Set up Basic Express Server (src/server.js):

javascript
// src/server.js
require('dotenv').config(); // Load .env variables first
const express = require('express');
const campaignRoutes = require('./routes/campaignRoutes');

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware
app.use(express.json()); // Parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies

// Basic Root Route
app.get('/', (req, res) => {
  res.send('Vonage SMS Campaigner API is running!');
});

// API Routes
app.use('/api/campaigns', campaignRoutes);

// Simple Error Handling Middleware (Will be enhanced in Step 5)
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send({ error: 'Something went wrong!' });
});


app.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
});

8. Configure Environment Variables (.env): Create the .env file in the project root. We'll add Vonage credentials later. For now, set the database URL (Prisma already added this) and optionally the port. Replace the placeholder values after completing Step 4.

dotenv
# .env
# Database Configuration (Prisma default)
DATABASE_URL="file:./prisma/dev.db"

# Server Port
PORT=3000

# Vonage Credentials (Add these later in Step 4)
VONAGE_APPLICATION_ID=YOUR_VONAGE_APP_ID_FROM_DASHBOARD # Replace with actual ID
VONAGE_PRIVATE_KEY_PATH=./private.key                   # Path relative to project root where you save the key
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER_E164           # Replace with actual Vonage number (e.g., +12015550123)

Why .env? Storing configuration like API keys and database URLs in environment variables keeps sensitive data out of your codebase and makes it easy to configure different deployment environments.


2. Implementing Core Functionality

We need services to interact with Vonage and manage the campaign sending logic.

1. Vonage Service (src/services/vonageService.js): This service initializes the Vonage SDK and provides a function to send a single SMS.

javascript
// src/services/vonageService.js
const { Vonage } = require('@vonage/server-sdk');
const path = require('path');
const fs = require('fs'); // Needed to check if key file exists

// Resolve path from the current working directory where Node is started
const privateKeyPath = process.env.VONAGE_PRIVATE_KEY_PATH
    ? path.resolve(process.cwd(), process.env.VONAGE_PRIVATE_KEY_PATH)
    : null;

if (!privateKeyPath || !fs.existsSync(privateKeyPath)) {
    console.error(`Error: Vonage private key file not found at path specified by VONAGE_PRIVATE_KEY_PATH: ${process.env.VONAGE_PRIVATE_KEY_PATH}`);
    // Optionally exit or handle appropriately
    // process.exit(1);
}

const vonage = new Vonage({
  applicationId: process.env.VONAGE_APPLICATION_ID,
  privateKey: privateKeyPath, // Use the resolved absolute path
});

const fromNumber = process.env.VONAGE_NUMBER;

/**
 * Sends a single SMS message using the Vonage Messages API.
 * @param {string} to - The recipient's phone number (E.164 format recommended).
 * @param {string} text - The message content.
 * @returns {Promise<object>} - Promise resolving with the Vonage API response.
 * @throws {Error} - Throws an error if sending fails.
 */
async function sendSms(to, text) {
  if (!to || !text) {
    throw new Error('Recipient phone number and message text are required.');
  }
  if (!process.env.VONAGE_APPLICATION_ID || !privateKeyPath || !fromNumber) {
      throw new Error('Vonage credentials (Application ID, Private Key Path, Number) are missing or invalid in .env');
  }

  console.log(`Attempting to send SMS from ${fromNumber} to ${to}`);

  try {
    // Use standard double quotes for JSON string values
    const resp = await vonage.messages.send({
      message_type: ""text"",
      text: text,
      to: to,
      from: fromNumber,
      channel: ""sms""
    });
    console.log(`Message sent successfully to ${to}: ${resp.message_uuid}`);
    return resp; // Contains message_uuid
  } catch (err) {
    // Log more detailed Vonage error if available
    const errorMessage = err.response?.data?.title || err.response?.data?.detail || err.message || 'Unknown Vonage error';
    const errorStatus = err.response?.status || 'N/A';
    console.error(`Error sending SMS to ${to}: Status ${errorStatus} - ${errorMessage}`);
    // Rethrow a more informative error
    throw new Error(`Failed to send SMS to ${to}. Vonage API Error: ${errorMessage} (Status: ${errorStatus})`);
  }
}

module.exports = { sendSms };

Why Application ID/Private Key? This authentication method is generally more secure than API Key/Secret for server-to-server communication, as the private key never leaves your server. The SDK handles the JWT generation needed for authentication.

2. Campaign Service (src/services/campaignService.js): This service orchestrates the sending process, fetching recipients and iterating through them to send messages via the vonageService.

javascript
// src/services/campaignService.js
const { PrismaClient } = require('@prisma/client');
const { sendSms } = require('./vonageService');

const prisma = new PrismaClient();

/**
 * Sends an SMS campaign to a list of recipients.
 * @param {string[]} recipientNumbers - Array of phone numbers (E.164 format recommended).
 * @param {string} message - The message text to send.
 * @returns {Promise<object>} - Promise resolving with results (successes, failures).
 */
async function sendCampaign(recipientNumbers, message) {
  if (!Array.isArray(recipientNumbers) || recipientNumbers.length === 0) {
    throw new Error('Recipient numbers must be a non-empty array.');
  }
  if (!message) {
    throw new Error('Message text cannot be empty.');
  }

  const results = {
    successes: [],
    failures: [],
  };

  // Sequentially send messages to avoid overwhelming the API rate limits initially
  // For higher volume, consider batching or parallel requests with delays & proper rate limiting
  for (const number of recipientNumbers) {
    try {
      // Basic E.164 format check (can be improved with regex)
      if (!/^\+[1-9]\d{1,14}$/.test(number)) {
          throw new Error(`Invalid phone number format: ${number}. Must be E.164 (e.g., +14155552671).`);
      }
      const response = await sendSms(number, message);
      results.successes.push({ number: number, message_uuid: response.message_uuid });
      // Optional: Add a small delay between sends if hitting rate limits
      // await new Promise(resolve => setTimeout(resolve, 200)); // 200ms delay
    } catch (error) {
      console.error(`Failed processing number ${number}: ${error.message}`);
      results.failures.push({ number: number, error: error.message });
    }
  }

  console.log(`Campaign finished. Success: ${results.successes.length}, Failures: ${results.failures.length}`);
  return results;
}

// --- Example of fetching recipients from DB (Requires Schema Update - See Step 6) ---
/**
 * Sends an SMS campaign to recipients belonging to a specific group.
 * @param {number} recipientGroupId - The ID of the RecipientGroup.
 * @param {string} message - The message text to send.
 * @returns {Promise<object>} - Promise resolving with results (successes, failures).
 */
async function sendCampaignToGroup(recipientGroupId, message) {
    const groupWithRecipients = await prisma.recipientGroup.findUnique({
        where: { id: recipientGroupId },
        include: {
            recipients: { // Only include recipients who haven't opted out
                where: { isOptedOut: { not: true } } // Requires 'isOptedOut' field in schema (Step 8)
            }
        },
    });

    if (!groupWithRecipients) {
        throw new Error(`Recipient group with ID ${recipientGroupId} not found.`);
    }

    const recipientNumbers = groupWithRecipients.recipients.map(r => r.phoneNumber);

    if (recipientNumbers.length === 0) {
        console.warn(`Recipient group ${recipientGroupId} has no active recipients.`);
        return { successes: [], failures: [] };
    }

    return sendCampaign(recipientNumbers, message);
}


module.exports = { sendCampaign, sendCampaignToGroup }; // Export both functions

Design Choice: Sending messages sequentially is simpler to start with. For large campaigns, you'd implement parallel sending with rate limiting awareness (e.g., using libraries like p-limit or async.queue) and potentially handle Vonage's asynchronous responses via status webhooks for better tracking (See Step 8).


3. Building a Complete API Layer

We need an endpoint to trigger the campaign sending process.

1. Campaign Controller (src/controllers/campaignController.js): Handles incoming API requests, validates input, calls the appropriate service, and sends back the response.

javascript
// src/controllers/campaignController.js
const { sendCampaign, sendCampaignToGroup } = require('../services/campaignService');

/**
 * POST /api/campaigns/send
 * Sends a campaign to a provided list of numbers.
 * Request body example: { "recipientNumbers": ["+15551234567", "+447700900123"], "message": "Hello!" }
 */
async function handleSendCampaign(req, res, next) {
  const { recipientNumbers, message } = req.body;

  // Basic Validation
  if (!Array.isArray(recipientNumbers) || recipientNumbers.length === 0 || typeof message !== 'string' || message.trim() === '') {
    return res.status(400).json({
      error: 'Invalid input. Requires "recipientNumbers" (non-empty array of strings) and "message" (non-empty string).',
    });
  }

  // Further validation (e.g., check number format within the service or here)

  try {
    const results = await sendCampaign(recipientNumbers, message);
    res.status(200).json({
      message: 'Campaign processing completed.', // Changed from "initiated" as it's sequential for now
      results: results,
    });
  } catch (error) {
    console.error('Error in handleSendCampaign:', error);
    // Pass to the centralized error handler
    next(error);
  }
}

/**
 * POST /api/campaigns/send-group
 * Sends a campaign to recipients of a specific group ID.
 * Request body example: { "recipientGroupId": 1, "message": "Group message!" }
 */
async function handleSendCampaignToGroup(req, res, next) {
    const { recipientGroupId, message } = req.body;

    // Basic Validation
    const groupId = parseInt(recipientGroupId, 10);
    if (isNaN(groupId) || typeof message !== 'string' || message.trim() === '') {
        return res.status(400).json({
            error: 'Invalid input. Requires "recipientGroupId" (number) and "message" (non-empty string).',
        });
    }

    try {
        const results = await sendCampaignToGroup(groupId, message);
        res.status(200).json({
            message: `Campaign processing completed for group ${groupId}.`, // Changed from "initiated"
            results: results,
        });
    } catch (error) {
        console.error('Error in handleSendCampaignToGroup:', error);
        // Handle specific errors like "group not found" differently if needed
        if (error.message.includes('not found')) {
             return res.status(404).json({ error: error.message });
        }
        // Pass other errors to the centralized handler
        next(error);
    }
}


module.exports = { handleSendCampaign, handleSendCampaignToGroup };

2. Campaign Routes (src/routes/campaignRoutes.js): Defines the API endpoints and maps them to controller functions. Note: These endpoints currently lack authentication (See Step 7).

javascript
// src/routes/campaignRoutes.js
const express = require('express');
const { handleSendCampaign, handleSendCampaignToGroup } = require('../controllers/campaignController');
// Rate limiting middleware will be introduced and implemented in Step 7
const rateLimiter = require('../middleware/rateLimiter');

const router = express.Router();

// WARNING: These endpoints are currently PUBLIC. Add authentication middleware for production.
// Apply rate limiting (introduced in Step 7) to campaign sending endpoints
router.post('/send', rateLimiter, handleSendCampaign);
router.post('/send-group', rateLimiter, handleSendCampaignToGroup);

// Optional: Endpoint to check basic Vonage configuration status
router.get('/status', (req, res) => {
    // Basic check: Ensure Vonage credentials seem present in environment
    if (process.env.VONAGE_APPLICATION_ID && process.env.VONAGE_PRIVATE_KEY_PATH && process.env.VONAGE_NUMBER) {
        // Further check could involve ensuring the private key file exists
        res.status(200).json({ status: 'Vonage credentials seem loaded from environment variables.'});
    } else {
        res.status(500).json({ status: 'Error: One or more required Vonage credentials missing in environment variables.' });
    }
});


module.exports = router;

3. Testing the Endpoint (Example with curl): (Before running, ensure you've completed Step 4 for Vonage setup and Step 6 for Database setup if using /send-group)

Start your server: node src/server.js

  • Test /send endpoint:

    bash
    curl -X POST http://localhost:3000/api/campaigns/send \
         -H "Content-Type: application/json" \
         -d '{
               "recipientNumbers": ["+15551234567", "+447700900123"],
               "message": "Hello from our Vonage Campaigner!"
             }'
    # Replace numbers with your actual test numbers (whitelisted on trial accounts)

    Expected Response (Success):

    json
    {
        "message": "Campaign processing completed.",
        "results": {
            "successes": [
                { "number": "+15551234567", "message_uuid": "..." },
                { "number": "+447700900123", "message_uuid": "..." }
            ],
            "failures": []
        }
    }
  • Test /send-group endpoint (after DB setup in Step 6):

    bash
     curl -X POST http://localhost:3000/api/campaigns/send-group \
          -H "Content-Type: application/json" \
          -d '{
                "recipientGroupId": 1,
                "message": "Special offer for our valued group members!"
              }'

    Expected Response (Success):

    json
    {
        "message": "Campaign processing completed for group 1.",
        "results": {
            "successes": [ /* ... list of successful sends ... */ ],
            "failures": []
        }
    }

4. Integrating with Vonage

This involves setting up your Vonage account, creating an application, and configuring your .env file.

1. Sign Up/Log In to Vonage: Go to the Vonage API Dashboard.

2. Create a Vonage Application:

  • Navigate to ""Applications"" -> ""Create a new application"".
  • Give it a name (e.g., ""Node SMS Campaigner"").
  • Click ""Generate public and private key"". Immediately save the private.key file that downloads. Place this file in the root directory of your project (or the location specified by VONAGE_PRIVATE_KEY_PATH in your .env).
  • Enable ""Messages"" capability.
  • For the Inbound URL and Status URL, you need publicly accessible endpoints if you want to handle replies (opt-outs) or delivery receipts (See Step 8).
    • For local testing (if implementing webhooks):
      • Run ngrok http 3000 (assuming your server runs on port 3000).
      • Copy the HTTPS forwarding URL provided by ngrok (e.g., https://random-string.ngrok.io).
      • Enter YOUR_NGROK_URL/webhooks/inbound for Inbound URL (e.g., https://random-string.ngrok.io/webhooks/inbound).
      • Enter YOUR_NGROK_URL/webhooks/status for Status URL (e.g., https://random-string.ngrok.io/webhooks/status).
      • (Note: The webhook handlers themselves are not implemented in this guide's code).
    • For production: Use your deployed application's webhook URLs.
  • Click ""Generate new application"".

3. Note Your Application ID: On the application details page, copy the Application ID.

4. Link a Vonage Number:

  • Go to ""Numbers"" -> ""Your numbers"".
  • If you don't have one, click ""Buy numbers"", find one with SMS capability in your desired country, and purchase it.
  • Go back to your Application details (""Applications"" -> Your App).
  • Under ""Link numbers"", find your virtual number and click ""Link"".

5. Configure .env: Open your .env file and fill in the Vonage details with the values you obtained:

dotenv
# .env (Update these lines with your actual values)
# ... other variables ...

VONAGE_APPLICATION_ID=PASTE_YOUR_APPLICATION_ID_HERE # Replace with the ID from Vonage Dashboard
VONAGE_PRIVATE_KEY_PATH=./private.key                # Ensure this path is correct relative to project root
VONAGE_NUMBER=PASTE_YOUR_VONAGE_NUMBER_HERE          # Replace with your linked Vonage number in E.164 format (e.g., +14155552671)
  • VONAGE_APPLICATION_ID: The ID copied from the Vonage dashboard.
  • VONAGE_PRIVATE_KEY_PATH: The path relative to your project root where you saved the private.key file. ./private.key assumes it's directly in the root. The code (vonageService.js) resolves this relative to the current working directory.
  • VONAGE_NUMBER: The Vonage virtual number (must be in E.164 format, e.g., +14155552671) you linked to the application.

Security: NEVER commit your private.key file or your .env file containing secrets to Git. Ensure .gitignore includes them (add *.key and .env*).


5. Implementing Error Handling and Logging

Robust error handling and logging are crucial.

1. Consistent Error Handling:

  • Services (vonageService, campaignService) use try...catch and throw errors, sometimes re-throwing more specific ones.
  • The controller (campaignController.js) catches errors from services and either returns specific client errors (400, 404) or passes server errors (next(error)) to the central Express error handler.
  • Replace the basic error middleware in server.js with a more structured one.

Example: Enhance server.js Error Handler:

javascript
// src/server.js (Replace the simple error handler with this)

// Remove or comment out the previous simple error handler:
// app.use((err, req, res, next) => {
//   console.error(err.stack);
//   res.status(500).send({ error: 'Something went wrong!' });
// });

// More structured error handler (Place this AFTER your routes)
app.use((err, req, res, next) => {
  // Log the error internally (replace console.error with a proper logger like Winston/Pino in production)
  console.error(`[ERROR] ${req.method} ${req.path} - ${err.message}`);
  // Log stack trace only in development or if needed for detailed debugging
  if (process.env.NODE_ENV !== 'production') {
      console.error(err.stack);
  }

  // Determine status code - check for custom status or default to 500
  // Use err.status for compatibility with some error libraries, fallback to statusCode
  const statusCode = err.status || err.statusCode || 500;

  // Send structured error response to client - avoid leaking sensitive details
  res.status(statusCode).json({
    status: 'error',
    // Only include specific error message in non-production environments for debugging
    // In production, use a generic message unless it's a specific client error (4xx) you want to expose
    message: (statusCode < 500 || process.env.NODE_ENV !== 'production') ? err.message : 'An internal server error occurred.',
  });
});

2. Logging:

  • Currently using console.log and console.error. This is insufficient for production monitoring and debugging.
  • Recommendation: Integrate a structured logging library like Pino (very performant) or Winston.
    • Configure log levels (info, warn, error, debug).
    • Log to standard output (for containerized environments) or files. Consider sending logs to a centralized log management service (e.g., Datadog, Logtail, Sentry, ELK Stack).
    • Include timestamps, request IDs (using middleware like express-request-id), and relevant context (e.g., user ID if authenticated, campaign ID).
    • Log key events: Application start, API requests (incoming/outgoing), campaign start/finish, individual SMS send attempts (success/failure with IDs), errors encountered, database operations.

3. Retry Mechanisms:

  • Network issues or temporary Vonage API rate limits can cause transient failures.

  • Simple Retry (Conceptual): Wrap the vonageService.sendSms call within the campaignService loop with delays (exponential backoff is recommended).

    javascript
    // Inside campaignService.js - Conceptual Retry Logic (Not fully implemented here)
    async function sendSmsWithRetry(number, message, maxRetries = 2) {
        let attempt = 0;
        while (attempt <= maxRetries) {
            try {
                return await sendSms(number, message); // Attempt sending
            } catch (error) {
                attempt++;
                // Check if the error is potentially retryable (e.g., network error, 429 Too Many Requests)
                const isRetryable = error.message.includes('429') || error.message.includes('network'); // Basic check
    
                if (!isRetryable || attempt > maxRetries) {
                    console.error(`Final attempt failed for ${number} after ${attempt} tries or error not retryable.`);
                    throw error; // Rethrow original error
                }
                // Exponential backoff: wait 1s, 2s, 4s...
                const delay = Math.pow(2, attempt - 1) * 1000 * (Math.random() + 0.5); // Add jitter
                console.warn(`Attempt ${attempt} failed for ${number}. Retrying in ${delay.toFixed(0)}ms... Error: ${error.message}`);
                await new Promise(resolve => setTimeout(resolve, delay));
            }
        }
    }
    // You would then modify sendCampaign to call sendSmsWithRetry instead of sendSms directly.
  • Robust Retries: Use libraries like async-retry or leverage a background job queue system (Step 9) which often have built-in retry capabilities. For delivery confirmation, rely on Delivery Receipts via webhooks (Step 8).


6. Creating a Database Schema and Data Layer

We'll use Prisma to define a schema for recipients and groups.

1. Define Prisma Schema (prisma/schema.prisma): Update the schema file to include models for Recipient and RecipientGroup. Add an isOptedOut flag for compliance (See Step 8).

prisma
// prisma/schema.prisma

generator client {
  provider = ""prisma-client-js""
}

datasource db {
  provider = ""sqlite"" // Or ""postgresql"", ""mysql""
  url      = env(""DATABASE_URL"")
}

model Recipient {
  id             Int      @id @default(autoincrement())
  phoneNumber    String   @unique // E.164 format REQUIRED
  firstName      String?
  lastName       String?
  isOptedOut     Boolean  @default(false) // For handling STOP replies
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  // Relation to RecipientGroup (a recipient can belong to one group)
  recipientGroup   RecipientGroup? @relation(fields: [recipientGroupId], references: [id])
  recipientGroupId Int?

  @@index([recipientGroupId]) // Index for faster group lookups
}

model RecipientGroup {
  id        Int      @id @default(autoincrement())
  name      String   @unique // e.g., ""Newsletter Subscribers"", ""VIP Customers""
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relation to multiple recipients
  recipients Recipient[]
}
  • Recipient: Stores individual phone numbers (enforce E.164 format), optional names, and the crucial isOptedOut flag.
  • RecipientGroup: Allows grouping recipients.

2. Apply Migrations: Generate and apply the SQL migration to create/update the database tables.

bash
# Create the migration files based on schema changes
# Prisma will prompt you for a name, e.g., add_recipient_models_and_optout
npx prisma migrate dev

# This command will also apply the migration to your dev database (dev.db)

Prisma creates an SQL migration file in prisma/migrations/ and updates your dev.db.

3. (Optional) Seed the Database: Create sample data for testing.

  • Create prisma/seed.js:

    javascript
    // prisma/seed.js
    const { PrismaClient } = require('@prisma/client');
    const prisma = new PrismaClient();
    
    async function main() {
      console.log('Start seeding ...');
    
      // Create recipient groups
      const group1 = await prisma.recipientGroup.upsert({
        where: { name: 'Early Adopters' },
        update: {},
        create: { name: 'Early Adopters' },
      });
      const group2 = await prisma.recipientGroup.upsert({
        where: { name: 'General Newsletter' },
        update: {},
        create: { name: 'General Newsletter' },
      });
      console.log(`Created/found groups: ${group1.name} (ID: ${group1.id}), ${group2.name} (ID: ${group2.id})`);
    
      // Create recipients, linking some to groups
      await prisma.recipient.upsert({
        where: { phoneNumber: '+15550001111' }, // Use test numbers
        update: {},
        create: {
          phoneNumber: '+15550001111',
          firstName: 'Alice',
          recipientGroupId: group1.id,
        },
      });
       await prisma.recipient.upsert({
        where: { phoneNumber: '+447700900222' }, // Use test numbers
        update: {},
        create: {
          phoneNumber: '+447700900222',
          firstName: 'Bob',
          lastName: 'Smith',
          recipientGroupId: group1.id,
        },
      });
       await prisma.recipient.upsert({
        where: { phoneNumber: '+15559998888' }, // Use test numbers
        update: { isOptedOut: true }, // Example of an opted-out user
        create: {
          phoneNumber: '+15559998888',
          firstName: 'Charlie',
          isOptedOut: true, // Ensure they start opted out
          recipientGroupId: group2.id,
        },
      });
       await prisma.recipient.upsert({
        where: { phoneNumber: '+15557776666' }, // Use test numbers
        update: {},
        create: {
          phoneNumber: '+15557776666',
          firstName: 'Diana', // No group initially
        },
      });
    
      console.log('Seeding finished.');
    }
    
    main()
      .catch((e) => {
        console.error(e);
        process.exit(1);
      })
      .finally(async () => {
        await prisma.$disconnect();
      });
  • Add the seed script command to package.json:

    json
    // package.json
    {
      // ... other package.json content ...
      ""prisma"": {
        ""seed"": ""node prisma/seed.js""
      },
      ""scripts"": {
        ""start"": ""node src/server.js"",
        ""dev"": ""nodemon src/server.js"", // If using nodemon
        ""migrate:dev"": ""npx prisma migrate dev"",
        ""db:seed"": ""npx prisma db seed"" // Add this line
        // ... other scripts ...
      }
      // ... rest of package.json ...
    }
  • Run the seed script:

    bash
    npx prisma db seed

    This executes the prisma/seed.js file.

Frequently Asked Questions

How to send SMS marketing campaigns with Node.js?

Use Node.js with Express and the Vonage Messages API to build an application that can send SMS messages programmatically. This involves setting up routes to handle incoming requests, services to interact with the Vonage API, and database integration to manage recipients. The provided guide offers a step-by-step walkthrough for building this type of application.

What is the Vonage Messages API used for?

The Vonage Messages API is a service that allows developers to send and receive messages across various channels, including SMS. It's used in this project to send SMS messages for marketing campaigns. It offers reliability and various features useful for developers.

Why does this project use Application ID/Private Key for Vonage?

This authentication method is preferred for server-side applications because the private key never leaves your server, offering enhanced security over API Key/Secret methods. The Vonage SDK manages JWT generation for authentication, simplifying implementation.

When should I implement webhook handling with Vonage?

For production SMS marketing systems, webhooks are crucial for features like opt-out management via user replies and delivery receipt processing. Although discussed, the provided code doesn't fully implement webhooks. For basic campaign sending, it's not strictly required in the initial stages.

Can I use a different database with Prisma?

Yes, Prisma supports various databases like PostgreSQL and MySQL for production deployments. This guide uses SQLite for simplicity during local development, but you can easily adapt the schema and configuration to a different database provider.

How to set up a Node.js Express project for SMS marketing?

Start by initializing a Node.js project (`npm init -y`), install dependencies (`express`, `@vonage/server-sdk`, `prisma`, `@prisma/client`, `dotenv`), set up Prisma (`npx prisma init --datasource-provider sqlite`), define your project structure (folders for controllers, services, routes), and configure your .gitignore file to exclude sensitive data.

What is the purpose of the dotenv library?

Dotenv loads environment variables from a `.env` file into `process.env`. This is essential for storing sensitive data, such as API keys and database URLs, outside of your codebase and simplifying configuration across different environments.

How to structure a Node.js project for sending SMS messages?

The recommended project structure includes directories for controllers, services, and routes to separate concerns. The `controllers` handle API requests and validation, `services` encapsulate logic for Vonage API interactions, and `routes` define the API endpoints. The `server.js` file sets up the Express app and serves as the entry point.

What is Prisma used for in this project?

Prisma is used as a database toolkit and ORM. It simplifies database operations with its client and schema management capabilities. The project uses SQLite for development, but Prisma can switch to other production databases like PostgreSQL and MySQL easily.

How to send an SMS message with the Vonage SDK?

The `vonageService.js` file provides a `sendSms` function. This function initializes the Vonage SDK with your credentials and then uses the `messages.send` method to send the SMS message to the specified recipient number. Ensure your .env file is configured correctly with Vonage credentials.

How to handle errors when sending SMS messages?

The guide emphasizes using `try...catch` blocks in services and controllers to handle errors robustly. A centralized error handler in `server.js` catches any unhandled errors and sends a structured error response to the client, avoiding leaking sensitive information.

How to manage recipients for SMS campaigns?

The project uses a database with Prisma to store recipient information. You can create recipient groups and add individual recipients, including optional details like names. The schema includes an 'isOptedOut' field to help manage opt-outs, essential for compliance.

Why is E.164 format important for phone numbers?

E.164 is an international standard format for phone numbers, ensuring consistent formatting for global SMS delivery. The project emphasizes using this format (e.g., +14155552671) throughout the application, including the database schema and validation.

What are the prerequisites for building this SMS campaigner?

You'll need Node.js and npm (or yarn) installed, a Vonage API account with a Vonage application created, and a Vonage virtual number linked to your application. For local testing with webhooks, ngrok is recommended. Postman or curl are helpful for testing the API endpoints.