code examples

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

How to Build SMS Scheduling & Reminders with Vonage, Node.js, and Express in 2025

Complete guide to building production-ready SMS scheduling with Vonage Messages API, Node.js, and Express. Includes job queues, webhooks, retry logic, and database integration.

Build a Robust SMS Scheduler with Node.js, Express, and Vonage

Learn how to build a production-ready SMS scheduler using Node.js, Express, and the Vonage Messages API. This comprehensive guide covers everything from accepting scheduling requests through a REST API to implementing job queues, handling delivery webhooks, and managing retry logic for reliable SMS delivery.

Whether you're building appointment reminders, payment alerts, or marketing campaigns, this tutorial shows you how to schedule and send SMS messages programmatically. You'll implement a complete Node.js SMS scheduling system using the Vonage API, with patterns applicable to any messaging provider. We'll cover both development setup with node-cron and production-ready alternatives like BullMQ and Agenda for enterprise deployments.

Technologies Used:

  • Node.js: JavaScript runtime environment (v22 LTS recommended for production, supported until April 2027; v20 LTS supported until April 2026).
  • Express: Minimalist web framework for Node.js (v4.x stable, widely adopted for REST APIs).
  • Vonage Messages API: Cloud communications platform for SMS, MMS, and messaging channels (formerly Nexmo, acquired by Ericsson 2021).
  • @vonage/server-sdk: Official Node.js SDK for Vonage API (v3.x supports Messages API v1).
  • node-cron: Simple cron-based task scheduler (suitable for single-instance deployments only).

SMS Scheduling Context (2024 – 2025):

  • Global A2P SMS market valued at $70+ billion, with delivery rates of 95 – 98% for tier-1 providers.
  • Scheduled SMS use cases: appointment reminders (healthcare, services), marketing campaigns (time-zone targeting), payment reminders (billing cycles), event notifications (webinars, deadlines).
  • Average SMS delivery time: 3 – 10 seconds domestically, 5 – 30 seconds internationally.
  • Standard SMS format: 160 characters (GSM-7 encoding) or 70 characters (UCS-2/Unicode); longer messages segment into 153-character chunks (GSM-7) or 67-character chunks (Unicode) (source: SMS encoding standards).

Production Architecture Considerations:

  • Job Queue Systems: BullMQ (Redis-based, 50K+ GitHub stars), Agenda (MongoDB-based, 9K+ stars), AWS SQS, Google Cloud Tasks, Azure Service Bus provide persistent queues, retry logic, and horizontal scaling (source: BullMQ documentation).
  • Database Requirements: PostgreSQL (with FOR UPDATE SKIP LOCKED for job locking), MongoDB (distributed job collections), or Redis (with persistence) required for production deployments.
  • Webhook Security: HMAC signature validation mandatory for all Vonage webhooks (prevents spoofing, ensures message integrity) (source: Vonage webhook security guide).
  • Delivery Guarantees: At-least-once delivery patterns with idempotency keys prevent duplicate sends during retries.
  • Time Zone Handling: Store all timestamps in UTC (ISO 8601 format), convert to user time zones for display only.

Important Note on Production Readiness: While this guide covers many concepts essential for a production system (error handling, security, database considerations, monitoring), the core code example uses an in-memory data store and the basic node-cron scheduler for simplicity. A true production deployment requires replacing the in-memory store with a persistent database and potentially using a more advanced job queue system (like BullMQ or Agenda), as discussed in detail within the guide.

Quick Reference

FeatureDetails
FrameworkNode.js v20/v22 LTS + Express v4.x
API ServiceVonage Messages API (formerly Nexmo)
Scheduler (Dev)node-cron (single-instance only)
Scheduler (Production)BullMQ, Agenda, AWS SQS, Google Cloud Tasks
Database OptionsPostgreSQL (recommended), MongoDB, Redis
SMS Format160 chars (GSM-7) or 70 chars (Unicode)
Delivery Time3 – 10 seconds (domestic), 5 – 30 seconds (international)
SecurityHMAC signature validation, rate limiting, input validation
Primary Use CasesAppointment reminders, payment alerts, event notifications
Development Toolngrok for webhook testing (development only)

System Architecture:

(Note: The original diagram block (Mermaid) has been removed as it is non-standard Markdown.)

Prerequisites:

  • Vonage API Account: Sign up at Vonage Dashboard (free trial includes €2 credit, ~200 SMS to US numbers).
  • Node.js: v20.x LTS or v22.x LTS recommended (Download Node.js). Verify your installation: node --version and npm --version.
  • Development Tools:
    • ngrok: For webhook testing during development (Download ngrok). Development only – not for production. Production requires stable HTTPS endpoint (AWS ALB, Cloudflare, etc.).
    • Code Editor: VS Code, WebStorm, or similar with JavaScript/TypeScript support.
    • API Testing Tool: Postman, Insomnia, or curl for endpoint testing.
  • Technical Knowledge:
    • JavaScript ES6+ (async/await, promises, modules).
    • Node.js fundamentals (event loop, modules, npm).
    • REST API concepts (HTTP methods, status codes, JSON).
    • Basic understanding of cron syntax (for node-cron configuration).
  • (Optional but Recommended) Vonage CLI: npm install -g @vonage/cli – simplifies application creation, number management, and webhook configuration.

Final Outcome:

By the end of this guide, you'll have a functional Node.js application that:

  1. Exposes an API endpoint (/schedule) to accept SMS scheduling requests.
  2. Stores scheduled message details (using an in-memory store in the example code, with guidance on persistent storage).
  3. Uses a basic scheduler (node-cron) to send messages via Vonage, with discussion on production alternatives.
  4. Includes basic configuration, error handling, logging, and security considerations.
  5. Provides conceptual handling for inbound SMS and delivery status webhooks.

GitHub Repository:

Find a complete working example of the code in this guide here: https://github.com/vonage-community/node-sms-scheduler-guide

Setting Up Your Node.js SMS Scheduler Project

Initialize your Node.js project and install the necessary dependencies.

  1. Create Project Directory: Open your terminal and create a new directory for your project, then navigate into it.

    bash
    mkdir node-sms-scheduler
    cd node-sms-scheduler
  2. Initialize Node.js Project: Create a package.json file to manage dependencies and project metadata.

    bash
    npm init -y
  3. Install Dependencies: Install Express for the web server, the Vonage SDK for sending SMS, node-cron for scheduling, dotenv for managing environment variables, uuid for generating unique job IDs, and helmet / express-rate-limit for basic security.

    bash
    npm install express @vonage/server-sdk node-cron dotenv uuid helmet express-rate-limit express-validator
    # Optional for concrete retry example:
    # npm install async-retry
    • express: Web framework for Node.js.
    • @vonage/server-sdk: Official Vonage SDK for Node.js.
    • node-cron: Task scheduler based on cron syntax. (Simple, but see limitations in Section 12).
    • dotenv: Loads environment variables from a .env file.
    • uuid: Generates unique identifiers.
    • helmet: Helps secure Express apps by setting various HTTP headers.
    • express-rate-limit: Basic rate limiting middleware.
    • express-validator: Middleware for request data validation.
    • async-retry: (Optional) Useful library for implementing retry logic.
  4. Create Project Structure: Set up a basic directory structure.

    bash
    mkdir src
    touch src/server.js
    touch .env
    touch .gitignore
    • src/: Contains your application source code.
    • src/server.js: The main entry point for your Express application and scheduler.
    • .env: Stores sensitive credentials and configuration. Never commit this file to version control.
    • .gitignore: Specifies files Git should ignore.
  5. Configure .gitignore: Add the following lines to your .gitignore file:

    text
    # Dependencies
    node_modules/
    
    # Environment variables
    .env
    *.env.*
    !.env.example
    
    # Keys
    *.key
    
    # Logs
    logs
    *.log
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    
    # Optional editor directories
    .idea
    .vscode
    *.swp
  6. Set up Environment Variables (.env): Open the .env file and add the following placeholders. Fill these in during the Vonage configuration step.

    dotenv
    # Vonage API Credentials
    VONAGE_API_KEY=YOUR_VONAGE_API_KEY
    VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
    VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
    VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root
    
    # Vonage Number
    VONAGE_FROM_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER
    
    # Application Settings
    PORT=3000
    SCHEDULER_INTERVAL_SECONDS=60 # How often the scheduler checks for jobs
    
    # Webhook Security (Example – Replace with actual secret or use signature verification)
    # INBOUND_WEBHOOK_SECRET=some-unguessable-string
    
    # Database (Required for production – Example for PostgreSQL)
    # DATABASE_URL="postgresql://user:password@host:port/database"
    • Purpose: Environment variables keep sensitive data out of your codebase and allow different configurations across environments. dotenv loads these during development.

Configuring the Vonage Messages API for Scheduled SMS

