code examples

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

Build SMS Scheduling & Reminders with Node.js Fastify: Complete 2025 Guide

Build a production-ready SMS scheduling system using Node.js, Fastify, and Vonage API. Complete tutorial with job scheduling, error handling, and deployment best practices.

You'll build a robust SMS scheduling and reminder application using Node.js with the Fastify framework and the Vonage Messages API. This guide covers everything from initial project setup to deployment considerations, focusing on creating a reliable system.

<!-- DEPTH: Introduction section lacks concrete use cases and expected learning outcomes (Priority: Medium) -->

Project Goal: To create an API endpoint that accepts requests to send an SMS message at a specified future time. This system will be built with scalability and reliability in mind, although this specific implementation uses an in-memory scheduler suitable for simpler use cases or as a starting point.

Problem Solved: Automates the process of sending timely SMS notifications, reminders, or alerts without requiring manual intervention at the exact send time. Useful for appointment reminders, notification systems, marketing campaigns, and more.

<!-- EXPAND: Could benefit from real-world examples or success metrics (Type: Enhancement) -->

Technologies Used:

  • Node.js: The JavaScript runtime environment.
  • Fastify: A high-performance, low-overhead Node.js web framework. Chosen for its speed, extensibility via hooks, and built-in schema validation.
  • Vonage Messages API: A powerful API for sending messages across various channels, including SMS. Chosen for its reliability and feature set.
    • SMS Character Limits: Message body supports up to 1,600 characters. Single SMS is 160 characters (GSM-7) or 70 characters (Unicode/emoji). Messages are automatically split into 153-character segments (GSM-7) or 67-character segments (Unicode) and reassembled by the recipient's device.
    • Throughput Limits: Default rate limit is 30 API requests per second per API key. SMS gateway supports up to 2.5 million messages per day (approximately 30 SMS/second sustained). For US 10DLC, throughput varies by brand trust score and campaign type (AT&T: per-minute limits, T-Mobile/Sprint: per-brand per-day limits, other carriers: 600 TPM per number).
    • Reliability: 99.95% uptime SLA guarantee.
  • @vonage/server-sdk: The official Vonage Node.js library for easy API interaction.
  • node-schedule: A flexible job scheduler for Node.js (used for the core scheduling logic in this guide).
  • dotenv: To manage environment variables securely.
  • pino-pretty: To format Fastify's logs nicely during development.
<!-- GAP: Missing cost considerations for Vonage API usage (Type: Substantive) -->

System Architecture:

text
+--------+      +-----------------+      +--------------------+      +-------------+
| Client |----->|  Fastify API    |----->| Scheduling Service |----->| Vonage API  |
| (User/ |      | (POST /schedule)|      |  (node-schedule)   |      | (Sends SMS) |
| System)|      +-----------------+      +--------------------+      +-------------+
|        |             |                                |
|        |             +------------- Logs -------------+
+--------+
<!-- EXPAND: Architecture diagram could include error handling flows and retry mechanisms (Type: Enhancement) -->

