This guide provides a complete walkthrough for building an SMS scheduling application using Node.js, Express, and the Vonage Messages API. You'll learn how to create an API endpoint to accept scheduling requests, use a cron job to trigger SMS sends at the specified times, handle API credentials securely, and implement basic error handling.
Project Goal: To create a backend service that can:
- Accept API requests to schedule an SMS message for a future date and time.
- Reliably send the scheduled SMS message at the designated time using Vonage.
- Provide basic confirmation and logging.
Problem Solved: Automates the process of sending timely SMS reminders or notifications without manual intervention or requiring the sending system to be actively running at the exact moment of dispatch. Useful for appointment reminders, event notifications, follow-ups, etc.
Technologies Used:
- Node.js: A JavaScript runtime environment for building the backend server.
- Express: A minimal and flexible Node.js web application framework to build the API layer.
- Vonage Messages API: Used for sending SMS messages reliably.
@vonage/server-sdk
: The official Vonage Node.js SDK for interacting with the API.node-cron
: A task scheduler for Node.js based on cron syntax, used to trigger scheduled messages.dotenv
: Module to load environment variables from a.env
file for secure configuration.- ngrok (for local development): A tool to expose your local server to the internet, necessary for receiving webhook status updates from Vonage (optional but recommended for full testing).
System Architecture:
- A client (e.g., Postman, another application) sends a
POST
request to the Express API endpoint (/schedule
) with recipient details, message content, and the desired send time. - The Express server validates the request and stores the scheduling details. Note: This guide uses a simple in-memory array for demonstration, which is not suitable for production (see Section 6).
- A
node-cron
job runs periodically (e.g., every minute). - The cron job checks the stored schedules. If a schedule's time has arrived, it triggers the SMS sending function.
- The SMS sending function uses the Vonage Node.js SDK to send the message via the Vonage Messages API.
- Vonage delivers the SMS to the recipient's phone.
- (Optional) Vonage can send status updates (e.g., 'delivered', 'failed') to a webhook URL configured in your Vonage Application, potentially routed through ngrok during local development.
Prerequisites:
- Node.js and npm (or yarn): Installed on your system. Download Node.js
- Vonage API Account: Sign up for free at Vonage API Dashboard.
- Vonage API Credentials: You'll need your API Key, API Secret, Application ID, and a Private Key file.
- Vonage Virtual Number: Purchase an SMS-capable number from the Vonage Dashboard.
- (Optional) ngrok: For testing webhooks locally. Download ngrok
1. Setting Up the Project
Let's initialize the project, install dependencies, and set up the basic structure and environment configuration.
Step 1: Create Project Directory
Open your terminal or command prompt and create a new directory for your project, then navigate into it:
mkdir sms-scheduler-vonage
cd sms-scheduler-vonage
Step 2: Initialize Node.js Project
Initialize the project using npm (accept defaults or customize as needed):
npm init -y
This creates a package.json
file.
Step 3: Install Dependencies
Install the necessary libraries:
npm install express @vonage/server-sdk node-cron dotenv
express
: Web framework.@vonage/server-sdk
: Vonage API client library.node-cron
: Task scheduler.dotenv
: Loads environment variables from a.env
file.
Step 4: Set Up Environment Variables
Create a file named .env
in the root of your project. This file will store your sensitive credentials and configuration. Never commit this file to version control.
# .env
# Vonage API Credentials
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root
# Vonage Number
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # In E.164 format, e.g., 14155552671
# Server Configuration
PORT=3000
Create a .env.example
file to document the required variables for other developers (or your future self):
# .env.example
# Vonage API Credentials
VONAGE_API_KEY=
VONAGE_API_SECRET=
VONAGE_APPLICATION_ID=
VONAGE_PRIVATE_KEY_PATH=./private.key
# Vonage Number
VONAGE_NUMBER=
# Server Configuration
PORT=3000
Finally, create a .gitignore
file to prevent committing sensitive files and unnecessary folders:
# .gitignore
node_modules
.env
*.log
private.key
Step 5: Obtain Vonage Credentials and Set Up Application
- API Key & Secret: Find these at the top of your Vonage API Dashboard. Add them to your
.env
file. - Create a Vonage Application:
- Go to Your applications > Create a new application.
- Give it a name (e.g., 'SMS Scheduler App').
- Click
Generate public and private key
. Save theprivate.key
file that downloads into the root of your project directory. The public key remains with Vonage. EnsureVONAGE_PRIVATE_KEY_PATH
in.env
points to this file. - Note the Application ID provided and add it to your
.env
file. - Enable the Messages capability.
- For Inbound URL and Status URL, you can leave these blank if you don't need to receive inbound SMS or delivery receipts for this simple scheduler. If you do want status updates (recommended for production), you'll need a publicly accessible URL. For local development:
- Run
ngrok http 3000
(assuming your app runs on port 3000). - Use the
https://<your-ngrok-subdomain>.ngrok.io/webhooks/status
for the Status URL andhttps://<your-ngrok-subdomain>.ngrok.io/webhooks/inbound
for the Inbound URL (we'll define these routes later if needed).
- Run
- Click
Create application
.
- Link Your Vonage Number:
- Go to Numbers > Your numbers.
- Find the SMS-capable virtual number you purchased or want to use. Add it to
VONAGE_NUMBER
in your.env
file (use E.164 format, e.g.,14155552671
). - Go back to Your applications, find your 'SMS Scheduler App', click
Edit
. - Under
Link Numbers
, find your virtual number and clickLink
.
Step 6: Create Basic Server File
Create a file named server.js
in the project root:
// server.js
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const cron = require('node-cron');
const { Vonage } = require('@vonage/server-sdk');
const { Auth } = require('@vonage/auth');
const fs = require('fs');
// --- Basic Configuration & Initialization ---
const app = express();
const port = process.env.PORT || 3000;
// Middleware to parse JSON request bodies
app.use(express.json());
// --- Vonage SDK Initialization ---
// Validate essential Vonage credentials are present
if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET || !process.env.VONAGE_APPLICATION_ID || !process.env.VONAGE_PRIVATE_KEY_PATH || !process.env.VONAGE_NUMBER) {
console.error("Error: Missing required Vonage environment variables in .env file.");
process.exit(1); // Exit if essential config is missing
}
let vonage;
try {
// Read the private key file content
// Read sync at startup is acceptable; avoid sync file I/O in request handlers.
const privateKey = fs.readFileSync(process.env.VONAGE_PRIVATE_KEY_PATH);
const credentials = new Auth({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET,
applicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: privateKey
});
vonage = new Vonage(credentials);
console.log("Vonage SDK Initialized Successfully.");
} catch (error) {
console.error("Error initializing Vonage SDK:", error.message);
if (error.code === 'ENOENT') {
console.error(` Ensure the private key file exists at path specified in .env: ${process.env.VONAGE_PRIVATE_KEY_PATH}`);
}
process.exit(1); // Exit if SDK initialization fails
}
// --- In-Memory Schedule Storage (IMPORTANT: Not suitable for production! See Section 6) ---
let scheduledJobs = []; // Structure: { id: string, to: string, text: string, scheduleTime: Date, status: 'pending' | 'sent' | 'failed', retryCount: number, vonageMessageUuid: string | null }
// --- API Endpoints ---
// Basic health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'OK', timestamp: new Date().toISOString() });
});
// --- Start Server ---
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
// Cron job starts automatically below if configured correctly
});
// --- Cron Job Logic (To be added) ---
// --- SMS Sending Logic (To be added) ---
// --- Webhook Handlers (Optional - To be added) ---
Step 7: Add Start Script
Modify your package.json
to include a convenient start script:
// package.json
{
// ... other properties
"scripts": {
"start": "node server.js",
"test": "echo \\"Error: no test specified\\" && exit 1"
},
// ... other properties
}
You can now run npm start
to start the basic server (it won't do much yet). Press Ctrl+C
to stop it.
2. Implementing Core Functionality (Scheduling)
Now, let's implement the logic for storing schedule requests and the cron job to check them.
Step 1: Implement the Schedule Storage (In-Memory)
We already defined the scheduledJobs
array in server.js
. Remember, this is highly discouraged for production due to data loss on restarts. Refer to Section 6 for persistent alternatives.
Step 2: Implement the Cron Job
We'll use node-cron
to run a task every minute to check for due messages.
Add the following inside server.js
, replacing the // --- Cron Job Logic ---
placeholder:
// server.js
// ... (Keep existing code above) ...
// --- SMS Sending Logic (Placeholder - Will be defined in Section 4) ---
async function sendScheduledSms(job) {
// Implementation will go here
console.warn(`[SendSMS] Placeholder called for Job ID: ${job.id}. Implement actual sending logic.`);
// Simulate success for now, returning a fake UUID
return `fake-uuid-${job.id}`;
// In real implementation, throw error on failure
}
// --- Cron Job Logic ---
console.log(""Setting up cron job to run every minute."");
cron.schedule('* * * * *', async () => { // Runs every minute
const now = new Date();
console.log(`[Cron Tick: ${now.toISOString()}] Checking for scheduled jobs...`);
// Find jobs that are pending and whose time is now or in the past
const jobsToSend = scheduledJobs.filter(job => job.status === 'pending' && job.scheduleTime <= now);
if (jobsToSend.length === 0) {
console.log(`[Cron] No jobs due at this time.`);
return;
}
console.log(`[Cron] Found ${jobsToSend.length} job(s) to process.`);
for (const job of jobsToSend) {
console.log(`[Cron] Processing Job ID: ${job.id} for recipient: ${job.to} (Attempt: ${job.retryCount + 1})`);
try {
const messageUuid = await sendScheduledSms(job); // Call the sending function
// Update status in our ""database"" (in-memory array)
job.status = 'sent';
job.vonageMessageUuid = messageUuid; // Store the Vonage Message UUID
console.log(`[Cron] Job ID ${job.id} sent successfully. Vonage UUID: ${messageUuid}`);
} catch (error) {
// Error logging should happen within sendScheduledSms
job.status = 'failed';
job.retryCount += 1; // Increment retry count
console.error(`[Cron] Job ID ${job.id} failed to send (Attempt: ${job.retryCount}). Marked as failed.`);
// Consider more robust retry logic or dead-letter queue (See Section 5)
}
}
// Optional: Implement logic here to clean up very old 'sent' or 'failed' jobs
// from the scheduledJobs array to prevent memory leaks over time.
// Example: scheduledJobs = scheduledJobs.filter(job => job.status === 'pending' || job.scheduleTime > someOldThreshold);
}, {
scheduled: true, // Start the job immediately upon application start
timezone: ""UTC"" // IMPORTANT: Run cron based on UTC timezone
});
console.log("" - Cron job scheduled successfully to run every minute."");
// ... (Keep existing code below - API Endpoints, Start Server) ...
Explanation:
cron.schedule('* * * * *', ...)
: Defines a task to run every minute.timezone: ""UTC""
: Crucial. Ensures the cron job runs based on Coordinated Universal Time (UTC), preventing issues related to server time zones or daylight saving changes. We will store and compare schedule times in UTC.- The callback function filters
scheduledJobs
for 'pending' jobs whosescheduleTime
is now or in the past. - It iterates through due jobs, calls
sendScheduledSms
(which we'll fully implement in Section 4), and captures the returnedmessageUuid
. - It updates the job
status
and stores thevonageMessageUuid
in the in-memory array. Note: In a real database, this update should be atomic. - Error handling increments
retryCount
and marks the job as 'failed'. Section 5 discusses more advanced retry strategies. - A placeholder for
sendScheduledSms
is added temporarily so the code runs.
3. Building the API Layer
Let's create the /schedule
endpoint to accept SMS scheduling requests.
Step 1: Define the /schedule
Endpoint
Add the following route handler within server.js
, between the // --- API Endpoints ---
comment and the // --- Start Server ---
section:
// server.js
// ... (Vonage Init, In-Memory Storage, Health Check Endpoint) ...
// Endpoint to schedule an SMS
app.post('/schedule', (req, res) => {
const { to, text, scheduleTime } = req.body;
// --- Input Validation ---
if (!to || !text || !scheduleTime) {
return res.status(400).json({ error: 'Missing required fields: to, text, scheduleTime' });
}
// Validate 'to' number format - Enforce E.164 standard
// E.164 format: '+' followed by country code and number (e.g., +14155552671)
if (!/^\+[1-9]\d{1,14}$/.test(to)) {
return res.status(400).json({ error: 'Invalid "to" phone number format. Please use E.164 format (e.g., +14155552671).' });
}
// Validate and parse 'scheduleTime'
let scheduledDate;
try {
// JavaScript's Date parsing can be inconsistent.
// Strongly recommend providing time in ISO 8601 format with timezone offset or 'Z' for UTC.
// e.g., "2025-12-31T10:30:00Z" or "2025-12-31T05:30:00-05:00"
scheduledDate = new Date(scheduleTime);
if (isNaN(scheduledDate.getTime())) { // Check if parsing resulted in a valid date
throw new Error("Invalid date format");
}
// Check if the date is in the past (allowing for a small buffer for processing time)
if (scheduledDate <= new Date(Date.now() + 1000)) { // Check if time is <= 1 second from now
return res.status(400).json({ error: 'scheduleTime must be in the future.' });
}
} catch (error) {
// For production_ consider using a robust date parsing library like date-fns or dayjs.
return res.status(400).json({ error: 'Invalid scheduleTime format or value. Please use ISO 8601 format (e.g._ 2025-12-31T10:30:00Z) and ensure it is in the future.' });
}
// --- Create and Store Job ---
const jobId = `job_${Date.now()}_${Math.random().toString(36).substring(7)}`; // Simple unique ID
const newJob = {
id: jobId_
to: to_
text: text_
scheduleTime: scheduledDate_ // Store as Date object (internally UTC epoch milliseconds)
status: 'pending'_
retryCount: 0_ // Initialize retry count
vonageMessageUuid: null // Initialize Vonage message ID storage
};
scheduledJobs.push(newJob);
console.log(`\nNew job scheduled:
ID: ${newJob.id}
To: ${newJob.to}
Scheduled For: ${newJob.scheduleTime.toISOString()}
Message: "${newJob.text}"`);
res.status(201).json({
message: 'SMS scheduled successfully.'_
jobId: newJob.id_
recipient: newJob.to_
scheduledTime: newJob.scheduleTime.toISOString()_ // Return standardized ISO string
status: newJob.status
});
});
// ... (Start Server_ Cron Job Logic) ...
Explanation:
- The endpoint listens for
POST
requests on/schedule
. - It uses
express.json()
middleware (configured earlier) to parse the JSON body. - Validation:
- Checks for the presence of
to
_text
_ andscheduleTime
. - Uses a stricter regex (
/^\+[1-9]\d{1_14}$/
) to enforce the E.164 phone number format (including the leading+
). - Validates
scheduleTime
:- Attempts to parse using
new Date()
. Note: This relies on the input string being in a formatnew Date()
understands_ preferably ISO 8601 with timezone (e.g._YYYY-MM-DDTHH:mm:ssZ
orYYYY-MM-DDTHH:mm:ss+HH:MM
). Inconsistent formats can lead to errors. Using libraries likedate-fns
ordayjs
is recommended for robust parsing in production. - Checks if the parsed date is valid (
isNaN
). - Ensures the date is in the future.
- Attempts to parse using
- Checks for the presence of
- Job Creation:
- Generates a simple unique
jobId
. - Creates the
newJob
object_ including initializingretryCount
to 0 andvonageMessageUuid
tonull
. - Stores the
scheduleTime
as a JavaScriptDate
object. Internally_ this represents milliseconds since the UTC epoch_ making comparisons straightforward.
- Generates a simple unique
- The new job object is added to the
scheduledJobs
array (our temporary in-memory store). - A
201 Created
response is sent back with confirmation details_ including the scheduled time in standardized ISO 8601 UTC format.
Step 2: Test the Endpoint
- Run the server:
npm start
- Use
curl
or a tool like Postman to send a POST request:
curl -X POST http://localhost:3000/schedule \
-H 'Content-Type: application/json' \
-d '{
"to": "+14155551234"_
"text": "Hello from the SMS Scheduler! This is a test."_
"scheduleTime": "2025-12-31T10:30:00Z"
}'
- Important: Replace
+14155551234
with a phone number you can check_ verified with Vonage if necessary (sandbox mode might require this). It must be in E.164 format. - Set
scheduleTime
to a time slightly in the future in UTC format (ending with 'Z'). Check your current UTC time if needed (e.g._ rundate -u
in Linux/macOS terminal).
You should see the job details logged in your server console and receive a JSON response like:
{
"message": "SMS scheduled successfully."_
"jobId": "job_1678886400000_abcdefg"_
"recipient": "+14155551234"_
"scheduledTime": "2025-12-31T10:30:00.000Z"_
"status": "pending"
}
4. Integrating with Vonage (Sending SMS)
Now_ let's implement the function that actually sends the SMS using the Vonage SDK.
Step 1: Implement the sendScheduledSms
Function
Replace the placeholder sendScheduledSms
function (added in Section 2) with the actual implementation within server.js
:
// server.js
// ... (Vonage Init_ In-Memory Storage_ API Endpoints) ...
// --- SMS Sending Logic ---
async function sendScheduledSms(job) {
console.log(` [SendSMS] Attempting to send SMS for Job ID: ${job.id} to ${job.to}`);
const fromNumber = process.env.VONAGE_NUMBER;
const toNumber = job.to;
const messageText = job.text;
try {
const resp = await vonage.messages.send({
message_type: 'text'_
text: messageText_
to: toNumber_ // Must be E.164 format for reliability
from: fromNumber_ // Your Vonage number
channel: 'sms'
});
console.log(` [SendSMS] SMS submitted successfully for Job ID: ${job.id}. Message UUID: ${resp.message_uuid}`);
// Note: Successful submission doesn't guarantee delivery.
// Use Status Webhooks (Section 10) for delivery confirmation.
return resp.message_uuid; // Return the UUID on success
} catch (err) {
// Log detailed error information
console.error(` [SendSMS] Error sending SMS for Job ID: ${job.id}.`);
if (err.response) {
// Log specific Vonage API error details if available
console.error(` Status: ${err.response.status} ${err.response.statusText}`);
console.error(` Data: ${JSON.stringify(err.response.data_ null_ 2)}`);
} else {
// Log general error message
console.error(` Error: ${err.message}`);
}
// Re-throw the error so the calling cron job logic knows it failed
// and can increment the retry count / mark as failed.
throw err;
}
}
// --- Cron Job Logic ---
// ... (cron.schedule code from Section 2 remains here) ...
// --- Start Server ---
// ... (app.listen) ...
// --- Webhook Handlers (Optional - To be added) ---
Explanation:
- The function takes the
job
object as input. - It retrieves the
from
number from environment variables (.env
) and theto
number andtext
from the job object. vonage.messages.send({...})
: This is the core Vonage SDK call.message_type: 'text'
: Standard text message.text
: SMS content.to
: Recipient number (should be E.164).from
: Your Vonage virtual number.channel: 'sms'
: Explicitly use SMS channel.
- Success: If the API call to Vonage is successful (HTTP 2xx response)_ it logs the
message_uuid
provided by Vonage. This UUID is crucial for tracking message status later. The function returns thismessage_uuid
. - Error Handling: A
try...catch
block handles errors.- It logs detailed error information_ including specific Vonage API responses (
err.response.data
) if available_ which helps diagnose issues like invalid numbers_ insufficient funds_ or authentication problems. - It re-throws the error. This is important so the cron job loop that called
sendScheduledSms
knows the operation failed and can update the job status to 'failed' and increment theretryCount
.
- It logs detailed error information_ including specific Vonage API responses (
5. Error Handling_ Logging_ and Retries
We've implemented basic logging (console.log
/console.error
) and error handling (try...catch
_ re-throwing errors). For production robustness:
- Consistent Logging: Use a dedicated logging library (e.g._
Winston
_Pino
) for structured logging (JSON)_ different log levels (info_ warn_ error_ debug)_ and routing logs to files or external services (Datadog_ Logstash_ etc.). This makes monitoring and debugging much easier. - Error Handling Strategy:
- Catch errors at logical boundaries (API handlers_ SDK calls_ background job processing).
- Provide clear_ non-sensitive error messages to API clients (like the
400 Bad Request
responses in/schedule
). - Log detailed internal errors with stack traces for debugging.
- Consider a global Express error handling middleware to catch unhandled exceptions and prevent crashes.
- Retry Mechanisms: The current code increments
retryCount
but doesn't stop retrying. Production systems need smarter retry logic for failed SMS sends:- Max Retries: Stop retrying after a certain number of attempts (e.g._ 3 or 5). Modify the cron job logic to check
job.retryCount
. - Exponential Backoff: Increase the delay between retries (e.g._ 1 min_ 5 min_ 15 min). This prevents hammering the Vonage API if there's a persistent issue and gives temporary problems time to resolve. Libraries like
async-retry
can simplify implementing this. - Dead Letter Queue: After exhausting retries_ move the failed job to a separate storage (another database table_ log file_ or queue) for manual inspection_ rather than letting it clog the main processing loop.
- Max Retries: Stop retrying after a certain number of attempts (e.g._ 3 or 5). Modify the cron job logic to check
(Code Example - Conceptual Enhanced Retry Logic in Cron Job)
// Inside cron.schedule callback:
// ... after jobsToSend = ...
for (const job of jobsToSend) {
// Check for max retries *before* attempting to send
const MAX_RETRIES = 3;
if (job.retryCount >= MAX_RETRIES) {
console.warn(`[Cron] Job ID: ${job.id} reached max retries (${MAX_RETRIES}). Marking as permanently failed.`);
job.status = 'failed_permanent'; // Use a distinct status or move to dead-letter queue
continue; // Skip processing this job further
}
console.log(`[Cron] Processing Job ID: ${job.id} for recipient: ${job.to} (Attempt: ${job.retryCount + 1})`);
try {
// Add exponential backoff delay here if needed based on job.retryCount
// if (job.retryCount > 0) { await delay(calculateBackoff(job.retryCount)); }
const messageUuid = await sendScheduledSms(job);
job.status = 'sent';
job.vonageMessageUuid = messageUuid;
console.log(`[Cron] Job ID ${job.id} sent successfully. Vonage UUID: ${messageUuid}`);
} catch (error) {
job.status = 'failed'; // Still marked as 'failed' for potential retry
job.retryCount += 1;
console.error(`[Cron] Job ID ${job.id} failed to send (Attempt: ${job.retryCount}). Marked as failed.`);
// Log error details (already done in sendScheduledSms)
}
}
// ...
6. Database Schema and Data Layer (Production Consideration)
Using the in-memory scheduledJobs
array is unsuitable for production. Any server restart, crash, or deployment will erase all pending schedules. You must use a persistent data store.
Requirements for a Persistent Store:
- Store job details reliably:
id
,to
,text
,scheduleTime
,status
,retryCount
,createdAt
,updatedAt
,vonageMessageUuid
. - Efficiently query for pending jobs due to run (
status = 'pending'
ANDscheduleTime <= now
). An index onstatus
andscheduleTime
is crucial. - Atomically update job status (
pending
->sent
/failed
) and storevonageMessageUuid
.
Options:
-
Relational Database (e.g., PostgreSQL, MySQL, MariaDB):
- Schema Example (PostgreSQL):
CREATE TABLE scheduled_sms ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Or SERIAL for auto-incrementing integer recipient_number VARCHAR(20) NOT NULL, -- Store E.164 message_body TEXT NOT NULL, scheduled_at TIMESTAMPTZ NOT NULL, -- Use TIMESTAMP WITH TIME ZONE status VARCHAR(20) NOT NULL DEFAULT 'pending', -- e.g., pending, sent, failed, failed_permanent retry_count INTEGER NOT NULL DEFAULT 0, vonage_message_uuid VARCHAR(50) NULL, -- To store Vonage response ID created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Index for efficient querying by the cron job CREATE INDEX idx_scheduled_sms_pending_time ON scheduled_sms (scheduled_at) WHERE status = 'pending'; -- Optional: Index for looking up by Vonage UUID (for webhooks) CREATE INDEX idx_scheduled_sms_vonage_uuid ON scheduled_sms (vonage_message_uuid);
- Data Layer: Use a Node.js ORM like
Sequelize
orPrisma
, or a query builder likeKnex.js
to interact with the database. Replace thescheduledJobs.push(...)
andjob.status = ...
logic with database operations (INSERT
,UPDATE
). - Cron Job Query:
SELECT * FROM scheduled_sms WHERE status = 'pending' AND scheduled_at <= NOW() ORDER BY scheduled_at ASC LIMIT 100;
(UseLIMIT
to process in batches). - Update:
UPDATE scheduled_sms SET status = 'sent', vonage_message_uuid = $1, updated_at = NOW() WHERE id = $2;
(Use parameterized queries).
- Schema Example (PostgreSQL):
-
NoSQL Database (e.g., MongoDB):
- Create a collection with a similar document structure.
- Ensure appropriate indexes on
status
andscheduled_at
for efficient querying. MongoDB's TTL (Time-To-Live) indexes could potentially be used for auto-cleanup of old jobs if desired.
-
Task Queue / Message Broker (e.g., Redis with BullMQ, RabbitMQ, AWS SQS):
- Often the best approach for job scheduling.
- When
/schedule
is hit, instead of storing directly, add a job to the queue with a specified delay corresponding toscheduleTime
. - Separate worker processes listen to the queue. The queue system handles persistence, delivering the job to a worker only when it's due.
- These systems often have built-in support for retries, exponential backoff, concurrency control, and monitoring. This offloads much of the complexity from your application logic.
Decision: While this guide uses the simple (but non-production-ready) in-memory approach, strongly consider switching to a persistent database or a dedicated task queue for any real-world application.
7. Adding Security Features
Security is critical. Implement these measures:
- Input Validation (API Layer):
- We added basic checks in
/schedule
. - Use a library: Employ
joi
orexpress-validator
for robust schema validation (data types, formats, lengths, patterns) on the incoming request body (to
,text
,scheduleTime
). This prevents malformed data and potential injection issues. - Sanitize Output (If Applicable): While less critical for SMS text itself, if this data were ever displayed in a web context, ensure proper output encoding/escaping to prevent XSS.
- We added basic checks in
- Rate Limiting: Protect the
/schedule
endpoint from abuse (accidental or malicious).- Use middleware like
express-rate-limit
. -
// npm install express-rate-limit const rateLimit = require('express-rate-limit'); const scheduleLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per windowMs message: { error: 'Too many schedule requests created from this IP, please try again after 15 minutes' }, standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); // Apply the rate limiting middleware to the schedule endpoint app.use('/schedule', scheduleLimiter);
- Use middleware like
- Authentication/Authorization:
- If this API is not purely internal, protect the
/schedule
endpoint. Implement API Key checking, JWT validation, OAuth, or another appropriate mechanism to ensure only authorized clients can schedule messages.
- If this API is not purely internal, protect the
- Secure Credential Management:
- Never commit
.env
files orprivate.key
files to Git. Use.gitignore
. - In production, inject secrets using secure environment variable management provided by your hosting platform or a dedicated secrets manager (e.g., AWS Secrets Manager, HashiCorp Vault, Google Secret Manager, Doppler).
- Never commit
- Helmet Middleware: Set various security-related HTTP headers. While less critical for a pure API backend with no browser interaction, it's good practice.
npm install helmet
-
const helmet = require('helmet'); app.use(helmet()); // Add near the top of middleware definitions
- Dependency Management: Regularly check for and update vulnerable dependencies.
npm audit