code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / Article

Vonage SMS Scheduling with Node.js & Express: Complete Guide 2025

Build an SMS scheduling system using Vonage API, Node.js 22 LTS, Express 5.1, and node-cron. Includes error handling, Winston logging, database schema, and production deployment strategies.

Vonage SMS Scheduling with Node.js & Express: Complete Guide 2025

Build an SMS scheduling and reminder application using Node.js, Express, and the Vonage APIs. Create a system that accepts requests to send SMS messages at specific future times, handles the scheduling logic, and integrates securely with Vonage for message delivery.

Important Note: This guide uses in-memory storage for simplicity during development. This is not suitable for production as all scheduled messages will be lost if the server restarts. Section 6 outlines the necessary database implementation for a production-ready system.

You'll learn project setup, core scheduling logic using node-cron, API design with Express, integrating the Vonage SDK (specifically using the SMS API for simplicity), error handling, structured logging, security considerations, and deployment strategies. By the end, you'll have a functional demonstration application and the knowledge to implement persistent storage and other production requirements.

Technologies Used:

  • Node.js: A JavaScript runtime environment for building the backend server. This guide uses Node.js 22.x LTS (Active LTS until October 2025, Maintenance until April 2027).
  • Express: A minimal and flexible Node.js web application framework for creating the API. This guide uses Express 5.1.0 (published April 2025, requires Node.js 18+).
  • Vonage Server SDK (@vonage/server-sdk): Used for sending SMS messages via the Vonage SMS API. (Note: While the SDK supports the newer Messages API, this guide uses the simpler vonage.sms.send() method). Vonage (formerly Nexmo, rebranded 2020) remains active as of January 2025.
  • node-cron: A simple cron-like job scheduler for Node.js to trigger message sending.
  • dotenv: A module to load environment variables from a .env file for secure configuration.
  • winston: A versatile logging library for structured logging.
  • uuid: Generates unique IDs for scheduled messages.
  • (Required for Production): A database (e.g., PostgreSQL, MongoDB) for persistent storage of scheduled messages and an ORM/ODM (e.g., Prisma, Mongoose). This guide's in-memory approach must be replaced for production.

System Architecture:

+-----------------+ +---------------------+ +-----------------+ | User / Client |----->| Node.js / Express |<---->| Vonage SMS | | (e.g., Postman) | | API Server | | API | +-----------------+ +---------------------+ +-----------------+ | 1. POST /schedule | 2. Store Schedule | 3. Send SMS | (to, msg, time) | (In-Memory/DB*) | at scheduled time | | 4. Run Cron Job | | | (Check Schedule) | +-----------------------+-----------------------+ *DB required for production
  1. A client sends an HTTP POST request to the /schedule endpoint with recipient details, message content, and the desired sending time.
  2. The Express server validates the request and stores the scheduling information (initially in memory; must be a database in production).
  3. A node-cron job runs at regular intervals (e.g., every minute).
  4. The cron job checks the stored schedule for any messages due to be sent.
  5. For due messages, the server uses the Vonage SDK (vonage.sms.send()) to send the SMS via the Vonage SMS API.

Before You Begin:

  • Node.js and npm (or yarn): Install Node.js 22.x LTS on your system. Download Node.js
  • Vonage API Account: Sign up for a free account. Vonage API Dashboard
  • Vonage API Key and Secret: Find these on your Vonage Dashboard homepage.
  • Vonage Application: Create one to get an Application ID and Private Key.
  • Vonage Virtual Number: Purchase a number capable of sending SMS. Buy Numbers
  • (Optional, for Webhook Testing): ngrok: Expose your local server if you implement and test webhook handling (e.g., for delivery receipts), which is not covered in this guide's core implementation. ngrok

1. Set Up Your Project

Start by creating the project structure, installing dependencies, and setting up basic configuration.

1.1 Create Project Directory:

Open your terminal and create a new directory for the project, then navigate into it.

bash
mkdir vonage-sms-scheduler
cd vonage-sms-scheduler

1.2 Initialize Node.js Project:

Initialize the project using npm. This creates a package.json file.

bash
npm init -y

1.3 Install Dependencies:

Install the necessary libraries: Express for the server, the Vonage SDK for SMS, node-cron for scheduling, dotenv for environment variables, winston for logging, and uuid for unique IDs.

bash
npm install express @vonage/server-sdk node-cron dotenv winston uuid
  • express: Web framework.
  • @vonage/server-sdk: Official Vonage SDK for Node.js.
  • node-cron: Task scheduler.
  • dotenv: Loads environment variables from a .env file.
  • winston: Structured logging library.
  • uuid: Generates unique IDs.

1.4 Create Project Structure:

Create the main server file and a file for environment variables.

bash
touch server.js .env .gitignore

Your basic structure should look like this:

vonage-sms-scheduler/ ├── node_modules/ ├── .env ├── .gitignore ├── package.json ├── package-lock.json └── server.js

1.5 Configure .gitignore:

Add node_modules, .env, your private key file, and log files to your .gitignore file to prevent committing them to version control. Sensitive credentials should never be committed.

Code
node_modules
.env
private.key
*.log

1.6 Set Up Vonage Application and Credentials:

You need specific credentials from Vonage to interact with the API.

  1. Log in to your Vonage API Dashboard.
  2. API Key and Secret: Note down your API Key and API Secret found on the main dashboard page.
  3. Create Application:
    • Navigate to 'Applications' -> 'Create a new application'.
    • Give your application a name (e.g., SMSScheduler).
    • Click 'Generate public and private key'. Crucially, save the private.key file that downloads. It's recommended to save it within your project directory (e.g., in the root) but ensure it's listed in .gitignore.
    • Enable the 'Messages' capability (this enables SMS sending for the application).
    • For 'Inbound URL' and 'Status URL', you can initially put placeholder URLs like https://example.com/webhooks/inbound and https://example.com/webhooks/status. If you later implement webhook handling (outside the scope of this guide), you'll update these with your actual ngrok or deployed server URLs.
    • Click 'Generate new application'.
    • Note down the Application ID that is generated.
  4. Link Number:
    • Go to 'Numbers' -> 'Your numbers'.
    • Find the SMS-capable number you purchased. Click 'Link' next to it.
    • Select the application you just created (SMSScheduler) from the dropdown.
    • Click 'Link'.

1.7 Configure Environment Variables:

Open the .env file and add your Vonage credentials and configuration. Replace the placeholder values with your actual credentials.

Code
# Vonage API Credentials
VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
VONAGE_APPLICATION_ID=YOUR_APPLICATION_ID
# Make sure this path is correct relative to where you run `node server.js`
VONAGE_PRIVATE_KEY_PATH=./private.key
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # The number linked to your application (E.164 format)

# Server Configuration
PORT=3000

# Cron Schedule (Runs every minute)
CRON_SCHEDULE=* * * * *

# Logging Level
LOG_LEVEL=info
  • VONAGE_API_KEY, VONAGE_API_SECRET, VONAGE_APPLICATION_ID: Found in your Vonage Dashboard and Application settings.
  • VONAGE_PRIVATE_KEY_PATH: The path to the private.key file you downloaded. Adjust if you place it elsewhere.
  • VONAGE_NUMBER: Your purchased Vonage virtual number linked to the application (use E.164 format, e.g., +14155550100).
  • PORT: The port your Express server will listen on.
  • CRON_SCHEDULE: Defines how often the scheduler checks for due messages. * * * * * means every minute.
  • LOG_LEVEL: Controls the verbosity of the logger (e.g., debug, info, warn, error).

2. Implement Core Scheduling Logic

Write the core logic in server.js. Set up Express, initialize Vonage, configure logging, create the (non-production) in-memory schedule storage, and implement the cron job.

2.1 Basic Server Setup (server.js):

javascript
// server.js
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const { Vonage } = require('@vonage/server-sdk');
const cron = require('node-cron');
const { v4: uuidv4 } = require('uuid'); // For generating unique IDs
const winston = require('winston'); // Logging library

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

// --- Configure Structured Logger (Winston) ---
const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json() // Log in JSON format
  ),
  transports: [
    new winston.transports.Console({ // Log to the console
      format: winston.format.combine(
        winston.format.colorize(), // Add colors for readability in console
        winston.format.simple() // Use simple format for console
      )
    }),
    // Add file transports for production logging if needed
    // new winston.transports.File({ filename: 'error.log', level: 'error' }),
    // new winston.transports.File({ filename: 'combined.log' }),
  ],
});