To send SMS messages, configure a Vonage application and link a virtual number.

  1. Log in to Vonage Dashboard: Go to the Vonage API Dashboard.
  2. Get API Key and Secret: Find these on the dashboard homepage and add them to your .env file.
  3. Create a Vonage Application:
    • Navigate to "Applications" > "Create a new application".
    • Name it (e.g., "Node SMS Scheduler").
    • Click "Generate public and private key". Save the downloaded private.key file securely in your project root (or specified path). Update VONAGE_PRIVATE_KEY_PATH in .env.
    • Enable the "Messages" capability.
    • For "Inbound URL" and "Status URL", you need a public URL. For development only, use ngrok:
      • Open a new terminal: ngrok http 3000 (replace 3000 if your PORT differs).
      • Copy the HTTPS "Forwarding" URL (e.g., https://randomstring.ngrok.io).
      • Enter these URLs in Vonage (replace YOUR_NGROK_URL):
        • Inbound URL: YOUR_NGROK_URL/webhooks/inbound
        • Status URL: YOUR_NGROK_URL/webhooks/status
      • WARNING: ngrok URLs are temporary and not for production. Production requires a stable public HTTPS endpoint.
    • Click "Create application".
    • Copy the "Application ID" and add it to .env.
  4. Buy and Link a Vonage Number:
    • Go to "Numbers" > "Buy numbers", find and buy an SMS-capable number.
    • Go to "Numbers" > "Your numbers", find the number, click "Link" (or gear icon), and link it to your "Node SMS Scheduler" application.
    • Copy the number (with country code) and add it to .env as VONAGE_FROM_NUMBER.
  5. Ensure Messages API is Default:
    • Go to API Settings (https://dashboard.nexmo.com/settings).
    • Under "SMS Settings", set "Default SMS Setting" to "Messages API". Save changes.

(Optional) Using Vonage CLI: Perform steps 3 & 4 via CLI (use your ngrok URL when prompted for webhooks).

Implementing SMS Scheduling Logic with Node.js

Let's write the code for the Express server, Vonage setup, scheduling logic, and API endpoint.

src/server.js:

javascript
// src/server.js
require('dotenv').config();
const express = require('express');
const { Vonage } = require('@vonage/server-sdk');
const { Auth } = require('@vonage/auth');
const cron = require('node-cron');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');
const path = require('path'); // For resolving key path
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { body, validationResult } = require('express-validator');

const app = express();

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

// --- Configuration ---
const PORT = process.env.PORT || 3000;
const SCHEDULER_INTERVAL_SECONDS = parseInt(process.env.SCHEDULER_INTERVAL_SECONDS || '60', 10);

// --- Vonage Initialization ---
let vonage;
try {
    const privateKeyPath = path.resolve(process.env.VONAGE_PRIVATE_KEY_PATH || './private.key');
    if (!fs.existsSync(privateKeyPath)) {
        throw new Error(`Private key file not found at: ${privateKeyPath}`);
    }
    const privateKey = fs.readFileSync(privateKeyPath);

    const credentials = new Auth({
        apiKey: process.env.VONAGE_API_KEY,
        apiSecret: process.env.VONAGE_API_SECRET,
        applicationId: process.env.VONAGE_APPLICATION_ID,
        privateKey: privateKey
    });
    vonage = new Vonage(credentials);
    console.log('Vonage SDK initialized successfully.');
} catch (error) {
    console.error('FATAL: Failed to initialize Vonage SDK:', error.message);
    console.error('Check .env configuration and private key path/permissions.');
    process.exit(1);
}

// --- In-Memory Job Store (DEMONSTRATION ONLY) ---
// WARNING: THIS IS NOT PRODUCTION-READY. Data is lost on application restart.
// Replace this with a persistent database (PostgreSQL, MongoDB, etc.) for any real use case.
const scheduledJobs = new Map(); // Map<jobId, JobDetails>
// interface JobDetails { jobId, to, from, text, sendAt: Date, status: 'pending' | 'processing' | 'sent' | 'failed', messageUuid?: string, error?: string, createdAt: Date }

// --- Rate Limiter ---
const scheduleLimiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100, // Limit each IP to 100 requests per windowMs
    message: 'Too many schedule requests created from this IP, please try again after 15 minutes'
});

// --- API Endpoints ---

/**
 * @route POST /schedule
 * @description Schedules an SMS message.
 */
app.post('/schedule',
    scheduleLimiter, // Apply rate limiting
    // Input Validation Middleware
    body('to').isMobilePhone('any', { strictMode: false }).withMessage('Valid destination phone number (to) is required'),
    body('text').isString().trim().notEmpty().withMessage('Message text cannot be empty'),
    body('sendAt').isISO8601().withMessage('sendAt must be a valid ISO 8601 date-time string')
        .custom((value) => {
            if (new Date(value) <= new Date()) {
                throw new Error('sendAt date must be in the future.');
            }
            return true;
        }),
    (req, res) => {
        const errors = validationResult(req);
        if (!errors.isEmpty()) {
            return res.status(400).json({ errors: errors.array() });
        }

        const { to, text, sendAt } = req.body;
        const sendAtDate = new Date(sendAt);
        const jobId = uuidv4();
        const jobDetails = {
            jobId,
            to,
            from: process.env.VONAGE_FROM_NUMBER,
            text,
            sendAt: sendAtDate,
            status: 'pending',
            createdAt: new Date()
        };

        // === PERSISTENCE LOGIC ===
        // !! REPLACE THIS with database insertion !!
        scheduledJobs.set(jobId, jobDetails);
        // Example DB call: await db.insertJob(jobDetails);
        // =========================

        console.log(`[API] Job scheduled: ${jobId} for ${sendAtDate.toISOString()} to ${to}`);

        res.status(202).json({
            jobId: jobId,
            status: 'scheduled',
            sendAt: sendAtDate.toISOString()
        });
    }
);

// --- Webhook Endpoints ---
// IMPORTANT: Secure these endpoints in production using signature verification (see Section 8)

/**
 * @route POST /webhooks/status
 * @description Receives delivery status updates from Vonage.
 */
app.post('/webhooks/status', (req, res) => {
    // TODO: Implement Vonage Webhook Signature Verification here for security!
    console.log('[Webhook Status] Received:', JSON.stringify(req.body, null, 2));
    const { message_uuid, status, err_code, price, timestamp } = req.body;

    // === PERSISTENCE LOGIC ===
    // Find the job associated with this message_uuid and update its status in the DATABASE.
    // This requires storing message_uuid when the message is successfully sent.
    let associatedJobId = null;
    for (const [jobId, job] of scheduledJobs.entries()) { // !! Replace with DB lookup !!
        if (job.messageUuid === message_uuid) {
            associatedJobId = jobId;
            console.log(`[Webhook Status] Correlated message_uuid ${message_uuid} to job ${jobId}`);
            // Example DB update: await db.updateJobStatusByMessageUUID(message_uuid, status, err_code);
            // Update the in-memory map (for demo purposes only)
            const currentJob = scheduledJobs.get(jobId);
            if(currentJob) {
                currentJob.status = status === 'delivered' ? 'delivered' : (status === 'failed' || status === 'rejected' ? 'failed' : status);
                if (err_code) currentJob.error = `Vonage Error: ${err_code}`;
            }
            break;
        }
    }
    if (!associatedJobId) {
        console.warn(`[Webhook Status] Received status for unknown message_uuid: ${message_uuid}`);
    }
    // =========================

    res.status(200).send('OK'); // Always respond 200 OK to Vonage quickly
});