Prerequisites:

  • Node.js (LTS version recommended) and npm/yarn installed. As of January 2025, Node.js 22 (codenamed 'Jod') is the current LTS version recommended for production, with support until April 2027. Node.js 18.x reaches end-of-life on April 30, 2025 – plan to upgrade to Node.js 20 or 22 for continued support.
  • Note: This guide uses Fastify v5.x (latest: 5.6.1 as of October 2025) which requires Node.js 20+ and includes improved performance, security fixes, and streamlined APIs.
  • Note: This guide uses @vonage/server-sdk v3.24.1 (September 2025), which is fully promise-based with TypeScript support for better IDE integration.
  • A Vonage API account. Sign up for free if you don't have one.
  • A Vonage virtual phone number capable of sending SMS.
  • Basic familiarity with the command line/terminal.
  • ngrok (optional, useful for testing incoming Vonage webhooks, such as delivery receipts or inbound messages, during local development if you extend this guide's functionality).
  • Vonage CLI (optional but recommended for setup): npm install -g @vonage/cli
<!-- GAP: Missing verification steps to confirm prerequisites are properly installed (Type: Substantive) -->

Final Outcome: A running Fastify application with a /schedule-sms endpoint that takes a recipient number, message content, and a future send time, schedules the SMS using node-schedule, and sends it via the Vonage Messages API at the designated time.


How Do You Set Up a Node.js SMS Scheduling Project?

Initialize your Node.js project, install dependencies, and configure the basic Fastify server and Vonage credentials.

<!-- DEPTH: Setup section needs troubleshooting guidance for common installation issues (Priority: Medium) -->

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

bash
mkdir fastify-vonage-scheduler
cd fastify-vonage-scheduler

2. Initialize Node.js Project: Create a package.json file.

bash
npm init -y

3. Install Dependencies: We need Fastify, the Vonage SDK, node-schedule for scheduling, and dotenv for environment variables. We'll also add pino-pretty as a development dependency for readable logs.

bash
npm install fastify @vonage/server-sdk node-schedule dotenv
npm install --save-dev pino-pretty
<!-- GAP: Missing version compatibility verification steps (Type: Substantive) -->

4. Configure package.json Start Script: Modify the scripts section in your package.json to easily start the server, using pino-pretty for development logs.

json
// package.json
{
  // ... other fields
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "node src/server.js | pino-pretty",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ... other fields
}

5. Create Project Structure: Organize the code for better maintainability.

bash
mkdir src
touch src/server.js
touch src/scheduler.js
touch .env
touch .gitignore

6. Configure .gitignore: Prevent sensitive files and generated folders from being committed to version control.

text
# .gitignore
node_modules/
.env
*.log
private.key

7. Set up Environment Variables (.env): Create a .env file in the project root. This file will store your Vonage credentials and other configurations. Never commit this file to Git.

dotenv
# .env

# Vonage Credentials (Use Application ID and Private Key for server-side apps)
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_APP_ID=YOUR_VONAGE_APPLICATION_ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # The number SMS will be sent FROM

# Server Configuration
PORT=3000

# Simple API Key for basic authentication (replace with a strong random string)
API_ACCESS_KEY=YOUR_SECRET_API_ACCESS_KEY
<!-- GAP: Missing guidance on generating strong API access keys (Type: Substantive) -->

8. Obtain Vonage Credentials and Set Up Application:

  • API Key & Secret: Find these on the Vonage API Dashboard homepage. Add them to your .env file.
  • Vonage Application: You need a Vonage Application configured for Messages.
    • Using the Dashboard:
      1. Go to Your Applications > Create a new application.
      2. Give it a name (e.g., "Fastify Scheduler").
      3. Click "Generate public and private key". Save the private.key file into your project's root directory (or a secure location referenced by VONAGE_PRIVATE_KEY_PATH). The public key is stored by Vonage. For this local development guide, we save it to the project root (and ensure it's in .gitignore). In production, never commit this file; use secure methods like environment variables or secret management systems.
      4. Enable the "Messages" capability.
      5. For Status URL and Inbound URL, you can initially enter placeholder URLs like https://example.com/status and https://example.com/inbound. If you later want delivery receipts or inbound messages, you'll need to update these with publicly accessible endpoints (using ngrok for local development).
      6. Click "Generate new application".
      7. Copy the generated Application ID and add it to your .env file (VONAGE_APP_ID).
    • Using the Vonage CLI (Recommended):
      1. Configure the CLI with your API Key and Secret:
        bash
        vonage config:set --apiKey=YOUR_VONAGE_API_KEY --apiSecret=YOUR_VONAGE_API_SECRET
      2. Create the application (this automatically generates private.key in the current directory):
        bash
        vonage apps:create "Fastify Scheduler" --messages_status_url=https://example.com/status --messages_inbound_url=https://example.com/inbound --keyfile=private.key
        Note the Application ID outputted and add it to your .env file.
  • Vonage Number:
    1. Purchase a number if you don't have one: Go to Numbers > Buy Numbers on the dashboard, or use the CLI:
      bash
      # Search for available US numbers with SMS capability
      vonage numbers:search US --features=SMS
      # Buy one of the listed numbers
      vonage numbers:buy <NUMBER_TO_BUY> US
    2. Link the number to your application: Go to Your Applications > Find your "Fastify Scheduler" app > Link Numbers, or use the CLI:
      bash
      vonage apps:link --number=<YOUR_VONAGE_VIRTUAL_NUMBER> <YOUR_VONAGE_APPLICATION_ID>
    3. Add this number to your .env file (VONAGE_NUMBER).
<!-- GAP: Missing cost information for purchasing and using Vonage numbers (Type: Substantive) --> <!-- EXPAND: Could add screenshots or visual guides for dashboard configuration (Type: Enhancement) -->

9. Basic Fastify Server Setup: Add the initial Fastify server code to src/server.js.

javascript
// src/server.js
'use strict';

// Load environment variables early
require('dotenv').config();

const fastify = require('fastify')({
  logger: true, // Enable built-in Pino logger
});

// Basic health check route
fastify.get('/health', async (request, reply) => {
  return { status: 'ok' };
});