logger.info('Logger configured successfully.');

// Middleware to parse JSON bodies
app.use(express.json());

// --- In-Memory Storage for Scheduled Messages ---
// > **CRITICAL WARNING:** This in-memory storage is for demonstration ONLY.
// > It is NOT suitable for production. All data will be LOST on server
// > restart or crash. Implement database persistence (Section 6) before
// > deploying to production.
let scheduledMessages = []; // Array to hold { id, to, message, sendAt, status, error?, createdAt } objects

// --- Initialize Vonage Client ---
let vonage;
try {
    vonage = new Vonage({
        apiKey: process.env.VONAGE_API_KEY,
        apiSecret: process.env.VONAGE_API_SECRET,
        applicationId: process.env.VONAGE_APPLICATION_ID,
        privateKey: process.env.VONAGE_PRIVATE_KEY_PATH
    }, {
        debug: process.env.LOG_LEVEL === 'debug' // Enable SDK debug logging if LOG_LEVEL is debug
    });
    logger.info('Vonage client initialized successfully.');
} catch (error) {
    logger.error('FATAL: Failed to initialize Vonage client. Ensure Vonage environment variables (API Key, Secret, App ID, Private Key Path) are set correctly.', {
        errorMessage: error.message,
        stack: error.stack // Include stack trace for detailed debugging
    });
    process.exit(1); // Exit if Vonage can't be initialized
}

// --- Basic Routes (Health Check) ---
app.get('/health', (req, res) => {
    res.status(200).json({
        status: 'UP',
        // WARNING: scheduled_count reflects in-memory state only
        scheduled_count_in_memory: scheduledMessages.length
    });
});

// --- Placeholder for API endpoint ---
// (We will add the /schedule endpoint in the next section)

// --- Placeholder for Cron Job ---
// (We will add the cron job logic below)

// --- Start the Server ---
// (Moved after cron job setup)

Explanation:

  1. Load Env Vars: require('dotenv').config() loads variables from .env.
  2. Import Dependencies: Imports express, Vonage, node-cron, uuid, and winston.
  3. Configure Logger: Sets up winston for structured JSON logging, with console output formatted simply and colorized. Log level is controlled by LOG_LEVEL in .env.
  4. Express App & Middleware: Creates an Express app and uses express.json().
  5. In-Memory Storage: scheduledMessages array is initialized with a very prominent warning about its unsuitability for production, formatted as a blockquote.
  6. Vonage Initialization: Creates a Vonage client instance. Includes robust error logging on failure and exits the process. SDK debug logging is enabled if LOG_LEVEL=debug.
  7. Health Check: A simple /health endpoint.

2.2 Implementing the Cron Job Scheduler:

The cron job periodically checks the scheduledMessages array and sends messages whose sendAt time has passed.

Add the following code to server.js, after the Vonage initialization and before starting the server:

javascript
// server.js (continued)

// --- Cron Job to Check and Send Scheduled Messages ---
logger.info(`Scheduling cron job with schedule: '${process.env.CRON_SCHEDULE}' in UTC timezone.`);