/**
 * @route POST /webhooks/inbound
 * @description Receives inbound SMS messages sent to your Vonage number.
 */
app.post('/webhooks/inbound', (req, res) => {
    // TODO: Implement Vonage Webhook Signature Verification here for security!
    console.log('[Webhook Inbound] Received:', JSON.stringify(req.body, null, 2));
    const { from, to, text, timestamp, message_uuid } = req.body;

    // Process inbound message (e.g., handle STOP/HELP, route replies)
    // Example: Check if text.trim().toUpperCase() === 'STOP' and update opt-out status in DB.

    res.status(200).send('OK'); // Always respond 200 OK
});

// --- Health Check Endpoint ---
app.get('/health', (req, res) => {
    // TODO: Add checks for database connectivity in a real deployment
    res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});

// --- Scheduler Logic ---
console.log(`[Scheduler] Initializing cron job to run every ${SCHEDULER_INTERVAL_SECONDS} seconds.`);
console.warn(`[Scheduler] Using node-cron. Limitations apply (see docs/Section 12). Consider robust queues (BullMQ, Agenda) for production.`);

const schedulerTask = cron.schedule(`*/${SCHEDULER_INTERVAL_SECONDS} * * * * *`, async () => {
    const now = new Date();
    console.log(`[Scheduler Tick - ${now.toISOString()}] Checking for pending jobs...`);

    // === PERSISTENCE LOGIC ===
    // !! REPLACE iteration with a database query !!
    // Example DB Query: const jobsToSend = await db.findPendingJobsDue(now, 10); // Fetch N jobs due now
    // Use SELECT ... FOR UPDATE SKIP LOCKED on PostgreSQL to handle concurrency if scaling horizontally.
    const jobsToSend = [];
    for (const job of scheduledJobs.values()) {
        if (job.status === 'pending' && job.sendAt <= now) {
            jobsToSend.push(job);
        }
    }
    // =========================

    if (jobsToSend.length === 0) {
        console.log(`[Scheduler Tick] No jobs due.`);
        return;
    }

    console.log(`[Scheduler Tick] Found ${jobsToSend.length} job(s) to process.`);

    for (const job of jobsToSend) {
        console.log(`[Scheduler] Processing job: ${job.jobId}`);

        // === PERSISTENCE LOGIC ===
        // Mark job as 'processing' in the DATABASE to prevent reprocessing by this or other workers.
        // Example DB: await db.updateJobStatus(job.jobId, 'processing');
        // CAVEAT: If the process crashes *after* this update but *before* sending, the job might get stuck.
        // Database transactions or robust job queues handle this better.
        job.status = 'processing'; // Update in-memory state (demo only)
        // =========================

        try {
            // TODO: Implement proper retry logic here (see Section 6)
            const resp = await vonage.messages.send({
                message_type: 'text',
                to: job.to,
                from: job.from,
                channel: 'sms',
                text: job.text,
                client_ref: job.jobId // Useful for correlating status webhooks if message_uuid lookup fails
            });

            console.log(`[Scheduler] Message sent via Vonage for job ${job.jobId}. Message UUID: ${resp.message_uuid}`);

            // === PERSISTENCE LOGIC ===
            // Update job status to 'sent' and store the message_uuid in the DATABASE.
            // Example DB: await db.markJobAsSent(job.jobId, resp.message_uuid);
            job.status = 'sent';
            job.messageUuid = resp.message_uuid; // Store for webhook correlation
            // =========================

        } catch (error) {
            const errorMessage = error?.response?.data?.title || error.message || 'Unknown Vonage API error';
            console.error(`[Scheduler] Error sending SMS for job ${job.jobId}:`, errorMessage, error?.response?.data || '');

            // === PERSISTENCE LOGIC ===
            // Update job status to 'failed' and store the error in the DATABASE.
            // Implement retry counts/logic here.
            // Example DB: await db.markJobAsFailed(job.jobId, errorMessage, /* increment retry count */);
            job.status = 'failed';
            job.error = errorMessage;
            // =========================
        }
    }
    // Optional: Clean up old completed/failed jobs from the in-memory store if not using a DB
    // for (const [jobId, job] of scheduledJobs.entries()) { ... }
});

// --- Start Server ---
app.listen(PORT, () => {
    console.log(`Server listening on http://localhost:${PORT}`);
    console.log(`NGROK URL (for dev testing): YOUR_NGROK_URL`); // Remind user
    // cron.schedule starts the task automatically
});

// --- Graceful Shutdown ---
const gracefulShutdown = (signal) => {
    console.log(`${signal} signal received: closing HTTP server and scheduler...`);
    schedulerTask.stop();
    // TODO: Add code here to close database connections gracefully
    console.log('Scheduler stopped.');
    // Allow time for existing requests/jobs to finish if needed
    setTimeout(() => {
        console.log('Exiting process.');
        process.exit(0);
    }, 1000); // Adjust timeout as needed
};

process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));

