This guide provides a comprehensive walkthrough for building an SMS scheduling and reminder application using Node.js, Express, and the Vonage APIs. You'll learn how to create a system that accepts requests to send SMS messages at specific future times, handles the scheduling logic, and integrates securely with Vonage for message delivery.
> Important Note: This guide uses in-memory storage for simplicity during development. This is not suitable for production as all scheduled messages will be lost if the server restarts. Section 6 outlines the necessary database implementation for a production-ready system.
We will cover project setup, core scheduling logic using node-cron
, API design with Express, integrating the Vonage SDK (specifically using the SMS API for simplicity), error handling, structured logging, security considerations, and deployment strategies. By the end, you'll have a functional demonstration application and the knowledge to implement persistent storage and other production requirements.
Technologies Used:
- Node.js: A JavaScript runtime environment for building the backend server.
- Express: A minimal and flexible Node.js web application framework for creating the API.
- Vonage Server SDK (
@vonage/server-sdk
): Used for sending SMS messages via the Vonage SMS API. (Note: While the SDK supports the newer Messages API, this guide uses the simplervonage.sms.send()
method). node-cron
: A simple cron-like job scheduler for Node.js to trigger message sending.dotenv
: A module to load environment variables from a.env
file for secure configuration.winston
: A versatile logging library for structured logging.uuid
: Generates unique IDs for scheduled messages.- (Required for Production): A database (e.g., PostgreSQL, MongoDB) for persistent storage of scheduled messages and an ORM/ODM (e.g., Prisma, Mongoose). This guide's in-memory approach must be replaced for production.
System Architecture:
+-----------------+ +---------------------+ +-----------------+
| User / Client |----->| Node.js / Express |<---->| Vonage SMS |
| (e.g., Postman) | | API Server | | API |
+-----------------+ +---------------------+ +-----------------+
| 1. POST /schedule | 2. Store Schedule | 3. Send SMS
| (to, msg, time) | (In-Memory/DB*) | at scheduled time
| | 4. Run Cron Job |
| | (Check Schedule) |
+-----------------------+-----------------------+
*DB required for production
- A client sends an HTTP POST request to the
/schedule
endpoint with recipient details, message content, and the desired sending time. - The Express server validates the request and stores the scheduling information (initially in memory; must be a database in production).
- A
node-cron
job runs at regular intervals (e.g., every minute). - The cron job checks the stored schedule for any messages due to be sent.
- For due messages, the server uses the Vonage SDK (
vonage.sms.send()
) to send the SMS via the Vonage SMS API.
Prerequisites:
- Node.js and npm (or yarn): Installed on your system. Download Node.js
- Vonage API Account: Sign up for free. Vonage API Dashboard
- Vonage API Key and Secret: Found on your Vonage Dashboard homepage.
- Vonage Application: You'll create one to get an Application ID and Private Key.
- Vonage Virtual Number: Purchase one capable of sending SMS. Buy Numbers
- (Optional, for Webhook Testing): ngrok: To expose your local server if you decide to implement and test webhook handling (e.g., for delivery receipts), which is not covered in this guide's core implementation. ngrok
1. Setting Up the Project
Let's start by creating the project structure, installing dependencies, and setting up basic configuration.
1.1 Create Project Directory:
Open your terminal and create a new directory for the project, then navigate into it.
mkdir vonage-sms-scheduler
cd vonage-sms-scheduler
1.2 Initialize Node.js Project:
Initialize the project using npm. This creates a package.json
file.
npm init -y
1.3 Install Dependencies:
Install the necessary libraries: Express for the server, the Vonage SDK for SMS, node-cron
for scheduling, dotenv
for environment variables, winston
for logging, and uuid
for unique IDs.
npm install express @vonage/server-sdk node-cron dotenv winston uuid
express
: Web framework.@vonage/server-sdk
: Official Vonage SDK for Node.js.node-cron
: Task scheduler.dotenv
: Loads environment variables from a.env
file.winston
: Structured logging library.uuid
: Generates unique IDs.
1.4 Create Project Structure:
Create the main server file and a file for environment variables.
touch server.js .env .gitignore
Your basic structure should look like this:
vonage-sms-scheduler/
├── node_modules/
├── .env
├── .gitignore
├── package.json
├── package-lock.json
└── server.js
1.5 Configure .gitignore
:
Add node_modules
, .env
, your private key file, and log files to your .gitignore
file to prevent committing them to version control. Sensitive credentials should never be committed.
node_modules
.env
private.key
*.log
1.6 Set Up Vonage Application and Credentials:
You need specific credentials from Vonage to interact with the API.
- Log in to your Vonage API Dashboard.
- API Key and Secret: Note down your API Key and API Secret found on the main dashboard page.
- Create Application:
- Navigate to 'Applications' -> 'Create a new application'.
- Give your application a name (e.g.,
SMSScheduler
). - Click 'Generate public and private key'. Crucially, save the
private.key
file that downloads. It's recommended to save it within your project directory (e.g., in the root) but ensure it's listed in.gitignore
. - Enable the 'Messages' capability (this enables SMS sending for the application).
- For 'Inbound URL' and 'Status URL', you can initially put placeholder URLs like
https://example.com/webhooks/inbound
andhttps://example.com/webhooks/status
. If you later implement webhook handling (outside the scope of this guide), you'll update these with your actual ngrok or deployed server URLs. - Click 'Generate new application'.
- Note down the Application ID that is generated.
- Link Number:
- Go to 'Numbers' -> 'Your numbers'.
- Find the SMS-capable number you purchased. Click 'Link' next to it.
- Select the application you just created (
SMSScheduler
) from the dropdown. - Click 'Link'.
1.7 Configure Environment Variables:
Open the .env
file and add your Vonage credentials and configuration. Replace the placeholder values with your actual credentials.
# Vonage API Credentials
VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
VONAGE_APPLICATION_ID=YOUR_APPLICATION_ID
# Make sure this path is correct relative to where you run `node server.js`
VONAGE_PRIVATE_KEY_PATH=./private.key
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # The number linked to your application (E.164 format)
# Server Configuration
PORT=3000
# Cron Schedule (Runs every minute)
CRON_SCHEDULE=* * * * *
# Logging Level
LOG_LEVEL=info
VONAGE_API_KEY
,VONAGE_API_SECRET
,VONAGE_APPLICATION_ID
: Found in your Vonage Dashboard and Application settings.VONAGE_PRIVATE_KEY_PATH
: The path to theprivate.key
file you downloaded. Adjust if you place it elsewhere.VONAGE_NUMBER
: Your purchased Vonage virtual number linked to the application (use E.164 format, e.g.,+14155550100
).PORT
: The port your Express server will listen on.CRON_SCHEDULE
: Defines how often the scheduler checks for due messages.* * * * *
means every minute.LOG_LEVEL
: Controls the verbosity of the logger (e.g.,debug
,info
,warn
,error
).
2. Implementing Core Functionality (Scheduling Logic)
Now, let's write the core logic in server.js
. We'll set up Express, initialize Vonage, configure logging, create the (non-production) in-memory schedule storage, and implement the cron job.
2.1 Basic Server Setup (server.js
):
// server.js
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const { Vonage } = require('@vonage/server-sdk');
const cron = require('node-cron');
const { v4: uuidv4 } = require('uuid'); // For generating unique IDs
const winston = require('winston'); // Logging library
const app = express();
const port = process.env.PORT || 3000;
// --- Configure Structured Logger (Winston) ---
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json() // Log in JSON format
),
transports: [
new winston.transports.Console({ // Log to the console
format: winston.format.combine(
winston.format.colorize(), // Add colors for readability in console
winston.format.simple() // Use simple format for console
)
}),
// Add file transports for production logging if needed
// new winston.transports.File({ filename: 'error.log', level: 'error' }),
// new winston.transports.File({ filename: 'combined.log' }),
],
});
logger.info('Logger configured successfully.');
// Middleware to parse JSON bodies
app.use(express.json());
// --- In-Memory Storage for Scheduled Messages ---
// > **CRITICAL WARNING:** This in-memory storage is for demonstration ONLY.
// > It is NOT suitable for production. All data will be LOST on server
// > restart or crash. Implement database persistence (Section 6) before
// > deploying to production.
let scheduledMessages = []; // Array to hold { id, to, message, sendAt, status, error?, createdAt } objects
// --- Initialize Vonage Client ---
let vonage;
try {
vonage = new Vonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET,
applicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: process.env.VONAGE_PRIVATE_KEY_PATH
}, {
debug: process.env.LOG_LEVEL === 'debug' // Enable SDK debug logging if LOG_LEVEL is debug
});
logger.info('Vonage client initialized successfully.');
} catch (error) {
logger.error('FATAL: Failed to initialize Vonage client. Ensure Vonage environment variables (API Key, Secret, App ID, Private Key Path) are set correctly.', {
errorMessage: error.message,
stack: error.stack // Include stack trace for detailed debugging
});
process.exit(1); // Exit if Vonage can't be initialized
}
// --- Basic Routes (Health Check) ---
app.get('/health', (req, res) => {
res.status(200).json({
status: 'UP',
// WARNING: scheduled_count reflects in-memory state only
scheduled_count_in_memory: scheduledMessages.length
});
});
// --- Placeholder for API endpoint ---
// (We will add the /schedule endpoint in the next section)
// --- Placeholder for Cron Job ---
// (We will add the cron job logic below)
// --- Start the Server ---
// (Moved after cron job setup)
Explanation:
- Load Env Vars:
require('dotenv').config()
loads variables from.env
. - Import Dependencies: Imports
express
,Vonage
,node-cron
,uuid
, andwinston
. - Configure Logger: Sets up
winston
for structured JSON logging, with console output formatted simply and colorized. Log level is controlled byLOG_LEVEL
in.env
. - Express App & Middleware: Creates an Express app and uses
express.json()
. - In-Memory Storage:
scheduledMessages
array is initialized with a very prominent warning about its unsuitability for production, formatted as a blockquote. - Vonage Initialization: Creates a
Vonage
client instance. Includes robust error logging on failure and exits the process. SDK debug logging is enabled ifLOG_LEVEL=debug
. - Health Check: A simple
/health
endpoint.
2.2 Implementing the Cron Job Scheduler:
The cron job periodically checks the scheduledMessages
array and sends messages whose sendAt
time has passed.
Add the following code to server.js
, after the Vonage initialization and before starting the server:
// server.js (continued)
// --- Cron Job to Check and Send Scheduled Messages ---
logger.info(`Scheduling cron job with schedule: '${process.env.CRON_SCHEDULE}' in UTC timezone.`);
const scheduledTask = cron.schedule(process.env.CRON_SCHEDULE, async () => {
const now = new Date();
const jobStartTime = Date.now();
logger.info(`Cron job running: Checking for due messages...`, { timestamp: now.toISOString() });
// Find messages ready to be sent from the in-memory store
const messagesToSend = scheduledMessages.filter(msg =>
msg.status === 'pending' && new Date(msg.sendAt) <= now
);
if (messagesToSend.length === 0) {
logger.info(`No messages due at this time.`);
return;
}
logger.info(`Found ${messagesToSend.length} message(s) to send.`);
for (const msg of messagesToSend) {
logger.info(`Attempting to send message`_ { scheduleId: msg.id_ to: msg.to });
try {
// Use vonage.sms.send() from the SDK
const resp = await vonage.sms.send({
to: msg.to_
from: process.env.VONAGE_NUMBER_ // Use the Vonage number from .env
text: msg.message
});
// Check Vonage response for success (status '0')
// Note: This only confirms Vonage accepted the request_ not final delivery.
// Use Status Webhooks (not covered here) for delivery confirmation.
if (resp.messages && resp.messages.length > 0 && resp.messages[0].status === '0') {
const messageId = resp.messages[0]['message-id'];
logger.info(`Message sent successfully to Vonage`, {
scheduleId: msg.id,
to: msg.to,
vonageMessageId: messageId
});
// !! Update status in memory (Replace with DB update) !!
msg.status = 'sent';
msg.vonageMessageId = messageId; // Store Vonage ID if needed later
} else {
// Handle Vonage API errors reported in the response
const statusCode = resp.messages?.[0]?.status || 'N/A';
const errorText = resp.messages?.[0]?.['error-text'] || 'Unknown Vonage API error';
logger.error(`Failed to send message via Vonage API`, {
scheduleId: msg.id,
to: msg.to,
vonageStatus: statusCode,
vonageError: errorText
});
// !! Update status in memory (Replace with DB update) !!
msg.status = 'failed';
msg.error = `Vonage Status ${statusCode}: ${errorText}`;
// Optional: Add logic based on status code, e.g., don't retry permanent errors like '4' (Invalid Credentials)
if (statusCode === '1' || statusCode === '4' || statusCode === '3') {
logger.warn(`Permanent error detected for message. Won't retry automatically.`, { scheduleId: msg.id, statusCode });
// In a DB scenario, you might mark it as permanently failed.
}
}
} catch (error) {
// Handle network errors or other exceptions during the API call
logger.error(`CRITICAL ERROR sending message: Network or SDK issue`, {
scheduleId: msg.id,
to: msg.to,
errorMessage: error.message,
stack: error.stack
});
// !! Update status in memory (Replace with DB update) !!
msg.status = 'failed';
msg.error = error.message;
// Consider adding retry logic here for network errors (see Section 5)
}
}
// Clean up sent/failed messages from the active pending list (optional, depends on needs)
// For simplicity here, we just update status. In a DB scenario, you'd update the record.
// If keeping history isn't needed with in-memory:
// scheduledMessages = scheduledMessages.filter(msg => msg.status === 'pending');
// logger.info(`In-memory store cleanup complete. Remaining pending: ${scheduledMessages.length}`);
const jobEndTime = Date.now();
logger.info(`Cron job finished. Duration: ${jobEndTime - jobStartTime}ms`, { processedCount: messagesToSend.length });
}, {
scheduled: true,
timezone: ""Etc/UTC"" // IMPORTANT: Run cron based on UTC time
});
scheduledTask.start(); // Explicitly start the task
logger.info(""Cron job scheduled and started."");
// --- Start the Server --- (Now placed after cron setup)
app.listen(port, () => {
logger.info(`SMS Scheduler server listening at http://localhost:${port}`);
logger.info(`Cron job checking schedule: '${process.env.CRON_SCHEDULE}' in UTC timezone.`);
});
Explanation:
cron.schedule
: Creates the task using the.env
schedule and sets thetimezone
toEtc/UTC
(essential for consistency).- Logging: Uses the
logger
for all output, providing structured context (timestamps, schedule IDs, etc.). - Filter Due Messages: Finds messages in the in-memory array marked 'pending' with
sendAt
in the past. - Iterate and Send: Loops through due messages.
vonage.sms.send
: Calls the Vonage SDK's SMS sending method.- Handle Response:
- Checks
resp.messages[0].status === '0'
for success indication from Vonage. - Logs success with the
vonageMessageId
. - Logs failure with the specific Vonage
status
code anderror-text
. - Updates the status (
'sent'
or'failed'
) anderror
field in the in-memory object. Includes a warning about specific permanent error codes.
- Checks
- Error Handling: A
try...catch
block handles network/SDK exceptions, logging them as critical errors. - In-Memory Update: Updates the status directly in the
scheduledMessages
array. This needs to be replaced with database operations. - Start Task:
scheduledTask.start()
starts the job. - Server Start: The
app.listen
call is now placed after the cron job setup to ensure logging order makes sense.
3. Building the API Layer
We need an endpoint for clients to submit SMS scheduling requests.
Add the following POST route handler to server.js
, typically before the cron.schedule
block:
// server.js (continued)
// --- API Endpoint to Schedule SMS ---
app.post('/schedule', (req, res) => {
const { to, message, sendAt } = req.body;
const requestReceivedAt = new Date();
logger.info('Received POST /schedule request', { body: req.body });
// --- Input Validation ---
if (!to || !message || !sendAt) {
logger.warn('Validation failed: Missing required fields', { body: req.body });
return res.status(400).json({ error: 'Missing required fields: to, message, sendAt' });
}
// Validate 'to' number format (stricter E.164 check)
// Requires '+' sign, country code, and subscriber number.
if (!/^\+[1-9]\d{1,14}$/.test(to)) {
logger.warn('Validation failed: Invalid `to` phone number format', { to });
return res.status(400).json({ error: 'Invalid `to` phone number format. Use E.164 format (e.g., +14155550100).' });
}
// Validate 'sendAt' format and ensure it's in the future
const sendAtDate = new Date(sendAt);
if (isNaN(sendAtDate.getTime())) {
logger.warn('Validation failed: Invalid `sendAt` date format', { sendAt });
return res.status(400).json({ error: 'Invalid `sendAt` date format. Use ISO 8601 format (e.g., 2025-12-31T23:59:00Z).' });
}
// Allow a small buffer (e.g., 10 seconds) to account for processing time
const minSendTime = new Date(Date.now() + 10000);
if (sendAtDate <= minSendTime) {
logger.warn('Validation failed: `sendAt` date must be in the future'_ { sendAt_ now: minSendTime.toISOString() });
return res.status(400).json({ error: '`sendAt` date must be at least 10 seconds in the future.' });
}
// --- Create Schedule Object ---
const newScheduledMessage = {
id: uuidv4()_ // Generate a unique ID
to: to_
message: message_
sendAt: sendAtDate.toISOString()_ // Store as ISO string (UTC)
status: 'pending'_ // Initial status
createdAt: requestReceivedAt.toISOString()
};
// > **CRITICAL:** Replace this with a database INSERT operation
// > for production use.
scheduledMessages.push(newScheduledMessage);
logger.info(`Successfully scheduled new message`, {
scheduleId: newScheduledMessage.id,
to: newScheduledMessage.to,
sendAt: newScheduledMessage.sendAt
});
// --- Respond to Client ---
// 202 Accepted: Request received, processing will happen later.
res.status(202).json({
message: 'SMS scheduled successfully.',
scheduleId: newScheduledMessage.id,
scheduledTimeUTC: newScheduledMessage.sendAt
});
});
// Existing code (Vonage init, cron job, app.listen, etc.) follows...
Explanation:
- Route Definition & Logging: Defines
POST /schedule
and logs the incoming request. - Extract Body: Gets
to
,message
,sendAt
fromreq.body
. - Input Validation:
- Checks for missing fields.
- Uses a stricter E.164 regex (
/^\+[1-9]\d{1,14}$/
) for theto
number, requiring the+
. - Validates
sendAt
is a valid ISO 8601 date string and is in the future (with a small buffer). - Logs validation failures using the
logger
.
- Create Schedule Object: Creates the
newScheduledMessage
object with auuid
, details, 'pending' status, and timestamps (stored as UTC ISO strings). - Store Schedule (In-Memory): Pushes the object into the
scheduledMessages
array, again highlighting that this must be replaced with a database insert. - Respond: Sends
202 Accepted
with thescheduleId
and scheduled time.
Testing the API Endpoint:
Use curl
or Postman. Schedule a message for a few minutes in the future (using UTC).
# Ensure sendAt is ISO 8601 format with Z (UTC) and in the future
# Example for macOS/BSD date:
FUTURE_TIME=$(date -u -v+2M '+%Y-%m-%dT%H:%M:%SZ')
# Example for GNU date:
# FUTURE_TIME=$(date -u --date='+2 minutes' '+%Y-%m-%dT%H:%M:%SZ')
curl -X POST http://localhost:3000/schedule \
-H ""Content-Type: application/json"" \
-d '{
""to"": ""+14155550101"",
""message"": ""Hello from the scheduler! This is a test."",
""sendAt"": ""'""${FUTURE_TIME}""'""
}'
- Replace
+14155550101
with your target number. - Check server logs (now structured JSON) for scheduling and cron job activity.
- Verify SMS arrival.
4. Integrating with Vonage (Covered in Setup & Core Logic)
The core Vonage integration happens during:
- Initialization: Setting up the
Vonage
client instance inserver.js
using credentials from.env
(Section 2.1). - Sending: Calling
vonage.sms.send()
within the cron job logic (Section 2.2).
Secure Handling of Credentials:
.env
File: Keeps secrets out of the codebase..gitignore
: Ensures.env
andprivate.key
are never committed to Git.- Environment Variables in Production: Use platform-provided environment variables (Heroku Config Vars, Docker secrets, etc.), not a
.env
file in the production environment.
Fallback Mechanisms:
The current implementation has basic error logging. For more robust systems:
- Retry Logic: Implement exponential backoff for specific error types (e.g., temporary network issues, Vonage rate limits (
status: 1
)). See Section 5. - Dead Letter Queue: Move persistently failing messages (after retries) to a separate table/queue for investigation.
- Monitoring & Alerting: Set up alerts for high failure rates (Section 6 covers database aspects needed for this).
5. Error Handling, Logging, and Retry Mechanisms
Robust error handling and structured logging are vital.
Current Implementation:
- API Validation:
/schedule
validates input, returns 400 errors, logs failures. - Vonage Init: Logs fatal errors if initialization fails.
- Cron Job
try...catch
: Catches errors duringvonage.sms.send
. - Structured Logging: Uses
winston
throughout for JSON-formatted logs with levels (info, warn, error) and context. - Vonage Status Handling: Logs specific Vonage API errors encountered during sending.
Improvements for Production:
-
Centralized Error Handler (Express): Add Express error-handling middleware to catch unhandled errors in routes.
-
Log Aggregation: Send logs to a centralized service (Datadog, Splunk, ELK, Papertrail) for easier searching and analysis, especially in distributed systems. The JSON format from Winston is ideal for this.
-
Retry Mechanism (Example Sketch using
async-retry
):// Inside the cron job's loop (requires npm install async-retry) const retry = require('async-retry'); // ... inside the for (const msg of messagesToSend) loop ... try { // Wrap the Vonage call in retry logic await retry(async (bail, attempt) => { logger.debug(`Attempt ${attempt} to send message`, { scheduleId: msg.id }); const resp = await vonage.sms.send({ to: msg.to, from: process.env.VONAGE_NUMBER, text: msg.message }); if (resp.messages && resp.messages.length > 0) { const status = resp.messages[0].status; const errorText = resp.messages[0]['error-text'] || 'Unknown Vonage error'; const messageId = resp.messages[0]['message-id']; if (status === '0') { logger.info(`Message sent successfully via Vonage (Attempt ${attempt})`, { scheduleId: msg.id, vonageMessageId: messageId }); // !! DB Update: Mark as sent !! msg.status = 'sent'; msg.vonageMessageId = messageId; return; // Success, stop retrying } else { logger.warn(`Vonage API error on attempt ${attempt}`, { scheduleId: msg.id, status, errorText }); // Decide if the error is permanent and should not be retried // Status 1 (Throttled) might be retryable depending on strategy // Status 4 (Invalid Creds), 3 (Invalid Params), 6 (Invalid Msg), 9 (No Funds) are likely permanent if (status === '3' || status === '4' || status === '6' || status === '9') { const permanentError = new Error(`Permanent Vonage error: ${errorText} (Status: ${status})`); // !! DB Update: Mark as permanently failed !! msg.status = 'failed'; msg.error = permanentError.message; bail(permanentError); // Stop retrying immediately return; } else { // For other errors (e.g., 1-Throttled, 2-Missing Params (shouldn't happen?), 5-Internal Error), throw to trigger retry throw new Error(`Retryable Vonage error: ${errorText} (Status: ${status})`); } } } else { // Invalid response structure from Vonage - likely temporary issue throw new Error(""Invalid or empty response structure from Vonage API""); } }, { retries: 3, // Number of retries factor: 2, // Exponential backoff factor (1s, 2s, 4s) minTimeout: 1000, // Initial delay 1s onRetry: (error, attempt) => { logger.warn(`Retrying message sending (Attempt ${attempt})`, { scheduleId: msg.id, error: error.message, }); } }); // If retry succeeds, msg.status is updated inside the retry block } catch (error) { // This catches errors if retry fails completely (permanent or max retries reached) logger.error(`CRITICAL/FINAL ERROR after retries sending message`, { scheduleId: msg.id, errorMessage: error.message, // Avoid logging full stack for final failure unless needed }); // !! DB Update: Mark as failed !! if (msg.status !== 'failed') { // Avoid overwriting permanent failure status set by bail() msg.status = 'failed'; msg.error = error.message; // Store the last error } } // ... rest of loop ...
6. Creating a Database Schema and Data Layer (Essential for Production)
As emphasized repeatedly, the in-memory scheduledMessages
array must be replaced with a persistent database for any real-world application.
Example Schema (using SQL syntax for illustration - adapt for your DB):
CREATE TABLE scheduled_sms (
id UUID PRIMARY KEY, -- Unique identifier (use DB's UUID type or VARCHAR)
to_number VARCHAR(20) NOT NULL, -- Recipient phone number (E.164 format)
message_body TEXT NOT NULL, -- SMS content
send_at TIMESTAMPTZ NOT NULL, -- Scheduled time (Timestamp With Time Zone - store and query in UTC)
status VARCHAR(15) NOT NULL DEFAULT 'pending', -- 'pending', 'sent', 'failed', 'permanently_failed', 'retrying'
vonage_message_id VARCHAR(50) NULL, -- Message ID from Vonage response (useful for reconciliation/webhooks)
last_attempt_at TIMESTAMPTZ NULL, -- Timestamp of the last sending attempt (for retry logic/debugging)
attempt_count INT DEFAULT 0, -- Number of sending attempts
error_message TEXT NULL, -- Last error message if status is 'failed' or 'permanently_failed'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Record creation time
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- Record last update time (use DB triggers if available)
);
-- CRITICAL Index for efficient querying by the cron job
CREATE INDEX idx_scheduled_sms_pending_send_at ON scheduled_sms (status, send_at)
WHERE status = 'pending' OR status = 'retrying'; -- Adjust statuses based on your logic
-- Optional index for looking up by Vonage ID (if handling status webhooks)
-- CREATE INDEX idx_scheduled_sms_vonage_id ON scheduled_sms (vonage_message_id);
Implementation Steps:
- Choose Database: Select a suitable database (e.g., PostgreSQL, MySQL, MongoDB). PostgreSQL with
TIMESTAMPTZ
is excellent for time-based scheduling. - Choose ORM/ODM: Select a library like Prisma (recommended for type safety), Sequelize, TypeORM (for SQL), or Mongoose (for MongoDB).
- Define Schema/Model: Create the model/schema definition using your chosen ORM/ODM based on the example above.
- Migrations: Use the ORM's migration tools (
prisma migrate dev
,sequelize db:migrate
) to create and manage the database table structure safely. - Replace In-Memory Logic:
/schedule
Endpoint: ReplacescheduledMessages.push(newScheduledMessage)
with a databaseINSERT
operation (e.g.,prisma.scheduledSms.create({ data: newScheduledMessage })
).- Cron Job Query: Replace
scheduledMessages.filter(...)
with a databaseSELECT
query using the indexedstatus
andsend_at
columns (e.g.,prisma.scheduledSms.findMany({ where: { status: 'pending', sendAt: { lte: now } } })
). - Cron Job Updates: Replace direct updates to
msg.status
,msg.error
, etc., with databaseUPDATE
operations (e.g.,prisma.scheduledSms.update({ where: { id: msg.id }, data: { status: 'sent', vonageMessageId: messageId, attemptCount: { increment: 1 }, lastAttemptAt: now } })
). Handle updates for 'failed', 'permanently_failed', and incrementingattempt_count
.