const scheduledTask = cron.schedule(process.env.CRON_SCHEDULE, async () => {
    const now = new Date();
    const jobStartTime = Date.now();
    logger.info(`Cron job running: Checking for due messages...`, { timestamp: now.toISOString() });

    // Find messages ready to be sent from the in-memory store
    const messagesToSend = scheduledMessages.filter(msg =>
        msg.status === 'pending' && new Date(msg.sendAt) <= now
    );

    if (messagesToSend.length === 0) {
        logger.info(`No messages due at this time.`);
        return;
    }

    logger.info(`Found ${messagesToSend.length} message(s) to send.`);

    for (const msg of messagesToSend) {
        logger.info(`Attempting to send message`, { scheduleId: msg.id, to: msg.to });
        try {
            // Use vonage.sms.send() from the SDK
            const resp = await vonage.sms.send({
                to: msg.to,
                from: process.env.VONAGE_NUMBER, // Use the Vonage number from .env
                text: msg.message
            });

            // Check Vonage response for success (status '0')
            // Note: This only confirms Vonage accepted the request, not final delivery.
            // Use Status Webhooks (not covered here) for delivery confirmation.
            if (resp.messages && resp.messages.length > 0 && resp.messages[0].status === '0') {
                const messageId = resp.messages[0]['message-id'];
                logger.info(`Message sent successfully to Vonage`, {
                    scheduleId: msg.id,
                    to: msg.to,
                    vonageMessageId: messageId
                });
                // !! Update status in memory (Replace with DB update) !!
                msg.status = 'sent';
                msg.vonageMessageId = messageId; // Store Vonage ID if needed later
            } else {
                // Handle Vonage API errors reported in the response
                const statusCode = resp.messages?.[0]?.status || 'N/A';
                const errorText = resp.messages?.[0]?.['error-text'] || 'Unknown Vonage API error';
                logger.error(`Failed to send message via Vonage API`, {
                    scheduleId: msg.id,
                    to: msg.to,
                    vonageStatus: statusCode,
                    vonageError: errorText
                });
                // !! Update status in memory (Replace with DB update) !!
                msg.status = 'failed';
                msg.error = `Vonage Status ${statusCode}: ${errorText}`;

                // Optional: Add logic based on status code, e.g., don't retry permanent errors like '4' (Invalid Credentials)
                // Vonage SMS API Status Codes (per Vonage API documentation):
                // Status '0': Success (message accepted)
                // Status '1': Throttled (rate limit - potentially retryable)
                // Status '3': Invalid Parameters (permanent - invalid to/from number format)
                // Status '4': Invalid Credentials (permanent - check API key/secret)
                // Status '6': Invalid Message (permanent - message content issues)
                // Status '9': Partner Quota Exceeded (permanent - insufficient account balance)
                // See: https://api.support.vonage.com/hc/en-us/articles/204014733-Vonage-SMS-Delivery-Error-Codes-and-Descriptions
                if (statusCode === '1' || statusCode === '4' || statusCode === '3') {
                     logger.warn(`Permanent error detected for message. Won't retry automatically.`, { scheduleId: msg.id, statusCode });
                     // In a DB scenario, you might mark it as permanently failed.
                }
            }

        } catch (error) {
            // Handle network errors or other exceptions during the API call
            logger.error(`CRITICAL ERROR sending message: Network or SDK issue`, {
                scheduleId: msg.id,
                to: msg.to,
                errorMessage: error.message,
                stack: error.stack
            });
            // !! Update status in memory (Replace with DB update) !!
            msg.status = 'failed';
            msg.error = error.message;
            // Consider adding retry logic here for network errors (see Section 5)
        }
    }

    // Clean up sent/failed messages from the active pending list (optional, depends on needs)
    // For simplicity here, we just update status. In a DB scenario, you'd update the record.
    // If keeping history isn't needed with in-memory:
    // scheduledMessages = scheduledMessages.filter(msg => msg.status === 'pending');
    // logger.info(`In-memory store cleanup complete. Remaining pending: ${scheduledMessages.length}`);

    const jobEndTime = Date.now();
    logger.info(`Cron job finished. Duration: ${jobEndTime - jobStartTime}ms`, { processedCount: messagesToSend.length });

}, {
    scheduled: true,
    timezone: ""Etc/UTC"" // IMPORTANT: Run cron based on UTC time
});

scheduledTask.start(); // Explicitly start the task

logger.info(""Cron job scheduled and started."");

// --- Start the Server --- (Now placed after cron setup)
app.listen(port, () => {
    logger.info(`SMS Scheduler server listening at http://localhost:${port}`);
    logger.info(`Cron job checking schedule: '${process.env.CRON_SCHEDULE}' in UTC timezone.`);
});