Explanation:

  1. Initialization: Loads .env, initializes Express, Helmet, Vonage SDK (reading the key file). Exits if Vonage setup fails.
  2. In-Memory Store: The scheduledJobs Map is clearly marked as demonstration only and unsuitable for production due to data loss on restart. Comments indicate where database operations are needed.
  3. /schedule Endpoint:
    • Applies rate limiting (express-rate-limit).
    • Uses express-validator for robust input validation.
    • Generates jobId, creates jobDetails.
    • Stores the job (in memory for demo, marked for DB replacement).
    • Responds 202 Accepted.
  4. Webhook Endpoints:
    • /webhooks/status: Receives delivery reports. Logs data. Includes logic placeholders for finding the job by message_uuid (which needs to be stored upon successful send) and updating status (marked for DB replacement). Crucially notes the need for signature verification.
    • /webhooks/inbound: Receives incoming SMS. Logs data. Placeholder for processing logic (STOP/HELP). Notes need for signature verification.
  5. /health Endpoint: Basic health check.
  6. Scheduler (node-cron):
    • Warns about node-cron limitations.
    • Runs periodically (SCHEDULER_INTERVAL_SECONDS).
    • Fetches pending jobs (from memory for demo, marked for DB replacement with concurrency considerations).
    • For each due job:
      • Marks job as 'processing' (in memory for demo, marked for DB replacement, includes race condition caveat).
      • Calls vonage.messages.send() with client_ref: jobId.
      • On success: Updates status to 'sent' and stores message_uuid (in memory for demo, marked for DB replacement).
      • On failure: Logs error, updates status to 'failed' (in memory for demo, marked for DB replacement, notes need for retry logic).
  7. Server Start & Shutdown: Starts Express, logs reminder about ngrok, handles SIGINT/SIGTERM for graceful shutdown (stopping cron, placeholder for DB connection closing).

Building the SMS Scheduling REST API

The /schedule endpoint uses express-validator for robust validation.

API Endpoint Documentation:

  • Endpoint: POST /schedule

  • Description: Schedules an SMS message.

  • Request Body (JSON):

    json
    {
      "to": "+14155551212",                 // Destination phone number (E.164 format recommended)
      "text": "Your appointment is tomorrow at 10 AM.", // Message content
      "sendAt": "2025-05-15T10:00:00Z"      // Desired send time (ISO 8601 format, UTC recommended)
    }
  • Success Response (202 Accepted):

    json
    {
      "jobId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", // Unique ID for the scheduled job
      "status": "scheduled",
      "sendAt": "2025-05-15T10:00:00.000Z" // The scheduled time in ISO 8601 format
    }
  • Error Responses:

    • 400 Bad Request: Invalid input (missing fields, invalid formats, date not in future). Body contains errors array from express-validator.
    • 429 Too Many Requests: Rate limit exceeded.
    • 500 Internal Server Error: Unexpected server issue.

Testing with curl:

Replace placeholders with your ngrok URL (for development), a destination number, and a future date/time.

bash
# Ensure your server is running: node src/server.js
# Ensure ngrok is running: ngrok http 3000
# Use the ngrok HTTPS URL provided

curl -X POST YOUR_NGROK_URL/schedule \
 -H "Content-Type: application/json" \
 -d '{
   "to": "+12015551234",
   "text": "Hello from the Node.js Scheduler! This is a test.",
   "sendAt": "2025-12-01T10:30:00Z" # Adjust to a time a few minutes in the future
 }'

Check server logs for scheduling, processing, and sending messages. Verify SMS receipt.

Understanding Vonage SMS Integration Patterns

Integration relies on:

  • Configuration: Securely managed via .env and loaded using dotenv. The private key file is read directly.
  • SDK Initialization: The @vonage/server-sdk is initialized with credentials.
  • Sending: vonage.messages.send() call within the scheduler task.
  • Fallback/Retries: The base code marks jobs as 'failed'. Production systems need robust retries (see Section 6) and potentially dead-letter queues (moving jobs that repeatedly fail to a separate table/queue for investigation).

Implementing Error Handling and SMS Retry Logic

  • Error Handling:

    • try...catch around async operations (Vonage calls, DB operations).
    • Input validation at the API layer (express-validator).
    • Graceful handling of Vonage SDK initialization errors.
    • Centralized error logging (see below).
  • Logging:

    • Currently uses console.*. Strongly recommend a structured logging library like winston or pino for production.
    • Benefits: JSON format, log levels (error, warn, info, debug), multiple outputs (console, file, external services).
    • Log key events: server start, job schedule/process/send/fail, webhook receipt, errors.
  • Retry Mechanisms: Essential for handling transient network or API issues. The provided code does not implement retries, but here's how you could add it conceptually using async-retry:

    bash
    # Install the library
    npm install async-retry
    javascript
    // Conceptual retry logic in src/server.js (within the scheduler loop)
    const retry = require('async-retry'); // Make sure to install it
    
    // Inside the cron job loop, when processing a job...
    // job.status = 'processing'; // Mark as processing (in DB)
    
    try {
        const resp = await retry(async (bail, attemptNumber) => {
            // bail(error) is used to stop retrying for non-recoverable errors
            console.log(`[Scheduler] Attempt ${attemptNumber} to send job ${job.jobId}`);
            try {
                const vonageResponse = await vonage.messages.send({ /* ... message details ... */ });
                // If successful, return the response to exit retry loop
                return vonageResponse;
            } catch (error) {
                // Example: Don't retry client errors (4xx) which usually indicate permanent issues
                if (error.response && error.response.status >= 400 && error.response.status < 500) {
                    console.error(`[Scheduler] Non-retryable Vonage error for job ${job.jobId}: ${error.response.status}`);
                    bail(new Error(`Non-retryable Vonage error: ${error.response.status}`)); // Stop retrying
                    return; // bail throws, so this won't be reached, but good practice
                }
                // For other errors (network, 5xx), throw to trigger retry
                console.warn(`[Scheduler] Retrying job ${job.jobId} due to error: ${error.message}`);
                throw error;
            }
        }, {
            retries: 3,        // Number of retries (adjust as needed)
            factor: 2,         // Exponential backoff factor
            minTimeout: 1000,  // Initial delay 1s
            maxTimeout: 10000, // Max delay 10s
            onRetry: (error, attemptNumber) => {
                console.warn(`[Scheduler] Retrying job ${job.jobId}, attempt ${attemptNumber}. Error: ${error.message}`);
            }
        });
    
        // If retry succeeded:
        console.log(`[Scheduler] Message sent via Vonage for job ${job.jobId} after retries. Message UUID: ${resp.message_uuid}`);
        // === PERSISTENCE LOGIC ===
        // Update job status to 'sent' and store message_uuid in DB
        job.status = 'sent';
        job.messageUuid = resp.message_uuid;
        // await db.markJobAsSent(job.jobId, resp.message_uuid);
        // =========================
    
    } catch (error) {
        // This catches errors after all retries have failed or if bail() was called
        const finalErrorMessage = error?.response?.data?.title || error.message || 'Retry failed';
        console.error(`[Scheduler] Failed to send SMS for job ${job.jobId} after all retries:`, finalErrorMessage);
        // === PERSISTENCE LOGIC ===
        // Update job status to 'failed' and store the final error in DB
        job.status = 'failed';
        job.error = finalErrorMessage;
        // await db.markJobAsFailed(job.jobId, finalErrorMessage, /* final retry count */);
        // Consider moving to a dead-letter queue here.
        // =========================
    }

