This guide provides a complete walkthrough for building a robust SMS scheduling and reminder application using Node.js with the Fastify framework and the Vonage Messages API. We'll cover everything from initial project setup to deployment considerations, focusing on creating a reliable system.
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.
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.
@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.
System Architecture:
+--------+ +-----------------+ +--------------------+ +-------------+
| Client |----->| Fastify API |----->| Scheduling Service |----->| Vonage API |
| (User/ | | (POST /schedule)| | (node-schedule) | | (Sends SMS) |
| System)| +-----------------+ +--------------------+ +-------------+
| | | |
| | +------------- Logs -------------+
+--------+
Prerequisites:
- Node.js (LTS version recommended) and npm/yarn installed.
- 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
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.
1. Setting up the project
Let's initialize our Node.js project, install dependencies, and configure the basic Fastify server and Vonage credentials.
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:
Create a package.json
file.
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.
npm install fastify @vonage/server-sdk node-schedule dotenv
npm install --save-dev pino-pretty
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.
// 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.
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.
# .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.
# .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
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:
- Go to Your Applications > Create a new application.
- Give it a name (e.g., "Fastify Scheduler").
- Click "Generate public and private key". Save the
private.key
file into your project's root directory (or a secure location referenced byVONAGE_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. - Enable the "Messages" capability.
- For Status URL and Inbound URL, you can initially enter placeholder URLs like
https://example.com/status
andhttps://example.com/inbound
. If you later want delivery receipts or inbound messages, you'll need to update these with publicly accessible endpoints (usingngrok
for local development). - Click "Generate new application".
- Copy the generated Application ID and add it to your
.env
file (VONAGE_APP_ID
).
- Using the Vonage CLI (Recommended):
- Configure the CLI with your API Key and Secret:
vonage config:set --apiKey=YOUR_VONAGE_API_KEY --apiSecret=YOUR_VONAGE_API_SECRET
- Create the application (this automatically generates
private.key
in the current directory):Note the Application ID outputted and add it to yourvonage apps:create "Fastify Scheduler" --messages_status_url=https://example.com/status --messages_inbound_url=https://example.com/inbound --keyfile=private.key
.env
file.
- Configure the CLI with your API Key and Secret:
- Using the Dashboard:
- Vonage Number:
- Purchase a number if you don't have one: Go to Numbers > Buy Numbers on the dashboard, or use the CLI:
# 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
- Link the number to your application: Go to Your Applications > Find your "Fastify Scheduler" app > Link Numbers, or use the CLI:
vonage apps:link --number=<YOUR_VONAGE_VIRTUAL_NUMBER> <YOUR_VONAGE_APPLICATION_ID>
- Add this number to your
.env
file (VONAGE_NUMBER
).
- Purchase a number if you don't have one: Go to Numbers > Buy Numbers on the dashboard, or use the CLI:
9. Basic Fastify Server Setup:
Add the initial Fastify server code to src/server.js
.
// 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.
2. Implementing Core Functionality (Scheduling Service)
We'll create a separate module to handle the scheduling logic using node-schedule
and interact with the Vonage SDK.
1. Create the Scheduler Module:
Populate src/scheduler.js
.
// 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
};
Explanation:
- Dependencies: Imports
node-schedule
,Vonage
SDK, andfs
. - 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. - 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. scheduledJobs
: An in-memory object to keep track of activenode-schedule
jobs. This is the core limitation – if the server restarts, all jobs in this object are lost.scheduleSms
:- Takes a unique
jobId
, recipient, message, andDate
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 fromscheduledJobs
after execution (or failure).
- Stores the returned
node-schedule
job object inscheduledJobs
. - Returns
true
on successful scheduling,false
otherwise.
- Takes a unique
cancelSmsJob
: Allows cancelling a job by ID usingjob.cancel()
and removing it fromscheduledJobs
.- Graceful Shutdown: Listens for
SIGINT
(Ctrl+C) andSIGTERM
(common process termination signal) to callschedule.gracefulShutdown()
. This attempts to allow running jobs to complete before exiting.
3. Building the API Layer
Now, let's 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.
// 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();
Explanation:
dotenv.config()
: Ensures environment variables are loaded first.randomUUID
: Imported for generating unique job IDs if the client doesn't provide one.- Scheduler Import: The
scheduler.js
module is imported. - 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.
- A simple hook checks for an
- Validation Schema (
scheduleSmsSchema
):- Defines the expected structure and types for the
POST /schedule-sms
request body using Fastify's schema validation. - Specifies
to
,message
, andsendAt
as required.jobId
is optional. - Uses
format: 'date-time'
forsendAt
, expecting an ISO 8601 string. - Defines expected response formats for success (
202
) and errors (400
,500
). Fastify uses this for automatic validation and serialization.
- Defines the expected structure and types for the
/schedule-sms
Route (POST
):- Applies the
scheduleSmsSchema
for automatic validation. - Extracts data from
request.body
. - Date Handling: Parses the
sendAt
ISO string into aDate
object. Includes crucial validation to ensure it's a valid date and in the future. - Generates a
jobId
usingrandomUUID()
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 thejobId
and the scheduled time. - Sends a
400 Bad Request
if the scheduler module returnsfalse
(e.g., duplicate ID). - Includes a
try...catch
block for unexpected errors during scheduling, returning a500 Internal Server Error
.
- Applies the
/schedule-sms/:jobId
Route (DELETE
): (Optional) Provides an endpoint to cancel a scheduled job using thecancelSmsJob
function from the scheduler module.- 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. - Server Start: Listens on
0.0.0.0
to be accessible from outside its container/machine in deployed environments.
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.
# 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.
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 viaprocess.env
. Theprivate.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.
5. Error Handling, Logging, and Retry Mechanisms
- 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 return400
. Unexpected errors return500
. - Vonage API Errors: Caught within the
try...catch
block of the scheduled job's callback inscheduler.js
. Errors are logged. The loggederr?.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.
- Validation Errors: Handled by Fastify's schema validation. Invalid requests receive
- Logging:
- Fastify's built-in Pino logger is enabled (
logger: true
). - Development logs are prettified using
pino-pretty
via thenpm 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.
- Fastify's built-in Pino logger is enabled (
- 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:
- Persistence: Storing scheduled jobs in a database.
- Queueing System: Using a dedicated message queue (like RabbitMQ, Redis with BullMQ, AWS SQS) to manage job execution and retries.
- Worker Process: A separate process dequeues jobs and attempts to send the SMS via Vonage.
- Retry Strategy: The queue system handles retries with exponential backoff upon failure.
- Dead-Letter Queue: Failed jobs (after exhausting retries) are moved to a dead-letter queue for investigation.
- Simple In-Process Retry (Use with Caution): Implementing retries directly within the
node-schedule
callback (e.g., usingsetTimeout
in thecatch
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.
6. Database Schema and Data Layer (Recommendation)
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.
Conceptual Schema (e.g., PostgreSQL):
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
Implementation Approach (If adding DB):
- 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
). - Integrate with Fastify: Use a Fastify plugin for your chosen database (e.g.,
fastify-postgres
,@fastify/mongodb
). - 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 thescheduled_sms
table for jobs wherestatus = 'scheduled'
andsend_at <= NOW()
. - Queueing (Better): The API endpoint adds the job details to a message queue. A separate worker process listens to the queue.
- Instead of
- Data Layer: Create functions to insert, update (status, message ID, error), and query jobs in the database.
- Worker Logic: The worker/poller fetches due jobs, attempts sending via Vonage, and updates the job status in the database.