Explanation:

  1. cron.schedule: Creates the task using the .env schedule and sets the timezone to Etc/UTC (essential for consistency).
  2. Logging: Uses the logger for all output, providing structured context (timestamps, schedule IDs, etc.).
  3. Filter Due Messages: Finds messages in the in-memory array marked 'pending' with sendAt in the past.
  4. Iterate and Send: Loops through due messages.
  5. vonage.sms.send: Calls the Vonage SDK's SMS sending method.
  6. Handle Response:
    • Checks resp.messages[0].status === '0' for success indication from Vonage.
    • Logs success with the vonageMessageId.
    • Logs failure with the specific Vonage status code and error-text.
    • Updates the status ('sent' or 'failed') and error field in the in-memory object. Includes a warning about specific permanent error codes.
  7. Error Handling: A try...catch block handles network/SDK exceptions, logging them as critical errors.
  8. In-Memory Update: Updates the status directly in the scheduledMessages array. This needs to be replaced with database operations.
  9. Start Task: scheduledTask.start() starts the job.
  10. Server Start: The app.listen call is now placed after the cron job setup to ensure logging order makes sense.

3. Build the API Layer

Create an endpoint for clients to submit SMS scheduling requests.

Add the following POST route handler to server.js, typically before the cron.schedule block:

javascript
// server.js (continued)

// --- API Endpoint to Schedule SMS ---
app.post('/schedule', (req, res) => {
    const { to, message, sendAt } = req.body;
    const requestReceivedAt = new Date();
    logger.info('Received POST /schedule request', { body: req.body });

    // --- Input Validation ---
    if (!to || !message || !sendAt) {
        logger.warn('Validation failed: Missing required fields', { body: req.body });
        return res.status(400).json({ error: 'Missing required fields: to, message, sendAt' });
    }

    // Validate 'to' number format (stricter E.164 check)
    // Requires '+' sign, country code, and subscriber number.
    // Per ITU-T Recommendation E.164: maximum 15 digits (country code 1-3 digits + subscriber number max 12 digits)
    if (!/^\+[1-9]\d{1,14}$/.test(to)) {
         logger.warn('Validation failed: Invalid `to` phone number format', { to });
         return res.status(400).json({ error: 'Invalid `to` phone number format. Use E.164 format (e.g., +14155550100).' });
    }

    // Validate 'sendAt' format and ensure it's in the future
    const sendAtDate = new Date(sendAt);
    if (isNaN(sendAtDate.getTime())) {
        logger.warn('Validation failed: Invalid `sendAt` date format', { sendAt });
        return res.status(400).json({ error: 'Invalid `sendAt` date format. Use ISO 8601 format (e.g., 2025-12-31T23:59:00Z).' });
    }
    // Allow a small buffer (e.g., 10 seconds) to account for processing time
    const minSendTime = new Date(Date.now() + 10000);
    if (sendAtDate <= minSendTime) {
        logger.warn('Validation failed: `sendAt` date must be in the future', { sendAt, now: minSendTime.toISOString() });
        return res.status(400).json({ error: '`sendAt` date must be at least 10 seconds in the future.' });
    }

    // --- Create Schedule Object ---
    const newScheduledMessage = {
        id: uuidv4(), // Generate a unique ID
        to: to,
        message: message,
        sendAt: sendAtDate.toISOString(), // Store as ISO string (UTC)
        status: 'pending', // Initial status
        createdAt: requestReceivedAt.toISOString()
    };

    // > **CRITICAL:** Replace this with a database INSERT operation
    // > for production use.
    scheduledMessages.push(newScheduledMessage);

    logger.info(`Successfully scheduled new message`, {
        scheduleId: newScheduledMessage.id,
        to: newScheduledMessage.to,
        sendAt: newScheduledMessage.sendAt
    });

    // --- Respond to Client ---
    // 202 Accepted: Request received, processing will happen later.
    res.status(202).json({
        message: 'SMS scheduled successfully.',
        scheduleId: newScheduledMessage.id,
        scheduledTimeUTC: newScheduledMessage.sendAt
    });
});

// Existing code (Vonage init, cron job, app.listen, etc.) follows...