Creating a Production Database Schema for SMS Jobs

Using the in-memory Map is strictly for demonstration and will lose all data on restart. A persistent database is mandatory for any real application.

Choice of Database: PostgreSQL (relational), MongoDB (NoSQL), or even Redis (with persistence configured) can work. Choose based on your team's familiarity and application needs.

Example Schema (PostgreSQL):

sql
-- Enum for job status (expand as needed)
CREATE TYPE job_status AS ENUM ('pending', 'processing', 'sent', 'failed', 'delivered', 'undelivered', 'retry');

CREATE TABLE scheduled_sms_jobs (
    job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Or use application-generated UUID
    to_number VARCHAR(20) NOT NULL,
    from_number VARCHAR(20) NOT NULL,
    message_text TEXT NOT NULL,
    send_at TIMESTAMPTZ NOT NULL, -- Use TIMESTAMPTZ to store timezone info (stores in UTC)
    status job_status NOT NULL DEFAULT 'pending',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    vonage_message_uuid VARCHAR(50) NULL UNIQUE, -- Unique constraint helps webhook correlation
    last_error TEXT NULL,
    retry_count INT NOT NULL DEFAULT 0,
    client_ref VARCHAR(100) NULL -- Store client_ref if used
);

-- Index for efficient querying by the scheduler (find pending jobs due now)
CREATE INDEX idx_scheduled_sms_jobs_pending_send_at ON scheduled_sms_jobs (send_at) WHERE status = 'pending' OR status = 'retry';

-- Index for looking up jobs by message UUID from webhooks
CREATE INDEX idx_scheduled_sms_jobs_message_uuid ON scheduled_sms_jobs (vonage_message_uuid) WHERE vonage_message_uuid IS NOT NULL;

-- Optional: Index for client_ref lookup
-- CREATE INDEX idx_scheduled_sms_jobs_client_ref ON scheduled_sms_jobs (client_ref) WHERE client_ref IS NOT NULL;

-- Trigger to automatically update the updated_at timestamp on changes
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
   NEW.updated_at = NOW();
   RETURN NEW;
END;
$$ language 'plpgsql';

CREATE TRIGGER update_jobs_updated_at BEFORE UPDATE
ON scheduled_sms_jobs FOR EACH ROW EXECUTE FUNCTION
update_updated_at_column();

Data Access Layer Implementation (Required Step):

You must replace all interactions with the scheduledJobs Map in src/server.js with database operations.

  • Use an ORM (Sequelize, Prisma, TypeORM) or a query builder (knex.js) to interact with your chosen database.
  • Scheduling: INSERT new job records.
  • Scheduler Query: SELECT ... WHERE status IN ('pending', 'retry') AND send_at <= NOW() ... FOR UPDATE SKIP LOCKED (crucial for concurrency – PostgreSQL documentation).
  • Updating Status: UPDATE ... SET status = ?, vonage_message_uuid = ?, last_error = ?, retry_count = ? WHERE job_id = ?.
  • Webhook Correlation: SELECT ... WHERE vonage_message_uuid = ?.
  • Use database migrations tools (knex-migrate, Prisma Migrate, etc.) to manage schema changes.

