Automated reminders and scheduled notifications are crucial for many applications, from appointment confirmations to timely alerts. Manually sending these messages is impractical and error-prone. This guide provides a step-by-step walkthrough for building an SMS scheduling service using Node.js with the Fastify framework and the Vonage Messages API.
We will build an API endpoint that accepts SMS details (recipient number, message content, and desired delivery time) and schedules the message for future delivery. Crucially, while this initial implementation uses node-cron
for simplicity, we will heavily emphasize its limitations for production environments due to its lack of persistence. We will outline the necessary steps toward a more resilient, database-backed solution suitable for real-world applications.
Project Overview and Goals
What We'll Build:
- A Node.js application using the Fastify web framework.
- An API endpoint (
POST /schedule-sms
) to receive scheduling requests. - Integration with the Vonage Messages API to send SMS messages.
- An in-memory scheduling mechanism using
node-cron
(with significant production caveats discussed). - Secure handling of API credentials using environment variables.
- Basic input validation and error handling.
Problem Solved:
This application addresses the need to send SMS messages automatically at a specific future date and time, eliminating manual intervention and improving communication reliability for time-sensitive information.
Technologies Involved:
- Node.js: JavaScript runtime environment.
- Fastify: A high-performance, low-overhead Node.js web framework. Chosen for its speed, extensibility, and developer-friendly features like built-in validation.
- Vonage Messages API: A powerful API for sending messages across various channels, including SMS. We'll use the
@vonage/server-sdk
for Node.js. node-cron
: A simple cron-like job scheduler for Node.js. Used here for demonstrating the scheduling concept, but not recommended for production without persistence.dotenv
: Module to load environment variables from a.env
file.- Vonage CLI: Command-line tool for managing Vonage resources.
System Flow (Conceptual):
A client (like curl
or another application) sends a POST
request to the Fastify application's /schedule-sms
endpoint. The Fastify app validates the request, uses node-cron
to schedule the SMS sending task for the specified time, and stores the task in memory. When the scheduled time arrives, the node-cron
job triggers, calling the Vonage Messages API via the Vonage SDK to send the SMS to the recipient's phone. Scheduling actions and sending attempts are logged.
Prerequisites:
- Node.js (LTS version recommended).
- A Vonage API account (Sign up for free credit).
- npm or yarn package manager.
- Basic familiarity with Node.js, APIs, and the command line.
curl
or a tool like Postman for testing the API.
Final Outcome:
By the end of this guide, you will have a running Fastify application capable of accepting API requests to schedule SMS messages for future delivery via Vonage. You will also understand the critical limitations of this basic implementation and the necessary path toward a production-grade system using persistent storage.
1. Setting Up the Project
Let's initialize our project, install dependencies, and configure Vonage access.
1. Create Project Directory
Open your terminal and create a new directory for the project, then navigate into it.
mkdir fastify-vonage-scheduler
cd fastify-vonage-scheduler
2. Initialize Node.js Project
Initialize the project using npm or yarn. This creates a package.json
file.
npm init -y
# or
# yarn init -y
3. Install Dependencies
Install Fastify, the Vonage SDK, node-cron
for scheduling, and dotenv
for environment variables.
npm install fastify @vonage/server-sdk node-cron dotenv
# or
# yarn add fastify @vonage/server-sdk node-cron dotenv
4. Install and Configure Vonage CLI
The Vonage CLI helps manage your Vonage account resources from the terminal.
npm install -g @vonage/cli
Log in to the CLI using your Vonage API Key and Secret found on the Vonage API Dashboard:
# Replace YOUR_VONAGE_API_KEY and YOUR_VONAGE_API_SECRET with your actual credentials
vonage config:set --apiKey=YOUR_VONAGE_API_KEY --apiSecret=YOUR_VONAGE_API_SECRET
5. Create a Vonage Application
Vonage Applications act as containers for your communication settings and credentials. We need one with ""Messages"" capability.
vonage apps:create
Follow the prompts:
- Application Name: Enter a descriptive name (e.g.,
Fastify Scheduler App
). - Select App Capabilities: Use arrow keys and spacebar to select
Messages
. Press Enter. - Create messages webhooks? Enter
n
(No). We aren't receiving messages in this guide. - Allow use of data for AI training? Choose
y
orn
.
The CLI will output details, including an Application ID. Save this ID. It will also prompt you to save a private key file (e.g., private.key
). Save this file securely within your project directory (we'll ensure it's ignored by Git later, but see notes on secure handling in Step 7).
6. Purchase and Link a Vonage Number
You need a Vonage virtual number to send SMS messages from.
- Search for a number (replace
US
with your desired two-letter country code):# Example: Search for a US number vonage numbers:search US
- Buy one of the available numbers:
Save this Vonage number.
# Replace AVAILABLE_NUMBER and COUNTRY_CODE with values from the search results vonage numbers:buy AVAILABLE_NUMBER COUNTRY_CODE
- Link the number to your application:
# Replace YOUR_VONAGE_NUMBER with the number you just bought (e.g., 15551234567) # Replace YOUR_APPLICATION_ID with the ID saved in step 5 vonage apps:link --number=YOUR_VONAGE_NUMBER YOUR_APPLICATION_ID
7. Set Up Environment Variables
Create a .env
file in the root of your project directory. Never commit this file to version control. Add it to your .gitignore
file (see next step).
# .env
# Vonage API Credentials (Get from Vonage Dashboard: https://dashboard.nexmo.com/getting-started-guide)
# While the SDK uses App ID/Private Key for Messages API JWT auth, having Key/Secret
# can be useful for CLI authentication or potentially other Vonage APIs.
# Replace with your actual API Key:
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
# Replace with your actual API Secret:
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
# Vonage Application Credentials (Generated in step 5)
# Replace with your actual Application ID:
VONAGE_APP_ID=YOUR_APPLICATION_ID
# IMPORTANT: Path to your downloaded private key file. Adjust if you saved it elsewhere.
# For production, consider loading key content via environment variable.
VONAGE_PRIVATE_KEY_PATH=./private.key
# Vonage Number (Purchased and linked in step 6)
# Replace with your purchased Vonage number (include country code, e.g., 15551234567):
VONAGE_NUMBER=YOUR_VONAGE_NUMBER
# Server Configuration
PORT=3000
VONAGE_API_KEY
,VONAGE_API_SECRET
: Found on your Vonage Dashboard. Primarily used here for CLI auth.VONAGE_APP_ID
: The ID of the Vonage application created earlier. Crucial for associating API calls with the correct configuration and keys for JWT auth.VONAGE_PRIVATE_KEY_PATH
: The relative path from your project root to theprivate.key
file. Used for JWT authentication with the Messages API. Ensure this file is kept secure and not committed. For better security, especially in production, load the key's content from an environment variable instead.VONAGE_NUMBER
: The Vonage virtual number you purchased and linked. This will be the 'From' number for outgoing SMS.PORT
: The port your Fastify server will listen on.
.gitignore
8. Create Ensure sensitive files and unnecessary directories are not committed to Git. Create a .gitignore
file:
# .gitignore
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment Variables
.env*
# Vonage Private Key - CRITICAL: DO NOT COMMIT YOUR PRIVATE KEY
private.key # Or the specific name of your key file
# OS generated files
.DS_Store
Thumbs.db
9. Basic Server Structure
Create a file named server.js
in your project root. This will be the entry point for our application.
// server.js
'use strict';
// Load environment variables from .env file
require('dotenv').config();
// Import Fastify
const fastify = require('fastify')({
logger: true, // Enable Fastify's built-in logger
});
// Basic health check route
fastify.get('/health', async (request, reply) => {
return { status: 'ok' };
});
// Function to start the server
const start = async () => {
try {
const port = process.env.PORT || 3000;
await fastify.listen({ port: parseInt(port, 10), host: '0.0.0.0' });
// Log after listen resolves, using the actual bound port
fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
You can run this basic server:
node server.js
You should see log output indicating the server is listening. You can access http://localhost:3000/health
in your browser or via curl to verify. Stop the server with Ctrl+C
.
2. Implementing Core Functionality: Scheduling Logic
Now, let's integrate the Vonage SDK and node-cron
to handle the scheduling.
server.js
Imports and Instantiation
1. Update Modify server.js
to include the necessary modules and initialize the Vonage client.
// server.js
'use strict';
require('dotenv').config();
const fastify = require('fastify')({
logger: true,
});
// Import Vonage SDK and node-cron
const { Vonage } = require('@vonage/server-sdk');
const cron = require('node-cron');
const fs = require('fs'); // Needed to read the private key file
const path = require('path'); // For robust path handling
// --- Vonage Client Instantiation ---
// Ensure required environment variables are present
const requiredEnv = [
'VONAGE_APP_ID',
'VONAGE_PRIVATE_KEY_PATH', // Or VONAGE_PRIVATE_KEY_CONTENT
'VONAGE_NUMBER',
'VONAGE_API_KEY', // Included for completeness check, though not directly used in JWT auth
'VONAGE_API_SECRET', // Included for completeness check
];
requiredEnv.forEach((envVar) => {
// Allow alternative private key loading
if (envVar === 'VONAGE_PRIVATE_KEY_PATH' && process.env.VONAGE_PRIVATE_KEY_CONTENT) {
return; // Skip path check if content is provided
}
if (!process.env[envVar]) {
console.error(`Error: Missing required environment variable ${envVar}`);
process.exit(1);
}
});
// Read the private key (prefer content if available, else use path)
let privateKeyContent;
if (process.env.VONAGE_PRIVATE_KEY_CONTENT) {
// Replace escaped newlines if reading from env var
privateKeyContent = process.env.VONAGE_PRIVATE_KEY_CONTENT.replace(/\\n/g, '\n');
fastify.log.info('Using private key content from VONAGE_PRIVATE_KEY_CONTENT.');
} else {
const privateKeyPath = path.resolve(process.env.VONAGE_PRIVATE_KEY_PATH);
try {
privateKeyContent = fs.readFileSync(privateKeyPath);
fastify.log.info(`Read private key from path: ${privateKeyPath}`);
} catch (err) {
console.error(`Error reading private key file at ${privateKeyPath}:`, err);
process.exit(1);
}
}
// Instantiate Vonage client using Application ID and Private Key (Recommended for Messages API)
const vonage = new Vonage({
applicationId: process.env.VONAGE_APP_ID,
privateKey: privateKeyContent,
});
const vonageNumber = process.env.VONAGE_NUMBER;
// --- Placeholder for scheduled tasks (VERY IMPORTANT CAVEAT) ---
// This object will hold references to our cron jobs.
// In a *real* production app, this state is volatile and lost on restart.
// A database + persistent job queue (e.g., BullMQ, Agenda) is essential.
const scheduledTasks = {};
// Basic health check route
fastify.get('/health', async (request, reply) => {
// Include task count for basic monitoring
return { status: 'ok', inMemoryScheduledTaskCount: Object.keys(scheduledTasks).length };
});
// Function to start the server
const start = async () => {
try {
const port = process.env.PORT || 3000;
await fastify.listen({ port: parseInt(port, 10), host: '0.0.0.0' });
// Log after listen resolves, using the actual bound port
fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
// Start the server after routes are defined
// start(); // Will be called after route definition below
Explanation:
- We import
Vonage
,node-cron
,fs
, andpath
. - We add checks to ensure required environment variables are set, exiting if any are missing. This prevents runtime errors later. The check includes API Key/Secret for completeness, although the primary authentication method here (JWT via App ID/Private Key) doesn't directly use them for sending messages. We also add logic to allow using
VONAGE_PRIVATE_KEY_CONTENT
instead of the path. - We use
path.resolve
to get the absolute path to the private key if using the path method. - We read the private key content using
fs.readFileSync
or directly from the environment variable. - We instantiate the
Vonage
client using theapplicationId
and theprivateKey
content. This method uses JWT authentication, preferred for the Messages API. - We store the
VONAGE_NUMBER
in a variable for easy access. - Crucially, we add a
scheduledTasks
object. This is where we'll store references to ournode-cron
jobs. This highlights the in-memory limitation: if the server restarts, this object is cleared, and all scheduled jobs are lost. - The
/health
route is updated slightly to show the count of in-memory tasks.
3. Building the API Layer
Let's create the /schedule-sms
endpoint to accept scheduling requests.
1. Define the API Route and Schema
Add the following route definition within server.js
, before the start()
function call.
// server.js
// ... (imports and Vonage instantiation) ...
const scheduledTasks = {}; // In-memory store for cron jobs
// --- API Route for Scheduling SMS ---
const scheduleSmsSchema = {
body: {
type: 'object',
required: ['to', 'text', 'scheduleTime'],
properties: {
to: {
type: 'string',
description: 'Recipient phone number in E.164 format (e.g., +15551234567)',
// E.164 pattern: '+' followed by 1-15 digits
pattern: '^\\+[1-9]\\d{1,14}$' // Added end anchor $
},
text: {
type: 'string',
description: 'The content of the SMS message',
minLength: 1,
maxLength: 1600 // Vonage supports longer messages, but be mindful of multipart costs
},
scheduleTime: {
type: 'string',
format: 'date-time', // Expect ISO 8601 format (e.g., 2025-04-20T15:30:00Z)
description: 'The time to send the SMS in ISO 8601 format (UTC recommended, e.g., YYYY-MM-DDTHH:mm:ssZ)'
}
},
additionalProperties: false
},
response: {
200: {
type: 'object',
properties: {
message: { type: 'string' },
jobId: { type: 'string' }
}
},
400: {
type: 'object',
properties: {
error: { type: 'string' }
}
},
500: {
type: 'object',
properties: {
error: { type: 'string' }
}
}
}
};
fastify.post('/schedule-sms', { schema: scheduleSmsSchema }, async (request, reply) => {
const { to, text, scheduleTime } = request.body;
const log = request.log; // Use request-specific logger
log.info(`Received schedule request for ${to} at ${scheduleTime}`);
// 1. Validate Schedule Time
const scheduledDate = new Date(scheduleTime);
const now = new Date();
if (isNaN(scheduledDate.getTime())) {
// Should typically be caught by Fastify's 'date-time' format validation, but good practice to double-check
log.warn(`Invalid scheduleTime format received: ${scheduleTime}`);
return reply.status(400).send({ error: 'Invalid date format for scheduleTime. Use ISO 8601 format (e.g., YYYY-MM-DDTHH:mm:ssZ).' });
}
if (scheduledDate <= now) {
log.warn(`Schedule time is in the past: ${scheduleTime}`);
return reply.status(400).send({ error: 'Schedule time must be in the future.' });
}
// 2. Convert Date to Cron Pattern (Simple approach - handles specific time)
// Note: node-cron uses format: 'ss mm HH DD MM DAY' (second_ minute_ hour_ day of month_ month_ day of week)
// We use UTC methods to align with the recommended ISO 8601 Z format.
const cronPattern = `${scheduledDate.getUTCSeconds()} ${scheduledDate.getUTCMinutes()} ${scheduledDate.getUTCHours()} ${scheduledDate.getUTCDate()} ${scheduledDate.getUTCMonth() + 1} *`;
// '*' for Day of Week means it runs regardless of the day_ as the date/month/year specific parts pin it down.
log.info(`Calculated cron pattern: ${cronPattern} (based on UTC time)`);
// 3. Schedule the Job using node-cron
try {
// Generate a unique ID for the job (simple example using timestamp)
const jobId = `sms-${to}-${scheduledDate.getTime()}`;
if (scheduledTasks[jobId]) {
// Prevent scheduling duplicate jobs for the exact same time/recipient if somehow requested quickly
log.warn(`Job with ID ${jobId} already exists. Ignoring duplicate request.`);
return reply.status(400).send({ error: 'Duplicate schedule request detected.' });
}
const task = cron.schedule(cronPattern_ async () => {
log.info(`Executing scheduled task ${jobId} for ${to}`);
try {
const resp = await vonage.messages.send({
message_type: ""text"", // Use standard quotes
to: to,
from: vonageNumber, // Your Vonage number from .env
channel: ""sms"", // Use standard quotes
text: text
});
log.info({ messageId: resp.message_uuid, recipient: to }, `SMS successfully sent via Vonage for job ${jobId}`);
} catch (err) {
// Log Vonage API errors
log.error({ err: err.message || err, recipient: to, jobId }, `Failed to send SMS via Vonage for job ${jobId}. Response: ${err.response?.data ? JSON.stringify(err.response.data) : 'N/A'}`);
// Optional: Implement retry logic here or mark as failed in a DB (See Section 5/6)
} finally {
// --- CRITICAL FOR IN-MEMORY APPROACH ---
// Clean up the task reference and stop the job regardless of success/failure
// because this cron pattern is for a specific past moment now.
log.info(`Cleaning up task ${jobId}`);
delete scheduledTasks[jobId];
task.stop(); // Stop the cron job itself after execution/attempt
}
}, {
scheduled: true,
timezone: ""Etc/UTC"" // IMPORTANT: Specify timezone, Use UTC for consistency with pattern generation
});
// Store task reference (IN-MEMORY - LOST ON RESTART)
scheduledTasks[jobId] = task;
log.info(`Task ${jobId} scheduled successfully.`);
return reply.status(200).send({ message: 'SMS scheduled successfully.', jobId: jobId });
} catch (error) {
// This catch handles errors from cron.schedule itself (e.g., invalid pattern)
log.error({ err: error, body: request.body }, 'Error scheduling cron job');
return reply.status(500).send({ error: 'Failed to schedule SMS due to internal error.' });
}
});
// ... (health check route is already defined above) ...
// Start the server now that routes are defined
start();
Explanation:
- Schema Definition (
scheduleSmsSchema
):- Defines the request body (
to
,text
,scheduleTime
) and expected success/error responses. required
: Specifies mandatory fields.properties
: Defines types and constraints.format: 'date-time'
expects ISO 8601.pattern: '^\\+[1-9]\\d{1,14}$'
validates E.164 format (added end anchor$
).additionalProperties: false
: Prevents extra fields.- Fastify uses this for automatic validation.
- Defines the request body (
- Route Handler (
fastify.post('/schedule-sms', ...)
):- Registers a
POST
handler with the schema. - Extracts data from
request.body
. - Uses request-specific logger (
request.log
). - Time Validation: Parses
scheduleTime
(expected in UTC per ISO 8601 Z format) and checks if it's valid and in the future. - Cron Pattern: Converts the
Date
object into anode-cron
pattern usinggetUTC*
methods to align with the UTC input and timezone setting. cron.schedule
:- Schedules the task with the pattern and an
async
callback. - Callback sends the SMS using
vonage.messages.send
(corrected double-quotes to standard quotes). - Error Handling: Inner
try...catch
handles Vonage API errors. - Logging: Logs success (
message_uuid
) or failure. - Cleanup: The
finally
block is crucial: it deletes the task reference fromscheduledTasks
and callstask.stop()
after the job runs (or fails), as the specific time pattern won't trigger again. - Timezone: Explicitly sets
timezone: ""Etc/UTC""
(corrected quotes). This ensuresnode-cron
interprets the pattern based on UTC, matching ourgetUTC*
pattern generation.
- Schedules the task with the pattern and an
- Task Storage: Stores the
task
object inscheduledTasks
(volatile in-memory storage). Includes a basic check to prevent duplicate job IDs. - Response: Sends 200 OK with
jobId
. - Outer Error Handling: Catches errors during the
cron.schedule
call itself.
- Registers a
curl
2. Testing with Stop your server (Ctrl+C
) if it's running, and restart it:
node server.js
Open a new terminal window.
- Replace
YOUR_RECIPIENT_NUMBER
with a real phone number in E.164 format (e.g.,+15559876543
). - Generate a future UTC time: Use the appropriate
date
command for your OS.
# --- Choose ONE of the following date commands ---
# For macOS/BSD: Generate UTC time 2 minutes in the future
FUTURE_TIME=$(date -u -v+2M +'%Y-%m-%dT%H:%M:%SZ')
# For Linux: Generate UTC time 2 minutes in the future
# FUTURE_TIME=$(date -u -d ""+2 minutes"" +'%Y-%m-%dT%H:%M:%SZ')
# --- Construct and send the curl request ---
# Replace with your recipient number:
RECIPIENT='+15559876543' # Example number
# Use printf to safely build the JSON payload
JSON_PAYLOAD=$(printf '{""to"": ""%s"", ""text"": ""Hello from your Fastify scheduler! This is a test."", ""scheduleTime"": ""%s""}' ""$RECIPIENT"" ""$FUTURE_TIME"")
echo ""Scheduling for time: $FUTURE_TIME""
echo ""Payload: $JSON_PAYLOAD""
curl -X POST http://localhost:3000/schedule-sms \
-H ""Content-Type: application/json"" \
-d ""$JSON_PAYLOAD""
Expected Output from curl
:
{""message"":""SMS scheduled successfully."",""jobId"":""sms-+15559876543-167...""}
Expected Output in Server Logs:
{""level"":30,""time"":...,""pid"":...,""hostname"":...,""reqId"":""req-1"",""msg"":""Received schedule request for +15559876543 at 2024-...""}
{""level"":30,""time"":...,""pid"":...,""hostname"":...,""reqId"":""req-1"",""msg"":""Calculated cron pattern: ... (based on UTC time)""}
{""level"":30,""time"":...,""pid"":...,""hostname"":...,""reqId"":""req-1"",""msg"":""Task sms-+15559876543-167... scheduled successfully.""}
After the scheduled time passes:
{""level"":30,""time"":...,""pid"":...,""hostname"":...,""msg"":""Executing scheduled task sms-+15559876543-167... for +15559876543""}
{""level"":30,""time"":...,""pid"":...,""hostname"":...,""messageId"":""aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"",""recipient"":""+15559876543"",""msg"":""SMS successfully sent via Vonage for job sms-+15559876543-167...""}
{""level"":30,""time"":...,""pid"":...,""hostname"":...,""msg"":""Cleaning up task sms-+15559876543-167...""}
You should also receive the SMS on the recipient phone.
4. Integrating with Vonage (Covered)
The core Vonage integration happens within the cron.schedule
callback in server.js
:
const resp = await vonage.messages.send({
message_type: ""text"", // Corrected quotes
to: to, // Recipient number from the request
from: vonageNumber, // Your Vonage number from .env
channel: ""sms"", // Specify SMS channel (Corrected quotes)
text: text // Message content from the request
});
- Authentication: Handled automatically by the SDK instance created with the Application ID and Private Key (JWT authentication).
vonage.messages.send
: The SDK method used to send messages via the Messages API.- Parameters: Correctly set
message_type
,to
,from
,channel
, andtext
. - Response: The
resp
object contains themessage_uuid
for tracking. - Dashboard: Monitor messages in your Vonage API Dashboard (""Logs"" -> ""Messages API"").
5. Error Handling, Logging, and Retry Mechanisms
Error Handling:
- Input Validation: Handled by Fastify schemas (400 errors).
- Time Validation: Explicit checks for future date (400 errors).
- Vonage API Errors: Caught in the
cron
callback'stry...catch
, logged with details. - Scheduling Errors: Caught by the outer
try...catch
aroundcron.schedule
(500 errors).
Logging:
- Fastify Logger: Structured JSON logging enabled.
- Request Logger:
request.log
ties logs to requests. - Key Events Logged: Request received, pattern calculation, scheduling success/failure, task execution, Vonage success/failure, task cleanup.
Retry Mechanisms (Conceptual - Not Implemented Here):
The current node-cron
implementation lacks persistence, making robust retries difficult. For production:
- Database Tracking: Store job status (
pending
,sent
,failed
,retry
). - Retry Logic: On Vonage API failure, update status, increment retry count, and schedule a new job (using a persistent scheduler like BullMQ/Agenda) with exponential backoff.
- Job Queue Library: Use BullMQ (Redis) or Agenda (MongoDB) for built-in, persistent retries and job management. This is the recommended approach.
6. Database Schema and Data Layer (Improvement Path)
The critical step for production readiness is replacing the in-memory node-cron
approach with a database-backed persistent job queue.
Why a Database?
- Persistence: Schedules survive server restarts/crashes.
- Scalability: Allows multiple workers (if using a job queue).
- Status Tracking: Monitor job lifecycle (
pending
,sent
,failed
). - Auditing: Query past and future schedules.
Suggested Schema (Example - PostgreSQL):
CREATE TYPE schedule_status AS ENUM ('pending', 'processing', 'sent', 'failed', 'retry');
CREATE TABLE scheduled_sms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Or SERIAL
recipient_number VARCHAR(20) NOT NULL,
sender_number VARCHAR(20) NOT NULL,
message_text TEXT NOT NULL,
scheduled_at TIMESTAMPTZ NOT NULL, -- Store scheduled time in UTC
status schedule_status NOT NULL DEFAULT 'pending',
vonage_message_uuid VARCHAR(50),
last_attempt_at TIMESTAMPTZ,
retry_count INT DEFAULT 0,
last_error TEXT, -- Store last error message for debugging
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index for efficiently finding jobs ready to be processed
CREATE INDEX idx_scheduled_sms_pending ON scheduled_sms (scheduled_at)
WHERE status = 'pending' OR status = 'retry';
-- Optional: Index for status lookup
CREATE INDEX idx_scheduled_sms_status ON scheduled_sms (status);
Data Layer Implementation (High-Level):
- Choose DB & Client/ORM: Select PostgreSQL, MongoDB, etc., and a Node.js client (e.g.,
pg
,mongodb
) or ORM (Prisma, Sequelize, Mongoose). - Modify API Endpoint (
/schedule-sms
): Instead ofcron.schedule
, this endpoint only inserts a record intoscheduled_sms
withstatus = 'pending'
. - Implement a Separate Worker Process:
- Use a job queue library (BullMQ, Agenda) or a dedicated script.
- The worker queries the DB for due jobs (
status IN ('pending', 'retry') AND scheduled_at <= NOW()
). - It attempts to send via Vonage, updating the DB record (
status
,vonage_message_uuid
,last_attempt_at
,retry_count
,last_error
) accordingly. - Job queues handle locking, retries, etc., making this much more robust.
7. Adding Security Features
Enhance security beyond the basics:
- Input Validation: Implemented via Fastify schemas.
- API Key/Secret Security:
- Handled via
.env
(local) / secure environment variables (production). - CRITICAL: Ensure
.env
andprivate.key
are in.gitignore
. - Use secrets management tools (Vault, AWS Secrets Manager, Doppler) in production.
- Handled via
- Rate Limiting: Protect against abuse using
@fastify/rate-limit
.npm install @fastify/rate-limit
// server.js (register plugin after fastify init) fastify.register(require('@fastify/rate-limit'), { max: 100, timeWindow: '1 minute' });
- Security Headers: Use
@fastify/helmet
for headers like X-Frame-Options, Strict-Transport-Security, etc.npm install @fastify/helmet
// server.js (register plugin after fastify init) fastify.register(require('@fastify/helmet'));
- Authentication/Authorization: Protect the
/schedule-sms
endpoint using API keys, JWT, or other mechanisms via@fastify/auth
. - Dependency Updates: Regularly run
npm audit
oryarn audit
and update dependencies.
8. Handling Special Cases
- Time Zones:
- Problem: Ambiguity in time interpretation.
- Solution:
- Standardize on UTC: Require
scheduleTime
in ISO 8601 format with the UTC 'Z' suffix (e.g.,2025-04-21T10:00:00Z
). Our schema enforces ISO 8601, encourage the 'Z'. - Use UTC Methods: Use
getUTC*
methods when creating thecron
pattern. - Specify Timezone: Set
timezone: ""Etc/UTC""
(corrected quotes) innode-cron
options to ensure consistent interpretation. - Libraries (Optional): For complex time zone logic, consider
date-fns-tz
orLuxon
.
- Standardize on UTC: Require
- Number Formatting: Validate E.164 (
+1...
) strictly. Our schema pattern (^\\+[1-9]\\d{1,14}$
) enforces this. Considerlibphonenumber-js
for advanced validation/parsing if needed. - Message Content: Be mindful of SMS length (multipart messages cost more) and character encoding. Sanitize
text
input if necessary. - Vonage API Rate Limits/Errors: Implement backoff/retries (ideally with a persistent queue) for
429
errors. Handle specific errors like invalid numbers gracefully. - Invalid Numbers: Log errors from Vonage indicating invalid recipients and mark the job as
failed
(in a database scenario).
9. Performance Optimizations
- Fastify: Already performant.
- Database (If implemented): Use indexes (e.g., on
scheduled_at
,status
), efficient queries, connection pooling. - Vonage API Calls: Asynchronous calls (
await
) prevent blocking. node-cron
Scalability: Managing thousands of in-memorynode-cron
jobs can be inefficient. A dedicated job queue is better for high volume.- Load Testing: Use
k6
,artillery
, etc., to test/schedule-sms
under load.
10. Monitoring, Observability, and Analytics
For production:
- Health Checks: Enhance
/health
to check DB/Vonage connectivity if needed. Use uptime monitors. - Logging: Centralize structured logs (ELK, Loki, Datadog).
- Metrics: Track key metrics (requests/sec, error rates, job queue length, Vonage API latency) using tools like Prometheus/Grafana or Datadog APM.
- Tracing: Implement distributed tracing to follow requests across services.
- Analytics: Monitor SMS delivery rates and costs via the Vonage Dashboard.