code examples
code examples
How to Schedule SMS Reminders with Vonage API, Node.js & Express (2025 Guide)
Build an automated SMS reminder system using Vonage Messages API, Node.js, Express, and node-cron. Complete tutorial with code examples, cron job scheduling, webhook integration, database design, and production deployment best practices.
Build an SMS Scheduler with Vonage, Node.js & Express
Learn how to build an automated SMS reminder and scheduling system with the Vonage Messages API, Node.js, and Express. This complete tutorial shows you how to schedule SMS messages for future delivery, implement reliable cron jobs, handle delivery status webhooks, and deploy to production with confidence.
What You'll Build:
- Accept API requests to schedule SMS messages for future delivery
- Reliably send scheduled SMS messages at designated times using Vonage
- Implement confirmation logging and error handling
Common Use Cases: Appointment reminders for healthcare and services, event notifications for registrations, automated follow-ups for sales teams, delivery notifications for e-commerce, payment reminders for billing systems, and time-sensitive alerts for emergency services.
Technology Stack:
- Node.js: JavaScript runtime for building the backend server
- Express: Web application framework for the API layer
- Vonage Messages API: Multi-channel messaging platform supporting SMS, MMS, WhatsApp, and Viber
@vonage/server-sdk: Official Vonage Node.js SDK (v3.25.1 as of September 2024)node-cron: Task scheduler based on cron syntax for triggering scheduled messagesdotenv: Secure environment variable management- ngrok (optional): Local webhook testing for delivery status updates
How the System Works:
- Client applications send
POSTrequests to/schedulewith recipient details, message content, and delivery time - Express server validates requests and stores scheduling details (in-memory for this demo; use a database for production)
node-cronjob runs every minute to check stored schedules- When a schedule's time arrives, the cron job triggers the SMS sending function
- Vonage Node.js SDK sends messages via the Vonage Messages API
- Vonage delivers SMS to recipients' phones
- (Optional) Vonage sends delivery status updates to your webhook URL
Prerequisites:
- Node.js and npm (or yarn): Download Node.js
- Vonage API Account: Sign up free
- Vonage API Credentials: API Key, API Secret, Application ID, and Private Key file
- Vonage Virtual Number: SMS-capable number from the Vonage Dashboard
- (Optional) ngrok: Download for webhook testing
Important API Specifications:
- SMS Character Limits: 160 characters (GSM-7) or 70 characters (Unicode/UCS-2). Longer messages send as concatenated messages with multiple charges.
- E.164 Phone Number Format: Required format is
+followed by country code and subscriber number (maximum 15 digits per ITU-T E.164 specification) - Rate Limits: 10–30 messages per second depending on account tier. Check your dashboard for specific limits.
1. Project Setup: Initialize Your Node.js SMS Scheduler
Initialize the project, install dependencies, and configure your development environment.
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-vonageStep 2: Initialize Node.js Project
Initialize the project using npm (accept defaults or customize as needed):
npm init -yThis creates a package.json file.
Step 3: Install Dependencies
Install the necessary libraries:
npm install express @vonage/server-sdk node-cron dotenvexpress: Web framework.@vonage/server-sdk: Vonage API client library.node-cron: Task scheduler.dotenv: Loads environment variables from a.envfile.
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=3000Create 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=3000Finally, create a .gitignore file to prevent committing sensitive files and unnecessary folders:
# .gitignore
node_modules
.env
*.log
private.keyStep 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
.envfile. - 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.keyfile that downloads into the root of your project directory. The public key remains with Vonage. EnsureVONAGE_PRIVATE_KEY_PATHin.envpoints to this file. - Note the Application ID provided and add it to your
.envfile. - 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/statusfor the Status URL andhttps://<your-ngrok-subdomain>.ngrok.io/webhooks/inboundfor 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_NUMBERin your.envfile (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. Implement Cron Jobs for Automated SMS Scheduling
Implement the logic for storing schedule requests and creating cron jobs to process 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 (IANA timezone database names supported, e.g., "America/New_York", "Europe/London")
});
console.log(" - Cron job scheduled successfully to run every minute.");
console.log(" - Timezone: UTC (prevents issues with daylight saving time changes)");
console.log(" - Production note: Consider using a dedicated task queue (BullMQ, AWS SQS) for improved reliability and scalability");
// ... (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
scheduledJobsfor 'pending' jobs whosescheduleTimeis 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
statusand stores thevonageMessageUuidin the in-memory array. Note: In a real database, this update should be atomic. - Error handling increments
retryCountand marks the job as 'failed'. Section 5 discusses more advanced retry strategies. - A placeholder for
sendScheduledSmsis added temporarily so the code runs.
3. Create the SMS Scheduling API Endpoint
Create the /schedule endpoint to accept and validate 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 subscriber number
// Maximum 15 digits total per ITU-T E.164 specification
if (!/^\+[1-9]\d{1,14}$/.test(to)) {
return res.status(400).json({ error: 'Invalid "to" phone number format. Must use E.164 format with + prefix and maximum 15 digits (e.g., +14155552671).' });
}
// Validate message text length (160 chars for GSM-7, 70 for Unicode)
if (!text || text.length > 1000) {
return res.status(400).json({ error: 'Message text must be between 1 and 1000 characters. Note: SMS messages over 160 GSM-7 characters or 70 Unicode characters will be sent as concatenated messages.' });
}
// 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
POSTrequests 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:ssZorYYYY-MM-DDTHH:mm:ss+HH:MM). Inconsistent formats can lead to errors. Using libraries likedate-fnsordayjsis 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
newJobobject, including initializingretryCountto 0 andvonageMessageUuidtonull. - Stores the
scheduleTimeas a JavaScriptDateobject. Internally, this represents milliseconds since the UTC epoch, making comparisons straightforward.
- Generates a simple unique
- The new job object is added to the
scheduledJobsarray (our temporary in-memory store). - A
201 Createdresponse 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
curlor 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
+14155551234with a phone number you can check, verified with Vonage if necessary (sandbox mode might require this). It must be in E.164 format. - Set
scheduleTimeto a time slightly in the future in UTC format (ending with 'Z'). Check your current UTC time if needed (e.g., rundate -uin 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. Integrate Vonage Messages API to Send Scheduled SMS
Implement the function that sends SMS messages using the Vonage Node.js 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
jobobject as input. - It retrieves the
fromnumber from environment variables (.env) and thetonumber andtextfrom 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_uuidprovided by Vonage. This UUID is crucial for tracking message status later. The function returns thismessage_uuid. - Error Handling: A
try...catchblock 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
sendScheduledSmsknows 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. Implement Error Handling & Retry Logic for SMS Delivery
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 Requestresponses 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
retryCountbut 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-retrycan 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. Design a Production Database for SMS Scheduling
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 onstatusandscheduleTimeis crucial. - Atomically update job status (
pending->sent/failed) and storevonageMessageUuid.
Options:
-
Relational Database (e.g., PostgreSQL, MySQL, MariaDB):
- Schema Example (PostgreSQL):
sql
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 format (max 15 digits + prefix) message_body TEXT NOT NULL, scheduled_at TIMESTAMPTZ NOT NULL, -- TIMESTAMPTZ stores in UTC, converts on retrieval 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() ); -- Partial index for efficient querying by the cron job (indexes only pending rows) 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); -- Optional: Index for status lookups and reporting CREATE INDEX idx_scheduled_sms_status ON scheduled_sms (status, created_at DESC); - PostgreSQL Best Practices:
- Use
TIMESTAMPTZ(timestamp with time zone) which stores values in UTC internally and converts based on session timezone - All timezone-aware timestamps are stored as UTC epoch, eliminating timezone ambiguity
- Use parameterized queries to prevent SQL injection
- Consider adding a
CHECKconstraint onscheduled_atto ensure future dates:CHECK (scheduled_at > created_at)
- Use
- Data Layer: Use a Node.js ORM like
SequelizeorPrisma, or a query builder likeKnex.jsto 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;(UseLIMITto 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
statusandscheduledAtfor efficient querying. MongoDB's TTL (Time-To-Live) indexes could potentially be used for auto-cleanup of old jobs if desired. -
javascript
// MongoDB Schema Example (with Mongoose) const scheduledSmsSchema = new mongoose.Schema({ recipientNumber: { type: String, required: true, match: /^\+[1-9]\d{1,14}$/ }, messageBody: { type: String, required: true, maxlength: 1000 }, scheduledAt: { type: Date, required: true, index: true }, status: { type: String, enum: ['pending', 'sent', 'failed', 'failed_permanent'], default: 'pending', index: true }, retryCount: { type: Number, default: 0 }, vonageMessageUuid: { type: String, sparse: true } }, { timestamps: true }); // Compound index for efficient pending job queries scheduledSmsSchema.index({ status: 1, scheduledAt: 1 });
-
Task Queue / Message Broker (e.g., Redis with BullMQ, RabbitMQ, AWS SQS):
-
Often the best approach for job scheduling. Recommended for production systems.
-
When
/scheduleis 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 have built-in support for retries, exponential backoff, concurrency control, rate limiting, and monitoring. This offloads much of the complexity from your application logic.
-
BullMQ Advantages Over node-cron:
- Persistence: Jobs survive application restarts (stored in Redis)
- Distributed: Multiple workers can process jobs across different servers
- Built-in retries: Automatic exponential backoff and configurable retry strategies
- Job prioritization: Process urgent messages first
- Concurrency control: Limit parallel job execution
- Progress tracking: Monitor job completion status
- Minimal CPU usage: Polling-free design using Redis pub/sub
- Sandboxed execution: Run blocking code without stalling the queue
-
BullMQ Implementation Example:
javascript// npm install bullmq redis const { Queue, Worker } = require('bullmq'); const { Vonage } = require('@vonage/server-sdk'); // Create queue connection const smsQueue = new Queue('sms-scheduler', { connection: { host: process.env.REDIS_HOST || 'localhost', port: process.env.REDIS_PORT || 6379 } }); // Add scheduled SMS to queue (in your /schedule endpoint) app.post('/schedule', async (req, res) => { const { to, text, scheduleTime } = req.body; // ... validation code ... const scheduledDate = new Date(scheduleTime); const delayMs = scheduledDate.getTime() - Date.now(); const job = await smsQueue.add('send-sms', { to: to, text: text, scheduleTime: scheduledDate.toISOString() }, { delay: delayMs, // Delay in milliseconds attempts: 3, // Retry up to 3 times backoff: { type: 'exponential', delay: 60000 // Start with 1 minute, then 2 min, 4 min, etc. }, removeOnComplete: 1000, // Keep last 1000 completed jobs removeOnFail: 5000 // Keep last 5000 failed jobs }); res.status(201).json({ message: 'SMS scheduled successfully.', jobId: job.id, recipient: to, scheduledTime: scheduledDate.toISOString() }); }); // Create worker to process jobs (can run in separate process/server) const worker = new Worker('sms-scheduler', async job => { const { to, text } = job.data; // Send SMS using Vonage const resp = await vonage.messages.send({ message_type: 'text', text: text, to: to, from: process.env.VONAGE_NUMBER, channel: 'sms' }); return { messageUuid: resp.message_uuid }; }, { connection: { host: process.env.REDIS_HOST || 'localhost', port: process.env.REDIS_PORT || 6379 }, concurrency: 5, // Process up to 5 jobs concurrently limiter: { max: 10, // Max 10 jobs duration: 1000 // per second (respects Vonage rate limits) } }); worker.on('completed', job => { console.log(`Job ${job.id} completed successfully`); }); worker.on('failed', (job, err) => { console.error(`Job ${job.id} failed:`, err.message); });
-
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. For production systems handling significant SMS volume, BullMQ with Redis is the recommended approach.
7. Secure Your SMS Scheduling API (Authentication & Rate Limiting)
Security is critical. Implement these measures:
- Input Validation (API Layer):
- We added basic checks in
/schedule. - Use a library: Employ
joiorexpress-validatorfor 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
/scheduleendpoint from abuse (accidental or malicious).- Use middleware like
express-rate-limit. - Recommended Thresholds: Per OWASP best practices, implement progressive rate limiting:
- Conservative: 10-20 requests per 15 minutes per IP
- Moderate: 50-100 requests per 15 minutes per IP (suitable for most applications)
- Consider per-user limits in addition to per-IP limits for authenticated endpoints
-
javascript
// 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); - Advanced: Implement exponential backoff or CAPTCHA after multiple failed attempts to deter automated attacks.
- Use middleware like
- Authentication/Authorization:
- If this API is not purely internal, protect the
/scheduleendpoint. Implement API Key checking, JWT validation, OAuth, or another appropriate mechanism to ensure only authorized clients can schedule messages. - Example API Key Middleware:
javascript
function validateApiKey(req, res, next) { const apiKey = req.headers['x-api-key']; if (!apiKey || apiKey !== process.env.API_KEY) { return res.status(401).json({ error: 'Unauthorized: Invalid or missing API key' }); } next(); } // Apply to schedule endpoint app.post('/schedule', validateApiKey, (req, res) => { /* ... */ });
- If this API is not purely internal, protect the
- Secure Credential Management:
- Never commit
.envfiles orprivate.keyfiles 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-
javascript
const helmet = require('helmet'); app.use(helmet()); // Add near the top of middleware definitions
- Dependency Management: Regularly check for and update vulnerable dependencies.
npm auditnpm audit fix(review changes before applying)- Consider automated tools like Snyk or Dependabot for continuous monitoring
Frequently Asked Questions
How do I schedule an SMS message using Vonage API?
Create a POST request to your /schedule endpoint with to (E.164 phone number), text (message content), and scheduleTime (ISO 8601 UTC timestamp). The server validates the request and stores it for automated delivery.
What's the difference between Vonage Messages API and SMS API? The Messages API is the modern multi-channel platform supporting SMS, MMS, WhatsApp, Viber, and Messenger. The SMS API is the legacy endpoint for SMS-only messaging. This guide uses Messages API for future compatibility.
How do I handle SMS delivery failures? Implement retry logic with exponential backoff (e.g., retry after 1 min, 5 min, 15 min). Set maximum retry limits (3–5 attempts). Move permanently failed messages to a dead-letter queue for manual review. Use Vonage delivery status webhooks to track actual delivery.
Can I use node-cron for production SMS scheduling? Node-cron works for small-scale applications but has limitations: no persistence (jobs lost on restart), no distributed processing, and manual retry implementation. For production, use BullMQ with Redis, AWS SQS, or RabbitMQ for built-in persistence, retries, and distributed processing.
What database should I use to store scheduled SMS messages?
PostgreSQL is recommended for its TIMESTAMPTZ support (automatic UTC storage with timezone conversion), ACID compliance, and efficient partial indexing. Use TIMESTAMPTZ for all timestamp columns to eliminate timezone ambiguity. MongoDB works well for high-volume applications. For large-scale systems, use a dedicated task queue like BullMQ.
How do I secure my Vonage API credentials?
Store credentials in environment variables using .env files (never commit to Git). Use .gitignore to exclude sensitive files. In production, use secrets managers like AWS Secrets Manager, HashiCorp Vault, or Google Secret Manager. Implement API key authentication on your /schedule endpoint.
What's the maximum length for SMS messages via Vonage? Standard SMS: 160 characters (GSM-7 encoding) or 70 characters (Unicode/UCS-2). Longer messages automatically send as concatenated SMS, charging per segment (up to 153 characters per segment for GSM-7, 67 for Unicode).
How do I test SMS scheduling locally?
Use ngrok to expose your local server publicly for webhook testing: ngrok http 3000. Configure the ngrok HTTPS URL in your Vonage Application's webhook settings. Schedule SMS messages 2–3 minutes in the future for testing.
What timezone should I use for scheduled times?
Always use UTC (Coordinated Universal Time) for storing and comparing scheduled times. Configure node-cron with timezone: "UTC" to prevent daylight saving time issues. Accept ISO 8601 timestamps with timezone offsets from clients (e.g., 2025-12-31T10:30:00Z).
How many SMS messages can I send per second with Vonage?
Standard accounts: 10–30 messages per second. Higher tiers support increased rates. Implement rate limiting in your worker processes (BullMQ example in Section 6 shows limiter: { max: 10, duration: 1000 } for 10 messages per second).
Frequently Asked Questions
How to schedule SMS messages with Node.js?
Use Node.js with Express, the Vonage Messages API, and node-cron to schedule SMS messages. Create an Express API endpoint to handle scheduling requests, store the schedule details, and use a cron job to trigger the Vonage API to send the SMS at the specified time.
What is the Vonage Messages API used for?
The Vonage Messages API is used to reliably send SMS messages. It's integrated into the Node.js application using the official Vonage Node.js SDK (@vonage/server-sdk), allowing you to send text messages programmatically.
Why use node-cron in an SMS scheduler?
Node-cron is a task scheduler that allows you to automate tasks in a Node.js environment. In this SMS scheduler app, it's used to trigger the sending of SMS messages at the scheduled times based on cron syntax.
When should I use ngrok with Vonage?
ngrok is recommended for local development and testing with Vonage. It exposes your local server to the internet, which is necessary for receiving Vonage webhook status updates and fully testing message delivery and status reporting during development.
What is the purpose of dotenv in this project?
Dotenv is used for secure credential management by loading environment variables from a .env file. This keeps sensitive API keys and secrets out of your codebase, protecting them from accidental exposure.
How to create a Vonage application for SMS scheduling?
In the Vonage API Dashboard, create a new application, generate public and private keys (save the private key), note the Application ID, and enable the Messages capability. You may configure Inbound and Status URLs, optionally using ngrok for local testing.
How to handle Vonage API credentials securely?
Store Vonage API credentials (API Key, API Secret, Application ID, Private Key path) in a .env file, which should be excluded from version control using .gitignore. For production, use a dedicated secrets manager.
Can I use the in-memory array for production SMS scheduling?
No, the in-memory array for storing scheduled SMS jobs is not suitable for production. A persistent data store like a relational database (PostgreSQL, MySQL) or a NoSQL database (MongoDB) is required to prevent data loss on server restarts or crashes. Task queues are another robust option
What database schema should I use for SMS scheduling?
A suitable database schema for SMS scheduling should include fields for recipient number, message body, scheduled time (using TIMESTAMPTZ for timezone support), status, retry count, Vonage message UUID, creation timestamp, and update timestamp. Ensure you have indexes on status and scheduled time for efficient querying.
How to implement SMS retry logic with Vonage?
Implement retry logic by incrementing a retry count on failure and using exponential backoff to increase the delay between retries. Set a maximum retry limit to prevent infinite retries and consider a dead-letter queue for permanently failed jobs after exhausting retry attempts.
What is the E.164 format for phone numbers?
E.164 is an international telephone number format that ensures consistent number representation. It consists of a '+' followed by the country code and the national subscriber number, without any spaces or special characters. Example: +14155552671
Why does UTC timezone matter for SMS scheduling?
Using UTC for scheduling ensures that the cron job triggers SMS messages at the correct time, regardless of the server's time zone or daylight saving time changes. All schedule times should be stored and compared in UTC to avoid discrepancies.
What are best practices for error handling in a Node.js SMS app?
Use try-catch blocks for error handling within API endpoints and function calls. Provide helpful error messages to clients in API responses (400 Bad Request, etc.) and implement structured logging (e.g. Winston or Pino) for internal errors with stack traces to aid debugging.
How to secure the /schedule API endpoint?
Implement robust input validation using libraries like Joi, and employ rate limiting (e.g., express-rate-limit) to prevent abuse. Secure the endpoint using suitable authentication and authorization mechanisms (API keys, JWT, OAuth).
What are some Node.js ORMs I can use with a database?
Popular Node.js Object-Relational Mappers (ORMs) you can use to interact with relational databases include Sequelize and Prisma. These simplify database operations and data management within your Node.js application.