Explanation:

  1. Route Definition & Logging: Defines POST /schedule and logs the incoming request.
  2. Extract Body: Gets to, message, sendAt from req.body.
  3. Input Validation:
    • Checks for missing fields.
    • Uses a stricter E.164 regex (/^\+[1-9]\d{1,14}$/) for the to number, requiring the +.
    • Validates sendAt is a valid ISO 8601 date string and is in the future (with a small buffer).
    • Logs validation failures using the logger.
  4. Create Schedule Object: Creates the newScheduledMessage object with a uuid, details, 'pending' status, and timestamps (stored as UTC ISO strings).
  5. Store Schedule (In-Memory): Pushes the object into the scheduledMessages array, again highlighting that this must be replaced with a database insert.
  6. Respond: Sends 202 Accepted with the scheduleId and scheduled time.

Testing the API Endpoint:

Use curl or Postman. Schedule a message for a few minutes in the future (using UTC).

bash
# Ensure sendAt is ISO 8601 format with Z (UTC) and in the future
# Example for macOS/BSD date:
FUTURE_TIME=$(date -u -v+2M '+%Y-%m-%dT%H:%M:%SZ')
# Example for GNU date:
# FUTURE_TIME=$(date -u --date='+2 minutes' '+%Y-%m-%dT%H:%M:%SZ')

curl -X POST http://localhost:3000/schedule \
-H ""Content-Type: application/json"" \
-d '{
  ""to"": ""+14155550101"",
  ""message"": ""Hello from the scheduler! This is a test."",
  ""sendAt"": ""'""${FUTURE_TIME}""'""
}'
  • Replace +14155550101 with your target number.
  • Check server logs (now structured JSON) for scheduling and cron job activity.
  • Verify SMS arrival.

4. Integrating with Vonage (Covered in Setup & Core Logic)

The core Vonage integration happens during:

  1. Initialization: Setting up the Vonage client instance in server.js using credentials from .env (Section 2.1).
  2. Sending: Calling vonage.sms.send() within the cron job logic (Section 2.2).

Secure Handling of Credentials:

  • .env File: Keeps secrets out of the codebase.
  • .gitignore: Ensures .env and private.key are never committed to Git.
  • Environment Variables in Production: Use platform-provided environment variables (Heroku Config Vars, Docker secrets, etc.), not a .env file in the production environment.

Fallback Mechanisms:

The current implementation has basic error logging. For more robust systems:

  • Retry Logic: Implement exponential backoff for specific error types (e.g., temporary network issues, Vonage rate limits (status: 1)). See Section 5.
  • Dead Letter Queue: Move persistently failing messages (after retries) to a separate table/queue for investigation.
  • Monitoring & Alerting: Set up alerts for high failure rates (Section 6 covers database aspects needed for this).

5. Error Handling, Logging, and Retry Mechanisms

Robust error handling and structured logging are vital.

Current Implementation:

  • API Validation: /schedule validates input, returns 400 errors, logs failures.
  • Vonage Init: Logs fatal errors if initialization fails.
  • Cron Job try...catch: Catches errors during vonage.sms.send.
  • Structured Logging: Uses winston throughout for JSON-formatted logs with levels (info, warn, error) and context.
  • Vonage Status Handling: Logs specific Vonage API errors encountered during sending.

