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.
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
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
# .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
# .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:
Text
# .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 the private.key file that downloads into the root of your project directory. The public key remains with Vonage. Ensure VONAGE_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 and https://<your-ngrok-subdomain>.ngrok.io/webhooks/inbound for the Inbound URL (we'll define these routes later if needed).
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 click Link.
Step 6: Create Basic Server File
Create a file named server.js in the project root:
JavaScript
// server.jsrequire('dotenv').config(); // Load environment variables from .env fileconst 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 presentif (!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 = newAuth({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET,
applicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: privateKey
});
vonage = newVonage(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: newDate().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:
JSON
// 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:
JavaScript
// server.js// ... (Keep existing code above) ...// --- SMS Sending Logic (Placeholder - Will be defined in Section 4) ---asyncfunctionsendScheduledSms(job) {
// Implementation will go hereconsole.warn(`[SendSMS] Placeholder called for Job ID: ${job.id}. Implement actual sending logic.`);
// Simulate success for now, returning a fake UUIDreturn`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 minuteconst now = newDate();
console.log(`[Cron Tick: ${now.toISOString()}] Checking for scheduled jobs...`);
// Find jobs that are pending and whose time is now or in the pastconst 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 = awaitsendScheduledSms(job); // Call the sending function// Update status in our ""database"" (in-memory array)
job.status = 'sent';
job.vonageMessageUuid = messageUuid; // Store the Vonage Message UUIDconsole.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 countconsole.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 starttimezone: "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 scheduledJobs for 'pending' jobs whose scheduleTime is now or in the past.
It iterates through due jobs, calls sendScheduledSms (which we'll fully implement in Section 4), and captures the returned messageUuid.
It updates the job status and stores the vonageMessageUuid 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. 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:
JavaScript
// 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 specificationif (!/^\+[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 = newDate(scheduleTime);
if (isNaN(scheduledDate.getTime())) { // Check if parsing resulted in a valid datethrownewError("Invalid date format");
}
// Check if the date is in the past (allowing for a small buffer for processing time)if (scheduledDate <= newDate(Date.now() + 1000)) { // Check if time is <= 1 second from nowreturn 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 IDconst newJob = {
id: jobId,
to: to,
text: text,
scheduleTime: scheduledDate, // Store as Date object (internally UTC epoch milliseconds)status: 'pending',
retryCount: 0, // Initialize retry countvonageMessageUuid: 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 stringstatus: 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, and scheduleTime.
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 format new Date() understands, preferably ISO 8601 with timezone (e.g., YYYY-MM-DDTHH:mm:ssZ or YYYY-MM-DDTHH:mm:ss+HH:MM). Inconsistent formats can lead to errors. Using libraries like date-fns or dayjs is recommended for robust parsing in production.
Checks if the parsed date is valid (isNaN).
Ensures the date is in the future.
Job Creation:
Generates a simple unique jobId.
Creates the newJob object, including initializing retryCount to 0 and vonageMessageUuid to null.
Stores the scheduleTime as a JavaScript Date object. Internally, this represents milliseconds since the UTC epoch, making comparisons straightforward.
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:
Bash
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., run date -u in Linux/macOS terminal).
You should see the job details logged in your server console and receive a JSON response like:
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:
JavaScript
// server.js// ... (Vonage Init, In-Memory Storage, API Endpoints) ...// --- SMS Sending Logic ---asyncfunctionsendScheduledSms(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 reliabilityfrom: fromNumber, // Your Vonage numberchannel: '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 informationconsole.error(` [SendSMS] Error sending SMS for Job ID: ${job.id}.`);
if (err.response) {
// Log specific Vonage API error details if availableconsole.error(` Status: ${err.response.status}${err.response.statusText}`);
console.error(` Data: ${JSON.stringify(err.response.data, null, 2)}`);
} else {
// Log general error messageconsole.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 the to number and text 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 this message_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 the retryCount.
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.
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.
(Code Example - Conceptual Enhanced Retry Logic in Cron Job)
JavaScript
// Inside cron.schedule callback:// ... after jobsToSend = ...for (const job of jobsToSend) {
// Check for max retries *before* attempting to sendconstMAX_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 queuecontinue; // 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 = awaitsendScheduledSms(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' AND scheduleTime <= now). An index on status and scheduleTime is crucial.
Atomically update job status (pending -> sent / failed) and store vonageMessageUuid.
CREATE TABLE scheduled_sms (
id UUID PRIMARY KEYDEFAULT 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 NULLDEFAULT'pending', -- e.g., pending, sent, failed, failed_permanent
retry_count INTEGERNOT NULLDEFAULT0,
vonage_message_uuid VARCHAR(50) NULL, -- To store Vonage response ID
created_at TIMESTAMPTZ NOT NULLDEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULLDEFAULT 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 reportingCREATE 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 CHECK constraint on scheduled_at to ensure future dates: CHECK (scheduled_at > created_at)
Data Layer: Use a Node.js ORM like Sequelize or Prisma, or a query builder like Knex.js to interact with the database. Replace the scheduledJobs.push(...) and job.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; (Use LIMIT to process in batches).
Update:UPDATE scheduled_sms SET status = 'sent', vonage_message_uuid = $1, updated_at = NOW() WHERE id = $2; (Use parameterized queries).
NoSQL Database (e.g., MongoDB):
Create a collection with a similar document structure.
Ensure appropriate indexes on status and scheduledAt for efficient querying. MongoDB's TTL (Time-To-Live) indexes could potentially be used for auto-cleanup of old jobs if desired.
Often the best approach for job scheduling. Recommended for production systems.
When /schedule is hit, instead of storing directly, add a job to the queue with a specified delay corresponding to scheduleTime.
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 redisconst { Queue, Worker } = require('bullmq');
const { Vonage } = require('@vonage/server-sdk');
// Create queue connectionconst smsQueue = newQueue('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 = newDate(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 millisecondsattempts: 3, // Retry up to 3 timesbackoff: {
type: 'exponential',
delay: 60000// Start with 1 minute, then 2 min, 4 min, etc.
},
removeOnComplete: 1000, // Keep last 1000 completed jobsremoveOnFail: 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 = newWorker('sms-scheduler', async job => {
const { to, text } = job.data;
// Send SMS using Vonageconst 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 concurrentlylimiter: {
max: 10, // Max 10 jobsduration: 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 joi or express-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.
Rate Limiting: Protect the /schedule endpoint 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-limitconst rateLimit = require('express-rate-limit');
const scheduleLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 100, // Limit each IP to 100 requests per windowMsmessage: { error: 'Too many schedule requests created from this IP, please try again after 15 minutes' },
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headerslegacyHeaders: 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.
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.
Never commit .env files or private.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).
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 audit
npm 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
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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
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.
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.
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).
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.
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:
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:
@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 managementHow the System Works:
POSTrequests to/schedulewith recipient details, message content, and delivery timenode-cronjob runs every minute to check stored schedulesPrerequisites:
Important API Specifications:
+followed by country code and subscriber number (maximum 15 digits per ITU-T E.164 specification)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):
This creates a
package.jsonfile.Step 3: Install Dependencies
Install the necessary libraries:
express: 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
.envin the root of your project. This file will store your sensitive credentials and configuration. Never commit this file to version control.Create a
.env.examplefile to document the required variables for other developers (or your future self):Finally, create a
.gitignorefile to prevent committing sensitive files and unnecessary folders:Step 5: Obtain Vonage Credentials and Set Up Application
.envfile.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..envfile.ngrok http 3000(assuming your app runs on port 3000).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).Create application.VONAGE_NUMBERin your.envfile (use E.164 format, e.g.,14155552671).Edit.Link Numbers, find your virtual number and clickLink.Step 6: Create Basic Server File
Create a file named
server.jsin 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.jsonto 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 startto start the basic server (it won't do much yet). PressCtrl+Cto 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
scheduledJobsarray inserver.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-cronto 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.scheduledJobsfor 'pending' jobs whosescheduleTimeis now or in the past.sendScheduledSms(which we'll fully implement in Section 4), and captures the returnedmessageUuid.statusand stores thevonageMessageUuidin the in-memory array. Note: In a real database, this update should be atomic.retryCountand marks the job as 'failed'. Section 5 discusses more advanced retry strategies.sendScheduledSmsis added temporarily so the code runs.3. Create the SMS Scheduling API Endpoint
Create the
/scheduleendpoint to accept and validate SMS scheduling requests.Step 1: Define the
/scheduleEndpointAdd 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:
POSTrequests on/schedule.express.json()middleware (configured earlier) to parse the JSON body.to,text, andscheduleTime./^\+[1-9]\d{1,14}$/) to enforce the E.164 phone number format (including the leading+).scheduleTime: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.isNaN).jobId.newJobobject, including initializingretryCountto 0 andvonageMessageUuidtonull.scheduleTimeas a JavaScriptDateobject. Internally, this represents milliseconds since the UTC epoch, making comparisons straightforward.scheduledJobsarray (our temporary in-memory store).201 Createdresponse is sent back with confirmation details, including the scheduled time in standardized ISO 8601 UTC format.Step 2: Test the Endpoint
npm startcurlor 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" }'+14155551234with a phone number you can check, verified with Vonage if necessary (sandbox mode might require this). It must be in E.164 format.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
sendScheduledSmsFunctionReplace the placeholder
sendScheduledSmsfunction (added in Section 2) with the actual implementation withinserver.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:
jobobject as input.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.message_uuidprovided by Vonage. This UUID is crucial for tracking message status later. The function returns thismessage_uuid.try...catchblock handles errors.err.response.data) if available, which helps diagnose issues like invalid numbers, insufficient funds, or authentication problems.sendScheduledSmsknows the operation failed and can update the job status to 'failed' and increment theretryCount.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: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.400 Bad Requestresponses in/schedule).retryCountbut doesn't stop retrying. Production systems need smarter retry logic for failed SMS sends:job.retryCount.async-retrycan simplify implementing this.(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
scheduledJobsarray 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:
id,to,text,scheduleTime,status,retryCount,createdAt,updatedAt,vonageMessageUuid.status = 'pending'ANDscheduleTime <= now). An index onstatusandscheduleTimeis crucial.pending->sent/failed) and storevonageMessageUuid.Options:
Relational Database (e.g., PostgreSQL, MySQL, MariaDB):
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);TIMESTAMPTZ(timestamp with time zone) which stores values in UTC internally and converts based on session timezoneCHECKconstraint onscheduled_atto ensure future dates:CHECK (scheduled_at > created_at)SequelizeorPrisma, or a query builder likeKnex.jsto interact with the database. Replace thescheduledJobs.push(...)andjob.status = ...logic with database operations (INSERT,UPDATE).SELECT * FROM scheduled_sms WHERE status = 'pending' AND scheduled_at <= NOW() ORDER BY scheduled_at ASC LIMIT 100;(UseLIMITto process in batches).UPDATE scheduled_sms SET status = 'sent', vonage_message_uuid = $1, updated_at = NOW() WHERE id = $2;(Use parameterized queries).NoSQL Database (e.g., MongoDB):
statusandscheduledAtfor efficient querying. MongoDB's TTL (Time-To-Live) indexes could potentially be used for auto-cleanup of old jobs if desired.// 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:
BullMQ Implementation Example:
// 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:
/schedule.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./scheduleendpoint from abuse (accidental or malicious).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);/scheduleendpoint. Implement API Key checking, JWT validation, OAuth, or another appropriate mechanism to ensure only authorized clients can schedule messages.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) => { /* ... */ });.envfiles orprivate.keyfiles to Git. Use.gitignore.npm install helmetconst helmet = require('helmet'); app.use(helmet()); // Add near the top of middleware definitionsnpm auditnpm audit fix(review changes before applying)Frequently Asked Questions
How do I schedule an SMS message using Vonage API? Create a POST request to your
/scheduleendpoint withto(E.164 phone number),text(message content), andscheduleTime(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
TIMESTAMPTZfor 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
.envfiles (never commit to Git). Use.gitignoreto exclude sensitive files. In production, use secrets managers like AWS Secrets Manager, HashiCorp Vault, or Google Secret Manager. Implement API key authentication on your/scheduleendpoint.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).