// Run the server
const start = async () => {
  try {
    const port = process.env.PORT || 3000;
    await fastify.listen({ port: parseInt(port, 10), host: '0.0.0.0' });
    // Fastify's logger will automatically log the listening address if enabled
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

You can now run npm run dev in your terminal. You should see log output indicating the server is listening, and visiting http://localhost:3000/health should return {"status":"ok"}. Press Ctrl+C to stop the server.

<!-- GAP: Missing troubleshooting steps if health check fails (Type: Substantive) -->

How Do You Implement SMS Scheduling with node-schedule?

Create a separate module to handle the scheduling logic using node-schedule and interact with the Vonage SDK.

<!-- DEPTH: Section needs explanation of how node-schedule works internally (Priority: Medium) -->

1. Create the Scheduler Module: Populate src/scheduler.js.

javascript
// src/scheduler.js
'use strict';

const schedule = require('node-schedule');
const { Vonage } = require('@vonage/server-sdk');
const fs = require('fs'); // To read the private key file

// --- Vonage SDK Initialization ---
// Ensure environment variables are loaded (though server.js does this, defense in depth)
require('dotenv').config();

// Validate required environment variables for JWT auth
const requiredEnv = [
  'VONAGE_APP_ID',
  'VONAGE_PRIVATE_KEY_PATH',
  'VONAGE_NUMBER'
  // Note: API Key/Secret are included in the constructor below for potential
  // SDK fallback or use with other APIs, but aren't strictly required *just*
  // for JWT authentication with the Messages API used in this example.
];

for (const variable of requiredEnv) {
  if (!process.env[variable]) {
    console.error(`Error: Missing required environment variable ${variable}`);
    process.exit(1); // Exit if critical config is missing
  }
}

let privateKey;
try {
    privateKey = fs.readFileSync(process.env.VONAGE_PRIVATE_KEY_PATH);
} catch (err) {
    console.error(`Error reading private key from ${process.env.VONAGE_PRIVATE_KEY_PATH}:`, err);
    process.exit(1);
}

const vonage = new Vonage({
  apiKey: process.env.VONAGE_API_KEY,
  apiSecret: process.env.VONAGE_API_SECRET,
  applicationId: process.env.VONAGE_APP_ID,
  privateKey: privateKey,
});

const scheduledJobs = {}; // Keep track of scheduled jobs (in-memory)

/**
 * Schedules an SMS message to be sent at a specific time.
 *
 * @param {string} jobId - A unique identifier for this job.
 * @param {string} recipientNumber - The phone number to send the SMS to (E.164 format).
 * @param {string} messageText - The content of the SMS message.
 * @param {Date} sendAt - The Date object representing when to send the message.
 * @returns {boolean} - True if scheduled successfully, false otherwise.
 */
const scheduleSms = (jobId, recipientNumber, messageText, sendAt) => {
  // Basic input validation
  if (!jobId || !recipientNumber || !messageText || !(sendAt instanceof Date) || sendAt <= new Date()) {
      console.error(`[Scheduler] Invalid input for job ${jobId}. Send time must be in the future.`);
      return false;
  }

  // Prevent duplicate job IDs
  if (scheduledJobs[jobId]) {
      console.warn(`[Scheduler] Job with ID ${jobId} already exists. Skipping.`);
      return false; // Or handle update/reschedule logic here
  }

  console.log(`[Scheduler] Scheduling job ${jobId} to send SMS to ${recipientNumber} at ${sendAt.toISOString()}`);

  const job = schedule.scheduleJob(sendAt, async () => {
    console.log(`[Scheduler] Executing job ${jobId}: Sending SMS to ${recipientNumber}`);
    try {
      const resp = await vonage.messages.send({
        message_type: 'text',
        to: recipientNumber,
        from: process.env.VONAGE_NUMBER, // Your Vonage number
        channel: 'sms',
        text: messageText,
      });

      console.log(`[Scheduler] Job ${jobId}: Vonage API response - Message UUID: ${resp.message_uuid}`);
      // In a production system, you might store this UUID and status

    } catch (err) {
      // Note: err.response.data often contains structured error info from Vonage
      console.error(`[Scheduler] Job ${jobId}: Failed to send SMS to ${recipientNumber}. Error:`, err?.response?.data || err.message || err);
      // Implement retry logic here if needed (use with caution for in-memory)
    } finally {
       // Clean up the job from our tracker once it's run (or attempted)
       delete scheduledJobs[jobId];
       console.log(`[Scheduler] Job ${jobId} finished execution.`);
    }
  });

  if (job) {
      scheduledJobs[jobId] = job; // Store the job object
      console.log(`[Scheduler] Job ${jobId} successfully scheduled.`);
      return true;
  } else {
      console.error(`[Scheduler] Failed to schedule job ${jobId}.`);
      return false;
  }
};

/**
 * Cancels a previously scheduled SMS job.
 *
 * @param {string} jobId - The unique identifier of the job to cancel.
 * @returns {boolean} - True if cancelled successfully, false otherwise.
 */
const cancelSmsJob = (jobId) => {
    if (scheduledJobs[jobId]) {
        const success = scheduledJobs[jobId].cancel();
        if (success) {
            delete scheduledJobs[jobId];
            console.log(`[Scheduler] Job ${jobId} cancelled successfully.`);
            return true;
        } else {
            console.error(`[Scheduler] Failed to cancel job ${jobId}.`);
            return false;
        }
    } else {
        console.warn(`[Scheduler] Job ${jobId} not found for cancellation.`);
        return false;
    }
};

// Optional: Add functions to list jobs, etc.

// --- Graceful Shutdown ---
// This is crucial for in-memory schedulers to attempt to finish work.
// In a real production setup with external queues/DBs, this is less critical.
process.on('SIGINT', () => {
  console.log('[Scheduler] Received SIGINT. Shutting down scheduler...');
  schedule.gracefulShutdown().then(() => {
      console.log('[Scheduler] Shutdown complete.');
      process.exit(0);
  });
});

process.on('SIGTERM', () => {
  console.log('[Scheduler] Received SIGTERM. Shutting down scheduler...');
  schedule.gracefulShutdown().then(() => {
      console.log('[Scheduler] Shutdown complete.');
      process.exit(0);
  });
});


module.exports = {
  scheduleSms,
  cancelSmsJob,
  // Export other functions if needed
};
<!-- GAP: Missing explanation of E.164 phone number format with validation examples (Type: Critical) --> <!-- EXPAND: Could add performance considerations for large numbers of scheduled jobs (Type: Enhancement) -->

Explanation:

  1. Dependencies: Imports node-schedule, Vonage SDK, and fs.
  2. Environment Variables: Loads and validates required Vonage credentials for JWT authentication (APP_ID, PRIVATE_KEY_PATH, NUMBER). Exits if crucial variables are missing. API Key/Secret are passed to the SDK constructor but not strictly validated here as they aren't essential for the JWT auth path shown.
  3. Vonage Initialization: Creates a Vonage instance using the Application ID and Private Key method, which is generally preferred for server applications. It reads the private key file content.
  4. scheduledJobs: An in-memory object to keep track of active node-schedule jobs. This is the core limitation – if the server restarts, all jobs in this object are lost.
  5. scheduleSms:
    • Takes a unique jobId, recipient, message, and Date object for the send time.
    • Performs basic validation (future time, non-empty fields).
    • Checks if jobId already exists.
    • Uses schedule.scheduleJob(sendAt, callback) to schedule the task.
    • The async callback function:
      • Logs execution.
      • Calls vonage.messages.send() with the required SMS parameters (message_type, to, from, channel, text).
      • Logs the Vonage response (especially the message_uuid for tracking).
      • Includes basic error handling, logging the error details (including potentially structured data in err.response.data). Note: Robust retry logic is complex for in-memory scheduling and often better handled by external queues.
      • Uses a finally block to remove the job from scheduledJobs after execution (or failure).
    • Stores the returned node-schedule job object in scheduledJobs.
    • Returns true on successful scheduling, false otherwise.
  6. cancelSmsJob: Allows cancelling a job by ID using job.cancel() and removing it from scheduledJobs.
  7. Graceful Shutdown: Listens for SIGINT (Ctrl+C) and SIGTERM (common process termination signal) to call schedule.gracefulShutdown(). This attempts to allow running jobs to complete before exiting.
<!-- GAP: Missing examples of common Vonage API error responses and how to handle them (Type: Substantive) -->

How Do You Build the Fastify API for SMS Scheduling?

Create the Fastify route to accept scheduling requests.

1. Update server.js: Modify src/server.js to include the scheduler, define the API route, add validation, and basic authentication.

javascript
// src/server.js
'use strict';

// Load environment variables early
require('dotenv').config();
const { randomUUID } = require('crypto'); // For generating unique job IDs

// Import scheduler BEFORE Fastify instance creation if it has critical setup logic
const scheduler = require('./scheduler');

const fastify = require('fastify')({
  logger: true,
});

// --- Basic Authentication Hook ---
// WARNING: This is a very basic API key check.
// Production systems should use more robust methods (JWT, OAuth).
fastify.addHook('onRequest', async (request, reply) => {
  // Allow health check without auth
  if (request.url === '/health') {
    return;
  }

  const apiKey = request.headers['x-api-key'];
  if (!apiKey || apiKey !== process.env.API_ACCESS_KEY) {
    fastify.log.warn(`Unauthorized attempt from ${request.ip}`);
    reply.code(401).send({ error: 'Unauthorized' });
    return reply; // Stop processing
  }
});

// --- Request Validation Schema ---
const scheduleSmsSchema = {
  body: {
    type: 'object',
    required: ['to', 'message', 'sendAt'],
    properties: {
      to: { type: 'string', description: 'Recipient phone number in E.164 format (e.g., +15551234567)' }, // Add pattern validation if desired
      message: { type: 'string', minLength: 1, maxLength: 1600 }, // Generous max length, Vonage handles concatenation
      sendAt: { type: 'string', format: 'date-time', description: 'ISO 8601 timestamp (e.g., 2025-12-31T23:59:59Z or 2025-12-31T18:59:59-05:00)' },
      jobId: { type: 'string', description: 'Optional unique ID for the job. If not provided, one will be generated.' } // Optional ID
    },
  },
  response: {
    202: { // 202 Accepted is appropriate as the job is scheduled, not sent yet
      type: 'object',
      properties: {
        message: { type: 'string' },
        jobId: { type: 'string' },
        scheduledAt: { type: 'string', format: 'date-time' },
      },
    },
    400: { // Bad Request for validation errors or scheduling failures
      type: 'object',
      properties: {
        error: { type: 'string' },
      },
    },
    500: { // Internal Server Error
        type: 'object',
        properties: {
            error: { type: 'string' }
        }
    }
  },
};

// --- API Route ---
fastify.post('/schedule-sms', { schema: scheduleSmsSchema }, async (request, reply) => {
  const { to, message, sendAt: sendAtIsoString, jobId: requestedJobId } = request.body;

  let sendAt;
  try {
    sendAt = new Date(sendAtIsoString);
    // Check if the date is valid and in the future
    if (isNaN(sendAt.getTime()) || sendAt <= new Date()) {
      request.log.warn({ body: request.body }, 'Invalid or past sendAt date received.');
      reply.code(400).send({ error: 'Invalid date format or date is not in the future.' });
      return;
    }
  } catch (e) {
    request.log.error({ body: request.body, err: e }, 'Failed to parse sendAt date.');
    reply.code(400).send({ error: 'Invalid date format for sendAt.' });
    return;
  }

  // Generate a unique ID if not provided
  const jobId = requestedJobId || randomUUID();

  try {
    const scheduled = scheduler.scheduleSms(jobId, to, message, sendAt);

    if (scheduled) {
      reply.code(202).send({
        message: 'SMS scheduled successfully.',
        jobId: jobId,
        scheduledAt: sendAt.toISOString(),
      });
    } else {
        // This could be due to duplicate jobId or other scheduler logic failure
        request.log.warn({ jobId, body: request.body }, 'Failed to schedule SMS via scheduler module.');
        reply.code(400).send({ error: 'Failed to schedule SMS. Check logs for details (e.g., duplicate Job ID or invalid input).' });
    }
  } catch (error) {
    request.log.error({ err: error, jobId, body: request.body }, 'Internal error during SMS scheduling.');
    reply.code(500).send({ error: 'An internal server error occurred while scheduling the SMS.' });
  }
});

// Add a route to cancel a job (optional)
fastify.delete('/schedule-sms/:jobId', async (request, reply) => {
    const { jobId } = request.params;
    try {
        const cancelled = scheduler.cancelSmsJob(jobId);
        if (cancelled) {
            reply.code(200).send({ message: `Job ${jobId} cancelled successfully.` });
        } else {
            // Could be job not found or failed to cancel
            reply.code(404).send({ error: `Job ${jobId} not found or could not be cancelled.` });
        }
    } catch (error) {
        request.log.error({ err: error, jobId }, 'Internal error during job cancellation.');
        reply.code(500).send({ error: 'An internal server error occurred.' });
    }
});


// Basic health check route (no auth needed)
fastify.get('/health', async (request, reply) => {
  return { status: 'ok' };
});

// --- Global Error Handler ---
fastify.setErrorHandler((error, request, reply) => {
  request.log.error({ err: error }, 'An error occurred processing the request');
  // Hide detailed errors in production
  if (process.env.NODE_ENV !== 'development') {
      reply.status(500).send({ error: 'Internal Server Error' });
  } else {
      // Provide more detail in development
      reply.status(error.statusCode || 500).send({
          error: error.message,
          statusCode: error.statusCode,
          stack: error.stack // Careful with exposing stack traces
      });
  }
});


// Run the server
const start = async () => {
  try {
    const port = process.env.PORT || 3000;
    // Listen on all network interfaces, important for Docker/deployment
    await fastify.listen({ port: parseInt(port, 10), host: '0.0.0.0' });
    // No need to log here, Fastify's logger does it if enabled
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();
<!-- DEPTH: API route section lacks examples of common validation errors and responses (Priority: Medium) --> <!-- GAP: Missing rate limiting implementation details (Type: Substantive) -->

Explanation:

  1. dotenv.config(): Ensures environment variables are loaded first.
  2. randomUUID: Imported for generating unique job IDs if the client doesn't provide one.
  3. Scheduler Import: The scheduler.js module is imported.
  4. Authentication Hook (onRequest):
    • A simple hook checks for an x-api-key header on all requests (except /health).
    • It compares the provided key with process.env.API_ACCESS_KEY.
    • If invalid, it sends a 401 Unauthorized response and stops further processing for that request. This is basic; use proper auth in production.
  5. Validation Schema (scheduleSmsSchema):
    • Defines the expected structure and types for the POST /schedule-sms request body using Fastify's schema validation.
    • Specifies to, message, and sendAt as required. jobId is optional.
    • Uses format: 'date-time' for sendAt, expecting an ISO 8601 string.
    • Defines expected response formats for success (202) and errors (400, 500). Fastify uses this for automatic validation and serialization.
  6. /schedule-sms Route (POST):
    • Applies the scheduleSmsSchema for automatic validation.
    • Extracts data from request.body.
    • Date Handling: Parses the sendAt ISO string into a Date object. Includes crucial validation to ensure it's a valid date and in the future.
    • Generates a jobId using randomUUID() if one isn't provided in the request.
    • Calls scheduler.scheduleSms with the validated data.
    • Sends a 202 Accepted response if scheduling is successful, including the jobId and the scheduled time.
    • Sends a 400 Bad Request if the scheduler module returns false (e.g., duplicate ID).
    • Includes a try...catch block for unexpected errors during scheduling, returning a 500 Internal Server Error.
  7. /schedule-sms/:jobId Route (DELETE): (Optional) Provides an endpoint to cancel a scheduled job using the cancelSmsJob function from the scheduler module.
  8. Global Error Handler (setErrorHandler): A catch-all handler for errors not caught within specific routes. Logs the error and sends a generic 500 response in production or more details in development.
  9. Server Start: Listens on 0.0.0.0 to be accessible from outside its container/machine in deployed environments.
<!-- GAP: Missing endpoint documentation or OpenAPI/Swagger specification (Type: Substantive) -->

Testing the API Endpoint:

Start the server: npm run dev

Use curl (or Postman/Insomnia) to send a request. Replace placeholders with your actual data and API key. Schedule it a minute or two in the future to test.

bash
# Replace YOUR_SECRET_API_ACCESS_KEY, YOUR_RECIPIENT_NUMBER
# Adjust the sendAt timestamp to be ~2 minutes in the future
# Use a future ISO 8601 string like the example below.

curl -X POST http://localhost:3000/schedule-sms \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_SECRET_API_ACCESS_KEY" \
  -d '{
    "to": "YOUR_RECIPIENT_NUMBER",
    "message": "Hello from Fastify Scheduler! This is a test.",
    "sendAt": "2025-04-20T15:30:00Z"
  }' # <-- Replace with a future ISO 8601 time

# Expected Response (Status Code 202):
# {
#   "message": "SMS scheduled successfully.",
#   "jobId": "some-generated-uuid-string",
#   "scheduledAt": "2025-04-20T15:30:00.000Z"
# }

# Example for Cancellation (use the jobId from the previous response)
# curl -X DELETE http://localhost:3000/schedule-sms/some-generated-uuid-string \
#  -H "x-api-key: YOUR_SECRET_API_ACCESS_KEY"
#
# Expected Response (Status Code 200):
# {
#   "message": "Job some-generated-uuid-string cancelled successfully."
# }

Check your server logs for scheduling confirmation and execution logs when the time arrives. Verify the SMS is received on the target phone.

<!-- EXPAND: Could add examples using Postman or other API testing tools (Type: Enhancement) --> <!-- GAP: Missing debugging tips for when SMS doesn't arrive (Type: Substantive) -->

4. Integrating with Vonage (Covered in Section 2)

The core integration happens within src/scheduler.js where the @vonage/server-sdk is initialized and used.

  • Configuration: Handled via .env file (VONAGE_APP_ID, VONAGE_PRIVATE_KEY_PATH, VONAGE_NUMBER, VONAGE_API_KEY, VONAGE_API_SECRET).
  • Authentication: The SDK uses the Application ID and the content of the private key file for authentication when privateKey is provided in the constructor.
  • Sending SMS: The vonage.messages.send({...}) method is used within the scheduled job's callback. Key parameters:
    • message_type: 'text'
    • to: Recipient number (from API request)
    • from: Your Vonage number (from .env)
    • channel: 'sms'
    • text: Message content (from API request)
  • API Keys & Secrets: Stored securely in .env and accessed via process.env. The private.key file should also be treated as a secret and have appropriate file permissions (readable only by the application user). In production, consider injecting these via environment variables directly rather than a .env file.
<!-- GAP: Missing webhook configuration for delivery receipts and status updates (Type: Substantive) --> <!-- EXPAND: Could add examples of handling delivery receipts (Type: Enhancement) -->

What Error Handling and Logging Should You Implement?

  • Error Handling Strategy:
    • Validation Errors: Handled by Fastify's schema validation. Invalid requests receive 400 Bad Request.
    • Scheduling Logic Errors: Caught within the API route (/schedule-sms) and scheduler module (scheduleSms). Invalid inputs or scheduler failures return 400. Unexpected errors return 500.
    • Vonage API Errors: Caught within the try...catch block of the scheduled job's callback in scheduler.js. Errors are logged. The logged err?.response?.data often contains structured JSON error information directly from the Vonage API, which is very useful for debugging.
    • Global Errors: Handled by Fastify's setErrorHandler for uncaught exceptions.
  • Logging:
    • Fastify's built-in Pino logger is enabled (logger: true).
    • Development logs are prettified using pino-pretty via the npm run dev script.
    • Specific log messages are added in scheduler.js for scheduling, execution, success, and errors.
    • API route handlers log warnings/errors for specific failure conditions (e.g., invalid date, scheduling failure).
    • The global error handler logs all uncaught errors.
  • Retry Mechanisms:
    • Current Implementation: No automatic retries are implemented for failed Vonage API calls within the scheduled job.
    • Recommendation: For production-critical reminders, relying on in-memory, in-process retries is fragile. A better approach involves:
      1. Persistence: Storing scheduled jobs in a database.
      2. Queueing System: Using a dedicated message queue (like RabbitMQ, Redis with BullMQ, AWS SQS) to manage job execution and retries.
      3. Worker Process: A separate process dequeues jobs and attempts to send the SMS via Vonage.
      4. Retry Strategy: The queue system handles retries with exponential backoff upon failure.
      5. Dead-Letter Queue: Failed jobs (after exhausting retries) are moved to a dead-letter queue for investigation.
    • Production Alternative: BullMQ (Recommended for 2025): BullMQ is a modern, Redis-based job queue system written in TypeScript that offers significantly better performance than node-schedule for production environments. Key advantages include:
      • Persistence: Jobs survive server restarts and are stored in Redis.
      • Distributed Processing: Multiple worker instances can process jobs in parallel.
      • Built-in Retries: Automatic retry logic with exponential backoff and configurable strategies.
      • Delayed Jobs: Native support for scheduling jobs at specific times or after delays.
      • Job Priorities: Prioritize critical messages over standard notifications.
      • Observability: Built-in metrics, progress tracking, and monitoring capabilities.
      • Performance: Lower latency and higher throughput compared to alternatives like Agenda or Bull (the original library).
      • Install with: npm install bullmq (requires Redis server).
    • Simple In-Process Retry (Use with Caution): Implementing retries directly within the node-schedule callback (e.g., using setTimeout in the catch block) is complex to manage correctly, can block the event loop if synchronous operations occur within the retry logic, and crucially, does not solve the fundamental problem of losing scheduled retries if the server restarts.
<!-- DEPTH: Error handling section needs specific code examples for each error type (Priority: High) --> <!-- GAP: Missing monitoring and alerting recommendations (Type: Substantive) --> <!-- EXPAND: Could add structured logging examples with correlation IDs (Type: Enhancement) -->

Why Should You Use a Database for Production SMS Scheduling?

This guide uses an in-memory approach for simplicity. For any production system, persisting scheduled jobs is essential.

Why a Database is Crucial:

  • Persistence: Scheduled jobs survive server restarts and crashes.
  • Scalability: Allows distributing scheduling/worker logic across multiple instances.
  • Tracking: Enables storing job status (scheduled, processing, sent, failed, cancelled), Vonage message UUIDs, timestamps, and error details.
  • Querying: Allows listing pending jobs, searching history, building dashboards.
<!-- GAP: Missing migration path from in-memory to database-backed implementation (Type: Substantive) -->

Conceptual Schema (e.g., PostgreSQL):

sql
CREATE TABLE scheduled_sms (
    job_id VARCHAR(255) PRIMARY KEY, -- Or UUID type
    recipient_number VARCHAR(20) NOT NULL,
    message_text TEXT NOT NULL,
    send_at TIMESTAMPTZ NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'scheduled', -- scheduled, processing, sent, failed, cancelled
    vonage_message_uuid VARCHAR(255) NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_attempt_at TIMESTAMPTZ NULL,
    error_message TEXT NULL,
    retry_count INT NOT NULL DEFAULT 0
);

-- Index for querying pending jobs efficiently
CREATE INDEX idx_scheduled_sms_pending ON scheduled_sms (status, send_at)
WHERE status = 'scheduled' OR status = 'failed'; -- Adjust based on retry logic
<!-- EXPAND: Could add examples for MongoDB schema design (Type: Enhancement) -->

Implementation Approach (If adding DB):

  1. Choose Database & ORM/Client: Select a database (PostgreSQL, MongoDB, etc.) and a suitable Node.js client or ORM (e.g., pg, node-postgres, Sequelize, Prisma, Mongoose).
  2. Integrate with Fastify: Use a Fastify plugin for your chosen database (e.g., fastify-postgres, @fastify/mongodb).
  3. Modify Scheduler:
    • Instead of node-schedule, create a database polling mechanism or use a queue.
    • Polling: A background task (e.g., using setInterval or a cron job) queries the scheduled_sms table for jobs where status = 'scheduled' and send_at <= NOW().
    • Queueing (Better): The API endpoint adds the job details to a message queue. A separate worker process listens to the queue.
  4. Data Layer: Create functions to insert, update (status, message ID, error), and query jobs in the database.
  5. Worker Logic: The worker/poller fetches due jobs, attempts sending via Vonage, and updates the job status in the database.
<!-- DEPTH: Implementation approach needs step-by-step code examples (Priority: High) --> <!-- GAP: Missing performance benchmarks comparing in-memory vs. database solutions (Type: Substantive) -->

Frequently Asked Questions (FAQ)

What Node.js version do I need for SMS scheduling with Fastify?

You need Node.js 20 or later for this SMS scheduling implementation. As of January 2025, Node.js 22 (codenamed 'Jod') is the current LTS version recommended for production, with support until April 2027. This guide uses Fastify v5.x (latest: 5.6.1), which requires Node.js 20+ and includes improved performance, security fixes, and streamlined APIs. Node.js 18.x reaches end-of-life on April 30, 2025, so plan to upgrade to Node.js 20 or 22 for continued support.

What are the SMS character limits for Vonage Messages API?

Vonage Messages API supports message bodies up to 1,600 characters. A single SMS is 160 characters for GSM-7 encoding or 70 characters for Unicode (emoji, non-Latin characters). Messages exceeding these limits are automatically split into segments: 153 characters per segment for GSM-7, or 67 characters per segment for Unicode. The recipient's device reassembles these segments into a complete message. Vonage handles segmentation automatically, so you can send longer messages without manual splitting.

<!-- EXPAND: Could add pricing implications for multi-segment messages (Type: Enhancement) -->

How do you authenticate with the Vonage Messages API?

Vonage Messages API uses JWT (JSON Web Token) authentication with your Application ID and private key. This guide uses the @vonage/server-sdk v3.24.1, which initializes with your VONAGE_APP_ID and the content of your private.key file. The SDK automatically generates and manages JWT tokens for API requests. Store your Application ID in environment variables and keep the private key file secure with appropriate file permissions (readable only by the application user). Never commit private keys to version control.

What's the difference between node-schedule and BullMQ for SMS scheduling?

node-schedule is an in-memory job scheduler suitable for simple use cases and prototypes, but all scheduled jobs are lost on server restart. BullMQ is a modern, Redis-based job queue system written in TypeScript that's recommended for production environments. Key advantages of BullMQ include: persistence (jobs stored in Redis survive restarts), distributed processing (multiple worker instances), built-in retries with exponential backoff, delayed job support, job priorities, observability with metrics and progress tracking, and significantly better performance with lower latency and higher throughput. Install BullMQ with npm install bullmq (requires Redis server).

<!-- EXPAND: Could add migration guide from node-schedule to BullMQ (Type: Enhancement) -->

What are the Vonage SMS API throughput limits?

Vonage provides a default rate limit of 30 API requests per second per API key. The SMS gateway supports up to 2.5 million messages per day (approximately 30 SMS/second sustained throughput). For US 10DLC (10-Digit Long Code) messaging, throughput varies based on brand trust score and campaign type: AT&T applies per-minute limits based on campaign type, T-Mobile and Sprint allocate per-brand per-day limits based on brand trust score, and other carriers enforce 600 TPM (transactions per minute) per number. Vonage offers a 99.95% uptime SLA guarantee.

<!-- GAP: Missing guidance on requesting throughput limit increases (Type: Substantive) -->

How do you handle SMS scheduling errors in production?

Implement comprehensive error handling at multiple layers: use Fastify's schema validation for request validation (returns 400 Bad Request for invalid input), catch scheduling logic errors in the API route and scheduler module (returns 400 for failures, 500 for unexpected errors), handle Vonage API errors in the scheduled job callback with detailed logging of err?.response?.data for structured error information, and use Fastify's global setErrorHandler for uncaught exceptions. For production systems, implement retry logic with exponential backoff using a message queue like BullMQ, and use dead-letter queues for failed jobs after exhausting retries.

<!-- EXPAND: Could add specific retry strategy examples with backoff calculations (Type: Enhancement) -->

Why should you use a database for production SMS scheduling?

A database is essential for production SMS scheduling because it provides persistence (jobs survive server restarts and crashes), scalability (distribute scheduling/worker logic across multiple instances), tracking (store job status, Vonage message UUIDs, timestamps, and error details), and querying capabilities (list pending jobs, search history, build dashboards). This guide's in-memory approach with node-schedule is suitable for simple use cases or prototypes, but production systems require PostgreSQL, MongoDB, or similar databases with proper indexing for efficient job queries.

How do you cancel a scheduled SMS job?

The guide implements a DELETE /schedule-sms/:jobId endpoint that calls the cancelSmsJob function from the scheduler module. This function uses job.cancel() to stop the scheduled job and removes it from the in-memory scheduledJobs tracker. Include the unique job ID (returned when scheduling) in the DELETE request with your API key in the x-api-key header. In production systems using a database and message queue, implement cancellation by updating the job status to "cancelled" in the database before the worker processes it.

<!-- EXPAND: Could add example curl commands for cancellation (Type: Enhancement) -->

What security measures should you implement for SMS scheduling APIs?

Implement multiple security layers: use API key authentication (this guide uses x-api-key header, but production should use JWT or OAuth 2.0), validate all input with Fastify's schema validation, rate limit API endpoints to prevent abuse, use HTTPS in production (TLS/SSL certificates), store credentials securely in environment variables or secret management systems (AWS Secrets Manager, HashiCorp Vault), implement IP whitelisting for known clients, validate phone numbers use E.164 international format, sanitize message content to prevent injection attacks, and log all API access with request details for security auditing.

<!-- GAP: Missing CORS configuration guidance (Type: Substantive) --> <!-- EXPAND: Could add examples of implementing JWT authentication (Type: Enhancement) -->

How do you deploy a Fastify SMS scheduling application to production?

For production deployment, use containerization with Docker (create a Dockerfile with Node.js 22 base image, copy application files, install dependencies, expose port), orchestrate with Kubernetes or Docker Swarm for scaling and resilience, use environment-based configuration (separate .env files for dev/staging/prod), implement health checks (/health endpoint), use process managers like PM2 for Node.js process management, set up monitoring with Prometheus and Grafana or cloud services (AWS CloudWatch, Datadog), implement structured logging with Pino, use Redis for BullMQ job queue, connect to production databases (PostgreSQL/MongoDB), configure auto-scaling based on queue depth and CPU/memory metrics, and implement CI/CD pipelines for automated testing and deployment.

<!-- DEPTH: Deployment section lacks concrete Docker and Kubernetes configuration examples (Priority: High) --> <!-- GAP: Missing testing strategy and test examples (Type: Critical) --> <!-- EXPAND: Could add cost optimization strategies for cloud deployment (Type: Enhancement) -->

Frequently Asked Questions

How to schedule SMS messages with Fastify?

You can schedule SMS messages by sending a POST request to the `/schedule-sms` endpoint of your Fastify application. This endpoint expects a JSON payload with the recipient's number (`to`), the message content (`message`), and the scheduled send time (`sendAt` in ISO 8601 format).

What is the Vonage Messages API used for?

The Vonage Messages API is a service that allows you to send messages through various channels, including SMS. In this project, it's the core component responsible for delivering the scheduled text messages after they are processed by the Fastify application and the scheduling service.

Why does this project use Fastify?

Fastify is a high-performance Node.js web framework chosen for its speed and extensibility. Its built-in schema validation and hook system make it well-suited for building robust and scalable API endpoints like the SMS scheduler described in the article.

When should I use an in-memory scheduler like node-schedule?

An in-memory scheduler like `node-schedule` is suitable for simpler use cases or development where persistence isn't critical. For production systems, or when reliability is essential, a database-backed system with a queuing mechanism offers more robust scheduling and job management.

Can I cancel a scheduled SMS message?

Yes, you can cancel a scheduled message by sending a DELETE request to `/schedule-sms/:jobId`, where `:jobId` is the unique identifier returned when you scheduled the message. This will remove the scheduled job from the in-memory store and it will not be sent.

How to set up Vonage credentials for this project?

You'll need a Vonage API key and secret, an Application ID, and a private key. Store these securely in a `.env` file in your project root, and ensure it's added to your `.gitignore` file to prevent accidentally committing credentials to version control.

What is node-schedule used for in this application?

`node-schedule` is a Node.js library that handles the scheduling logic. It allows the application to register a task (sending an SMS) to be executed at a specific future date and time, specified in the API request.

How to handle Vonage API errors in the scheduler?

The example code demonstrates basic error handling using `try...catch` blocks around the `vonage.messages.send()` call. Production-ready systems should have robust error handling, including retry mechanisms and detailed logging of `err.response.data` from the Vonage API.

What is the purpose of the x-api-key header?

The `x-api-key` header provides a simple way to authenticate API requests. The provided key is compared against the `API_ACCESS_KEY` environment variable for authorization. This is a basic example; stronger authentication methods like JWT or OAuth are recommended for production.

How does the scheduler handle duplicate job IDs?

The `scheduleSms` function checks for duplicate job IDs. If a job with the same ID already exists, it logs a warning and skips scheduling the new job. This behavior can be modified to allow updates or rescheduling if needed.

What is the best way to handle retries for failed SMS messages in production?

For production systems, using a message queue (like RabbitMQ or Redis) and a database to store job information is the recommended approach for retries. The queue manages the retry logic and the database maintains job state across restarts.

What database schema should I use for storing scheduled SMS messages?

The article provides a conceptual database schema example using PostgreSQL. It includes fields for the job ID, recipient, message, send time, status, Vonage message UUID, timestamps, error details, and retry count. Adjust the schema to fit your specific needs and chosen database.

How to test the /schedule-sms endpoint?

You can use tools like `curl` or Postman to send POST requests to the `/schedule-sms` endpoint. Be sure to include the necessary headers (like `x-api-key`) and a JSON body with the message details and a future `sendAt` time.

Why is graceful shutdown important for this application?

Graceful shutdown allows the scheduler to attempt to complete any currently running jobs before the server stops. This helps to ensure messages are sent even if the application is interrupted. It's especially crucial for in-memory schedulers that don't persist job information.