Improvements for Production:

  • Centralized Error Handler (Express): Add Express error-handling middleware to catch unhandled errors in routes.

  • Log Aggregation: Send logs to a centralized service (Datadog, Splunk, ELK, Papertrail) for easier searching and analysis, especially in distributed systems. The JSON format from Winston is ideal for this.

  • Retry Mechanism (Example Sketch using async-retry):

    javascript
    // Inside the cron job's loop (requires npm install async-retry)
    const retry = require('async-retry');
    
    // ... inside the for (const msg of messagesToSend) loop ...
        try {
            // Wrap the Vonage call in retry logic
            await retry(async (bail, attempt) => {
                logger.debug(`Attempt ${attempt} to send message`, { scheduleId: msg.id });
    
                const resp = await vonage.sms.send({
                    to: msg.to,
                    from: process.env.VONAGE_NUMBER,
                    text: msg.message
                });
    
                if (resp.messages && resp.messages.length > 0) {
                     const status = resp.messages[0].status;
                     const errorText = resp.messages[0]['error-text'] || 'Unknown Vonage error';
                     const messageId = resp.messages[0]['message-id'];
    
                     if (status === '0') {
                         logger.info(`Message sent successfully via Vonage (Attempt ${attempt})`, { scheduleId: msg.id, vonageMessageId: messageId });
                         // !! DB Update: Mark as sent !!
                         msg.status = 'sent';
                         msg.vonageMessageId = messageId;
                         return; // Success, stop retrying
                     } else {
                         logger.warn(`Vonage API error on attempt ${attempt}`, { scheduleId: msg.id, status, errorText });
                         // Decide if the error is permanent and should not be retried
                         // Status 1 (Throttled) might be retryable depending on strategy
                         // Status 4 (Invalid Creds), 3 (Invalid Params), 6 (Invalid Msg), 9 (No Funds) are likely permanent
                         if (status === '3' || status === '4' || status === '6' || status === '9') {
                             const permanentError = new Error(`Permanent Vonage error: ${errorText} (Status: ${status})`);
                             // !! DB Update: Mark as permanently failed !!
                             msg.status = 'failed';
                             msg.error = permanentError.message;
                             bail(permanentError); // Stop retrying immediately
                             return;
                         } else {
                            // For other errors (e.g., 1-Throttled, 2-Missing Params (shouldn't happen?), 5-Internal Error), throw to trigger retry
                             throw new Error(`Retryable Vonage error: ${errorText} (Status: ${status})`);
                         }
                     }
                } else {
                     // Invalid response structure from Vonage - likely temporary issue
                     throw new Error(""Invalid or empty response structure from Vonage API"");
                }
            }, {
                retries: 3, // Number of retries
                factor: 2, // Exponential backoff factor (1s, 2s, 4s)
                minTimeout: 1000, // Initial delay 1s
                onRetry: (error, attempt) => {
                    logger.warn(`Retrying message sending (Attempt ${attempt})`, {
                        scheduleId: msg.id,
                        error: error.message,
                    });
                }
            });
            // If retry succeeds, msg.status is updated inside the retry block
    
        } catch (error) {
             // This catches errors if retry fails completely (permanent or max retries reached)
             logger.error(`CRITICAL/FINAL ERROR after retries sending message`, {
                 scheduleId: msg.id,
                 errorMessage: error.message,
                 // Avoid logging full stack for final failure unless needed
             });
             // !! DB Update: Mark as failed !!
             if (msg.status !== 'failed') { // Avoid overwriting permanent failure status set by bail()
                 msg.status = 'failed';
                 msg.error = error.message; // Store the last error
             }
        }
    // ... rest of loop ...

Frequently Asked Questions (FAQ)

How does node-cron work for SMS scheduling?

node-cron runs a scheduled task at defined intervals (e.g., every minute using * * * * *) to check for messages whose sendAt time has passed. Set the timezone to Etc/UTC for consistency across different server locations. The cron job queries pending messages, filters by timestamp, and sends due messages via the Vonage SDK.

What Node.js version should I use for Vonage SMS scheduling?

Use Node.js 22.x LTS (Active LTS until October 2025, Maintenance until April 2027) for production SMS scheduling systems. This version ensures long-term support and compatibility with Express 5.1.0, which requires Node.js 18+. Avoid Node.js 18.x as it reached end-of-life in April 2025.

How do I handle Vonage SMS API errors in production?

Implement error handling based on Vonage status codes: Status '0' indicates success, Status '1' (throttled) is retryable, while Status '3' (invalid parameters), '4' (invalid credentials), '6' (invalid message), and '9' (quota exceeded) are permanent failures. Use the async-retry library with exponential backoff for retryable errors. See the official Vonage error codes documentation at api.support.vonage.com.

Why is in-memory storage not suitable for production SMS scheduling?

In-memory storage loses all scheduled messages when the server restarts, crashes, or deploys new code. Production systems require persistent database storage (PostgreSQL, MySQL, or MongoDB) with indexed queries on status and send_at columns. Use an ORM like Prisma for type-safe database operations and migrations.

What is the E.164 phone number format for Vonage SMS?

E.164 format requires a plus sign (+), country code (1-3 digits), and subscriber number, with a maximum of 15 digits total per ITU-T Recommendation E.164. Example: +14155550100 for a US number. The regex /^\+[1-9]\d{1,14}$/ validates this format in your API endpoint.

How do I secure Vonage API credentials in Node.js?