The provided src/server.js code includes comments (// === PERSISTENCE LOGIC ===) indicating exactly where database interactions need to replace the in-memory map operations. This implementation is left as an exercise for the reader, as it depends heavily on the chosen database and library.

Securing Your SMS Scheduler: Webhooks and API Protection

  • Input Validation: Implemented using express-validator. Ensure thorough validation of all inputs.
  • Secrets Management: Store all credentials in environment variables (.env file) and never commit them to version control. Use secrets management services (AWS Secrets Manager, HashiCorp Vault) for production.
  • HTTPS Only: Enforce HTTPS for all webhook endpoints in production.
  • Webhook Signature Verification: Validate Vonage webhook signatures using HMAC to ensure requests originate from Vonage and haven't been tampered with (Vonage signature guide). Vonage recommends using SHA-256 HMAC or SHA-512 HMAC as the signature algorithm.
  • Rate Limiting: Apply express-rate-limit to prevent abuse of your scheduling API.
  • CORS Configuration: Configure CORS appropriately if your API is accessed from browser applications.
  • Helmet.js: Use Helmet to set secure HTTP headers (already included in example).

Frequently Asked Questions

What is the Vonage Messages API and how does it differ from the SMS API?

The Vonage Messages API is a unified communications platform supporting SMS, MMS, WhatsApp, Viber, and Facebook Messenger through a single API (source: Vonage Messages API documentation). Unlike the legacy SMS API (which only handles text messages), the Messages API provides consistent webhook formats, better error handling, and multi-channel support. The Messages API supports both JWT and Basic authentication, while the SMS API uses API key/secret pairs (source: Vonage SMS API technical details). For new projects, Vonage recommends the Messages API. The Messages API provides status webhooks with values: submitted, delivered, rejected, and undeliverable, while the SMS API supports additional statuses including accepted, buffered, expired, and unknown.

Can I use node-cron for production SMS scheduling?

node-cron is suitable for simple, single-instance deployments with low message volumes. However, it has critical limitations: no job persistence (jobs lost on restart), no distributed worker support, no built-in retry mechanisms, and no job priority queues. For production systems, use dedicated job queue systems like BullMQ (Redis-based, 50K+ GitHub stars – BullMQ documentation), Agenda (MongoDB-based), AWS SQS, Google Cloud Tasks, or Azure Service Bus. These provide persistence, horizontal scaling, retry logic, and priority management. BullMQ Job Schedulers (available v5.16.0+) act as factories producing jobs based on repeat settings, with support for cron expressions and custom intervals.

How do I handle time zones correctly in SMS scheduling?

Store all timestamps in UTC (ISO 8601 format) in your database using TIMESTAMPTZ (PostgreSQL) or equivalent. When users submit scheduling requests, convert their local time to UTC before storage. Display times to users in their local time zone using libraries like date-fns-tz or luxon. Never store time zones as offsets – always use IANA time zone identifiers (e.g., "America/New_York") because offsets change with daylight saving time.

What happens if my SMS fails to send?

Implement a comprehensive retry strategy with exponential backoff. The example code shows how to use the async-retry library with 3 retries, starting at 1 second delay and doubling to 10 seconds maximum. Non-retriable errors (4xx status codes indicating client errors like invalid phone numbers) should fail immediately. After exhausting retries, move failed jobs to a dead-letter queue for manual investigation. Log all failures with full error context for debugging.

How do I prevent duplicate SMS sends during retries?

Use idempotency keys and at-least-once delivery patterns. Generate a unique client_ref for each job (using UUID) and include it in the Vonage API call. The Messages API supports a client_ref parameter of up to 100 characters that appears in every message status (source: Vonage Messages API reference). Store message UUIDs returned by Vonage in your database. Before sending, check if a message UUID already exists for that job. Implement database constraints (unique indexes on job_id) and use transaction isolation levels to prevent race conditions during concurrent processing.

What are the SMS character limits and encoding considerations?

Standard SMS supports 160 characters using GSM-7 encoding (includes A-Z, 0-9, basic punctuation). Unicode messages (UCS-2 encoding) for special characters, emojis, and non-Latin scripts are limited to 70 characters. Longer messages automatically segment: GSM-7 messages split into 153-character chunks, Unicode into 67-character chunks (source: SMS encoding documentation). Each segment is billed separately. The Vonage Messages API automatically detects whether unicode characters are present in the text field and sends the message appropriately as either text or unicode SMS, unless encoding_type is explicitly set. Test your message templates to optimize character usage and avoid unexpected segmentation.

How do I secure Vonage webhooks in production?

Implement HMAC signature verification for all Vonage webhooks. Vonage recommends using SHA-256 HMAC or SHA-512 HMAC as the signature algorithm (source: Vonage security best practices). For Messages API webhooks, the bearer token is an HMAC-SHA256 token. Use the Vonage SDK's built-in verifySignature method or the @vonage/jwt library to decode and validate signatures (source: Vonage webhook validation guide). Reject requests with missing or invalid signatures. Additionally, use HTTPS-only endpoints, implement rate limiting, and configure webhook signature verification to be mandatory by contacting Vonage support.

What database should I use for production SMS scheduling?

PostgreSQL is recommended for most production scenarios because it provides ACID guarantees, FOR UPDATE SKIP LOCKED for safe concurrent job processing (PostgreSQL documentation), TIMESTAMPTZ for time zone handling, and mature ecosystem support. FOR UPDATE SKIP LOCKED ensures that only one process retrieves and locks tasks, while others skip over already-locked rows – essential for horizontal scaling. MongoDB works well for high-volume, schema-flexible scenarios. Redis (with persistence enabled) is suitable for ephemeral jobs with sub-second latency requirements. Avoid SQLite for multi-worker deployments due to locking limitations.

How do I monitor and debug SMS scheduling in production?

Implement structured logging using winston or pino with log levels (error, warn, info, debug). Log key events: job creation, scheduling, sending, success, failure, and webhook receipt. Use correlation IDs (job_id, message_uuid, client_ref) to trace requests across systems. Set up monitoring dashboards tracking: jobs scheduled/sent/failed per hour, delivery rates, webhook receipt latency, and error rates by type. Configure alerts for high failure rates, webhook delays, or database connection issues. The Vonage Messages API includes a client_ref field (up to 100 characters) that persists in every message status webhook, facilitating correlation across distributed systems.

Can I send scheduled SMS to international numbers?

Yes, Vonage supports international SMS to 200+ countries. Use E.164 phone number format (+[country code][number], e.g., +442071234567) for all destinations. The Messages API requires phone numbers in E.164 format without leading + or 00 symbols – start with the country code directly (source: Vonage Messages API reference). Be aware: international SMS costs vary significantly by country ($0.005–$0.50+ per message), delivery times are longer (5–30 seconds), some countries require sender ID registration, and certain countries have content restrictions or block A2P SMS entirely. Check Vonage pricing and country-specific regulations before deploying.

Conclusion

You've built a comprehensive SMS scheduling system using Vonage Messages API, Node.js, and Express. The example demonstrates core concepts including API endpoints, job scheduling, webhook handling, and security considerations. While the in-memory implementation works for learning and testing, production deployments require persistent databases, robust job queue systems, and comprehensive monitoring.

Key Takeaways:

  • Use Node.js v20 or v22 LTS for production with Express v4.x for API endpoints
  • Replace node-cron with BullMQ, Agenda, or cloud-based job queues for production reliability
  • Store all timestamps in UTC using PostgreSQL TIMESTAMPTZ or equivalent
  • Implement HMAC signature verification (SHA-256 or SHA-512) for all Vonage webhooks
  • Apply exponential backoff retry logic with idempotency keys to prevent duplicate sends
  • Use structured logging with correlation IDs for effective debugging and monitoring
  • Use FOR UPDATE SKIP LOCKED in PostgreSQL for safe concurrent job processing

Next Steps:

  • Replace the in-memory Map with PostgreSQL, MongoDB, or Redis database
  • Implement BullMQ or Agenda for persistent job queue management
  • Add comprehensive unit and integration tests using Jest or Mocha
  • Set up monitoring dashboards with Prometheus, Grafana, or Datadog
  • Configure production webhook endpoints with HTTPS and signature verification
  • Implement opt-out handling for STOP/UNSUBSCRIBE compliance
  • Add delivery report processing to update job status from webhooks

Additional Resources:

Frequently Asked Questions

How to schedule SMS messages with Node.js and Vonage?

Use the '/schedule' API endpoint with a JSON payload containing the recipient's number ('to'), the message text ('text'), and the scheduled send time ('sendAt' in ISO 8601 format). The server stores this information and uses a scheduler to send the SMS at the specified time via the Vonage Messages API.

What is node-cron used for in this SMS scheduler?

Node-cron is a simple task scheduler that uses cron syntax to trigger sending scheduled SMS messages at the correct time. The article uses it for demonstration but recommends more robust solutions like BullMQ or Agenda for production environments.

Why does this SMS scheduler use an in-memory data store?

The in-memory store (a JavaScript Map) simplifies the example code. However, it's not suitable for production as all scheduled messages are lost if the server restarts. A persistent database is required for production.

When should I replace node-cron with a production scheduler?

Node-cron has limitations regarding concurrency and reliability. For production, consider using more robust job queue systems like BullMQ or Agenda when handling large volumes of messages or requiring guaranteed delivery.

Can I use ngrok for production webhook endpoints?

No, ngrok should only be used for development and testing. Production requires a stable, publicly accessible HTTPS endpoint for receiving inbound SMS and delivery status webhooks securely.

What is the Vonage Messages API and how is it used?

The Vonage Messages API is the core service used to send the SMS messages. The provided code utilizes the Vonage Node.js SDK to interact with the Messages API when a scheduled time is reached.

How to handle Vonage API errors in the SMS scheduler?

The code includes basic error handling with try...catch blocks around Vonage API calls. For production, implement retry mechanisms using libraries like 'async-retry' and a dead-letter queue for persistent failures.

How to implement SMS message retries with async-retry?

Install the 'async-retry' library and wrap your Vonage API call within a retry function. Configure the number of retries, backoff strategy, and error handling to manage transient failures and prevent message loss.

What database schema is recommended for the SMS scheduler?

The article provides an example PostgreSQL schema with fields for job ID, recipient, message, send time, status, error details, and retry count. You can adapt this to other databases like MongoDB or Redis with persistence.

How to secure the SMS scheduler API endpoints?

Use input validation with libraries like 'express-validator', implement proper secrets management (environment variables, secure key storage), and enable Vonage webhook signature verification to prevent unauthorized access and data breaches.

How to set up Vonage application for the SMS scheduler?

In the Vonage dashboard, create an application, enable the Messages API, link a virtual number, and configure inbound and status webhook URLs. Save the generated private key securely.

What are the prerequisites for building this Node.js SMS scheduler?

You need a Vonage API account, Node.js and npm installed, ngrok for local development, basic knowledge of JavaScript and REST APIs, and optionally, the Vonage CLI.

Where can I find the complete code for the Node.js SMS scheduler?

A complete working example, including the core server logic, API endpoint, and placeholder comments for database integration, can be found in the associated GitHub repository.

How to implement robust logging for the SMS scheduler?

Replace basic console logging with structured logging libraries like Winston or Pino. Log key events like server start/stop, job processing, API requests, and errors, preferably in JSON format for easier analysis.