Store credentials in a .env file (never committed to Git) using the dotenv package for local development. In production, use platform-provided environment variables (Heroku Config Vars, Docker secrets, AWS Parameter Store). Keep the private key file in .gitignore and load it via VONAGE_PRIVATE_KEY_PATH.

Can I use Vonage Messages API instead of SMS API?

Yes, Vonage Messages API supports SMS, MMS, RCS, and social chat apps. While this guide uses the simpler vonage.sms.send() method from the SMS API, you can upgrade to the Messages API for multi-channel support. Both APIs work with the @vonage/server-sdk package.

How do I implement retry logic for failed SMS messages?

Use the async-retry library with exponential backoff (e.g., 3 retries with factor 2: 1s, 2s, 4s delays). Check Vonage status codes to distinguish permanent errors (bail immediately) from retryable errors (throttling, network issues). Track attempt_count and last_attempt_at in your database for debugging and rate limiting.


6. Creating a Database Schema and Data Layer (Essential for Production)

As emphasized repeatedly, the in-memory scheduledMessages array must be replaced with a persistent database for any real-world application.

Example Schema (using SQL syntax for illustration - adapt for your DB):

sql
CREATE TABLE scheduled_sms (
    id UUID PRIMARY KEY,                      -- Unique identifier (use DB's UUID type or VARCHAR)
    to_number VARCHAR(20) NOT NULL,           -- Recipient phone number (E.164 format)
    message_body TEXT NOT NULL,               -- SMS content
    send_at TIMESTAMPTZ NOT NULL,             -- Scheduled time (Timestamp With Time Zone - store and query in UTC)
    status VARCHAR(15) NOT NULL DEFAULT 'pending', -- 'pending', 'sent', 'failed', 'permanently_failed', 'retrying'
    vonage_message_id VARCHAR(50) NULL,       -- Message ID from Vonage response (useful for reconciliation/webhooks)
    last_attempt_at TIMESTAMPTZ NULL,         -- Timestamp of the last sending attempt (for retry logic/debugging)
    attempt_count INT DEFAULT 0,              -- Number of sending attempts
    error_message TEXT NULL,                  -- Last error message if status is 'failed' or 'permanently_failed'
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Record creation time
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()  -- Record last update time (use DB triggers if available)
);

-- CRITICAL Index for efficient querying by the cron job
CREATE INDEX idx_scheduled_sms_pending_send_at ON scheduled_sms (status, send_at)
WHERE status = 'pending' OR status = 'retrying'; -- Adjust statuses based on your logic

-- Optional index for looking up by Vonage ID (if handling status webhooks)
-- CREATE INDEX idx_scheduled_sms_vonage_id ON scheduled_sms (vonage_message_id);

Implementation Steps:

  1. Choose Database: Select a suitable database (e.g., PostgreSQL, MySQL, MongoDB). PostgreSQL with TIMESTAMPTZ is excellent for time-based scheduling.
  2. Choose ORM/ODM: Select a library like Prisma (recommended for type safety), Sequelize, TypeORM (for SQL), or Mongoose (for MongoDB).
  3. Define Schema/Model: Create the model/schema definition using your chosen ORM/ODM based on the example above.
  4. Migrations: Use the ORM's migration tools (prisma migrate dev, sequelize db:migrate) to create and manage the database table structure safely.
  5. Replace In-Memory Logic:
    • /schedule Endpoint: Replace scheduledMessages.push(newScheduledMessage) with a database INSERT operation (e.g., prisma.scheduledSms.create({ data: newScheduledMessage })).
    • Cron Job Query: Replace scheduledMessages.filter(...) with a database SELECT query using the indexed status and send_at columns (e.g., prisma.scheduledSms.findMany({ where: { status: 'pending', sendAt: { lte: now } } })).
    • Cron Job Updates: Replace direct updates to msg.status, msg.error, etc., with database UPDATE operations (e.g., prisma.scheduledSms.update({ where: { id: msg.id }, data: { status: 'sent', vonageMessageId: messageId, attemptCount: { increment: 1 }, lastAttemptAt: now } })). Handle updates for 'failed', 'permanently_failed', and incrementing attempt_count.