Schedule SMS Messages with Plivo, Node.js & Express: Complete Guide
Learn how to build an SMS scheduling application with Plivo, Node.js, and Express. Send automated appointment reminders, alerts, and time-sensitive notifications with step-by-step code examples.
Build a production-ready Node.js application using Express to schedule SMS messages for future delivery via the Plivo Messages API. Create an API endpoint that accepts scheduling requests, manages pending messages with node-cron, and sends automated appointment reminders at the specified time.
Project Overview and Goals
What You'll Build:
You'll create a backend API service that enables users to schedule SMS messages. The core functionality includes:
An API endpoint (POST /schedule) that accepts SMS scheduling requests (recipient number, message content, desired send time)
A scheduling mechanism (node-cron) that triggers SMS sending at the correct time
Integration with the Plivo Messages API to send SMS messages
Basic in-memory storage for scheduled jobs (with caveats for production discussed)
Error handling, logging, and security considerations
Problem Solved:
Send automated SMS communications at specific future dates and times – such as appointment reminders, follow-up messages, or time-sensitive alerts – without manual intervention when sending.
Technologies Used:
Node.js: JavaScript runtime for building server-side applications
Express: Minimal and flexible Node.js web application framework for building APIs
Plivo Messages API: Communication API for sending messages across various channels, including SMS (uses plivo SDK)
node-cron: Simple cron-like job scheduler for Node.js that triggers tasks at specific times
dotenv: Manages environment variables securely
uuid: Generates unique identifiers for scheduled jobs
Why Plivo?
Plivo provides reliable and scalable communication APIs with extensive features and global reach. The Messages API offers a unified way to handle multiple channels, though this guide focuses on SMS scheduling and appointment reminders.
Node.js and npm (or yarn) installed on your system (Download Node.js)
Plivo API Account to get API credentials and a virtual number (Sign up for Plivo – free credit available for new accounts)
Plivo CLI (optional but recommended) for managing applications and numbers – install via npm: npm install -g plivo-cli
ngrok (optional) for testing webhooks locally if you extend the app to handle status updates or replies (ngrok Website)
Basic knowledge of JavaScript, Node.js, and REST APIs
Final Outcome:
You'll have a running Node.js Express application with a /schedule endpoint that accepts SMS scheduling requests and sends messages at the specified future time using Plivo.
1. Setting Up the Project
Initialize your Node.js project and install the necessary dependencies.
Step 1: Create Project Directory
Create a new directory for your project and navigate into it:
Bash
mkdir plivo-sms-scheduler
cd plivo-sms-scheduler
Step 2: Initialize Node.js Project
Initialize the project using npm (or yarn) – the -y flag accepts default settings:
Bash
npm init -y
This creates a package.json file.
Step 3: Install Dependencies
Install Express, the Plivo SDK, node-cron, dotenv, and uuid:
Bash
npm install express plivo node-cron dotenv uuid
express: Web framework
plivo: Official Plivo SDK for Node.js
node-cron: Task scheduler
dotenv: Loads environment variables from a .env file
Create app.js inside the src directory with this basic Express setup:
JavaScript
// src/app.jsrequire('dotenv').config(); // Load environment variables earlyconst express = require('express');
const app = express();
constPORT = process.env.PORT || 3000; // Use port from env or default to 3000// Middleware
app.use(express.json()); // Parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies// Simple root route for testing
app.get('/', (req, res) => {
res.status(200).json({ message: 'Plivo SMS Scheduler API is running!' });
});
// --- Routes will be added here later ---// Start the server
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
// Export app for potential testingmodule.exports = app;
Step 6: Create Start Script
Modify the scripts section in your package.json:
JSON
// package.json{// ... other properties"main":"src/app.js",// Point to your entry file"scripts":{"start":"node src/app.js","test":"echo \"Error: no test specified\" && exit 1"},// ... other properties}
Test the basic server by running npm start and navigating to http://localhost:3000 or using curl http://localhost:3000. You should see the JSON message. Stop the server with Ctrl+C.
2. Setting Up Plivo Credentials and SDK
Get credentials and a virtual phone number to interact with the Plivo API. The Plivo SDK uses Auth ID and Auth Token for authentication.
Navigate to your Dashboard – you'll see your Auth ID and Auth Token
Copy these credentials (you'll need them for the .env file)
Step 2: Obtain a Plivo Virtual Number
Get a Plivo phone number to send SMS messages from.
Using the Plivo Console:
Navigate to "Phone Numbers" → "Buy Numbers" in your Plivo Console
Select country, capabilities (SMS), and type
Click "Search" and buy a suitable number – note it down in E.164 format (e.g., +12025551234)
Step 3: Configure Environment Variables
Create .env in your project root (same level as package.json). Never commit this file to Git.
ini
# .env# Plivo credentialsPLIVO_AUTH_ID=YOUR_AUTH_ID
PLIVO_AUTH_TOKEN=YOUR_AUTH_TOKEN
# Plivo number to send SMS fromPLIVO_SMS_FROM_NUMBER=YOUR_PLIVO_NUMBER
# Optional: port for the server# PORT=3000
Replace YOUR_AUTH_ID, YOUR_AUTH_TOKEN, and YOUR_PLIVO_NUMBER with your actual values from the Plivo Console.
Initialize the SDK instance using your Auth ID and Auth Token:
JavaScript
// src/config/plivoClient.jsconst plivo = require('plivo');
// Validate essential environment variablesif (!process.env.PLIVO_AUTH_ID || !process.env.PLIVO_AUTH_TOKEN) {
console.error('Error: PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN must be set in .env');
process.exit(1); // Exit if critical config is missing
}
const client = new plivo.Client(
process.env.PLIVO_AUTH_ID,
process.env.PLIVO_AUTH_TOKEN
);
console.log('Plivo SDK initialized successfully.');
module.exports = client;
This creates a Plivo client instance using your Auth ID and Token. Basic checks ensure critical environment variables are present.
3. Implementing the SMS Scheduling Logic with Node-Cron
Use node-cron to run tasks at specific times and a simple in-memory object to track scheduled jobs.
Important Caveat: This in-memory storage is not suitable for production. If the server restarts, all scheduled jobs are lost. For production, use a persistent store like Redis or a database with a dedicated job queue library (e.g., BullMQ, Agenda).
Step 1: Create Scheduler Service (src/services/schedulerService.js)
Create a scheduler service to manage job scheduling and storage:
JavaScript
// src/services/schedulerService.jsconst cron = require('node-cron');
const { v4: uuidv4 } = require('uuid');
const smsService = require('./smsService'); // We will create this next// In-memory store for scheduled jobs { jobId: { task: cronTask, details: {...} } }const scheduledJobs = {};
/**
* Schedule an SMS message to be sent at a specific time.
* @param {string} recipient - The phone number to send the SMS to.
* @param {string} message - The content of the SMS.
* @param {Date} sendAt - The Date object representing when to send the message.
* @returns {string} - The unique ID of the scheduled job.
* @throws {Error} - If sendAt is in the past or invalid.
*/constscheduleSms = (recipient, message, sendAt) => {
const now = newDate();
if (!(sendAt instanceofDate) || isNaN(sendAt) || sendAt <= now) {
thrownewError('Invalid or past date provided for scheduling.');
}
const jobId = uuidv4();
// Use node-cron's ability to schedule based on a Date objectconst task = cron.schedule(sendAt, async () => {
console.log(`[Scheduler] Executing job ${jobId} at ${newDate()}`);
try {
await smsService.sendSms(recipient, message);
console.log(`[Scheduler] Job ${jobId}: SMS sent successfully to ${recipient}.`);
} catch (error) {
console.error(`[Scheduler] Job ${jobId}: Failed to send SMS to ${recipient}. Error: ${error.message}`);
// Optional: implement retry logic or log for manual intervention
} finally {
// Clean up the job from memory after execution (or attempted execution)delete scheduledJobs[jobId];
console.log(`[Scheduler] Job ${jobId} removed from memory.`);
}
}, {
scheduled: true,
timezone: "Etc/UTC"// Explicitly use UTC or your desired timezone
});
scheduledJobs[jobId] = {
task: task,
details: {
jobId,
recipient,
message, // Storing message in memory; consider security for sensitive datasendAt: sendAt.toISOString(),
createdAt: now.toISOString()
}
};
console.log(`[Scheduler] SMS for ${recipient} scheduled successfully. Job ID: ${jobId}, Send At: ${sendAt.toISOString()}`);
// Optional: start the task explicitly if needed// task.start(); // Usually not needed with `scheduled: true` and a future datereturn jobId;
};
/**
* Cancel a previously scheduled job.
* @param {string} jobId - The ID of the job to cancel.
* @returns {boolean} - True if cancelled, false if job not found.
*/constcancelSms = (jobId) => {
const job = scheduledJobs[jobId];
if (job) {
job.task.stop(); // Stop the cron taskdelete scheduledJobs[jobId];
console.log(`[Scheduler] Job ${jobId} cancelled successfully.`);
returntrue;
}
console.log(`[Scheduler] Job ${jobId} not found for cancellation.`);
returnfalse;
};
/**
* List currently scheduled jobs (for debugging/monitoring).
* @returns {object[]} - An array of job details.
*/constlistScheduledJobs = () => {
returnObject.values(scheduledJobs).map(job => job.details);
};
module.exports = {
scheduleSms,
cancelSms,
listScheduledJobs,
};
Explanation:
scheduledJobs: Simple in-memory database – keys are jobId, values contain the node-cron task object and job details
scheduleSms:
Takes recipient, message, and a Date object (sendAt)
Validates sendAt is a valid future date
Generates a unique jobId using uuid
Uses cron.schedule(sendAt, ...) to schedule the task for the specified date and time – provides a Date object, which node-cron supports for one-off tasks
The function passed to cron.schedule runs at sendAt and calls smsService.sendSms
Includes basic logging and removes the job from scheduledJobs after execution (using finally)
Sets timezone explicitly (UTC recommended for servers)
Stores the task and details in scheduledJobs
Returns the jobId
cancelSms: Finds a job by ID, stops the cron task using task.stop(), and removes it from memory
listScheduledJobs: Returns details of all pending jobs (useful for admin/status endpoints)
4. Implementing the SMS Sending Service with Plivo API
Encapsulate the Plivo SDK interaction to send SMS messages.
Step 1: Create SMS Service (src/services/smsService.js)
JavaScript
// src/services/smsService.jsconst plivoClient = require('../config/plivoClient'); // Import the initialized SDK client// Ensure the sender number is configuredconst fromNumber = process.env.PLIVO_SMS_FROM_NUMBER;
if (!fromNumber) {
console.error('Error: PLIVO_SMS_FROM_NUMBER must be set in .env');
process.exit(1);
}
/**
* Send an SMS message using the Plivo Messages API.
* @param {string} recipient - The phone number to send the SMS to (E.164 format recommended).
* @param {string} message - The content of the SMS.
* @returns {Promise<object>} - The response from the Plivo API.
* @throws {Error} - If sending fails.
*/constsendSms = async (recipient, message) => {
console.log(`[SMS Service] Attempting to send SMS to ${recipient}`);
try {
const response = await plivoClient.messages.create({
src: fromNumber,
dst: recipient,
text: message,
});
console.log(`[SMS Service] Message sent successfully. Message UUID: ${response.messageUuid}`);
return response; // Contains messageUuid
} catch (error) {
console.error(`[SMS Service] Error sending SMS to ${recipient}:`, error.response ? error.response : error.message);
// Rethrow the error so the scheduler knows it failedthrownewError(`Failed to send SMS via Plivo: ${error.message}`);
}
};
module.exports = {
sendSms,
};
Explanation:
Imports the configured plivoClient instance
Retrieves PLIVO_SMS_FROM_NUMBER from environment variables and exits if not set
sendSms function is async – the SDK call is asynchronous (plivoClient.messages.create returns a Promise)
Uses plivoClient.messages.create with required parameters: src, dst, text
Includes try...catch for error handling – logs detailed errors and rethrows a generic error to signal failure to the scheduler
Logs success, including the messageUuid returned by Plivo (useful for tracking)
5. Building the API Layer (Express Routes for SMS Scheduling)
Create the Express route that uses your scheduler service.
// src/routes/scheduleRoutes.jsconst express = require('express');
const schedulerService = require('../services/schedulerService');
const router = express.Router();
// POST /api/schedule – schedule a new SMS
router.post('/', (req, res, next) => {
const { recipient, message, sendAt } = req.body;
// --- Basic input validation ---if (!recipient || !message || !sendAt) {
return res.status(400).json({ error: 'Missing required fields: recipient, message, sendAt (ISO 8601 format string).' });
}
let sendAtDate;
try {
sendAtDate = newDate(sendAt);
if (isNaN(sendAtDate)) {
thrownewError('Invalid date format.');
}
} catch (error) {
return res.status(400).json({ error: 'Invalid date format for sendAt. Please use ISO 8601 format (e.g., YYYY-MM-DDTHH:mm:ssZ).' });
}
const now = newDate();
if (sendAtDate <= now) {
return res.status(400).json({ error: 'Scheduled time must be in the future.' });
}
// --- End basic validation ---try {
const jobId = schedulerService.scheduleSms(recipient, message, sendAtDate);
res.status(202).json({ // 202 Accepted: request taken, processing will happen latermessage: 'SMS scheduled successfully.',
jobId: jobId,
details: {
recipient: recipient,
// message: message, // SECURITY NOTE: avoid returning sensitive message content in production APIssendAt: sendAtDate.toISOString(),
}
});
} catch (error) {
// Catch errors from schedulerService (e.g., invalid date)console.error('[API Route /schedule] Error scheduling SMS:', error);
// Pass to global error handler or send specific response// Send 500 for now, but could be 400 if validation error caught in service
res.status(500).json({ error: 'Failed to schedule SMS.', details: error.message });
// Alternatively: next(error); // if using global error middleware
}
});
// GET /api/schedule – list pending jobs (for admin/debug)
router.get('/', (req, res) => {
const jobs = schedulerService.listScheduledJobs();
res.status(200).json({ scheduledJobs: jobs });
});
// DELETE /api/schedule/:jobId – cancel a scheduled job
router.delete('/:jobId', (req, res) => {
const { jobId } = req.params;
if (!jobId) {
return res.status(400).json({ error: 'Job ID is required.' });
}
const cancelled = schedulerService.cancelSms(jobId);
if (cancelled) {
res.status(200).json({ message: `Job ${jobId} cancelled successfully.` });
} else {
res.status(404).json({ error: `Job ${jobId} not found or already executed/cancelled.` });
}
});
module.exports = router;
Explanation:
Imports express.Router and schedulerService
POST / route:
Extracts recipient, message, and sendAt from req.body
Basic validation: Checks if fields exist and if sendAt is a valid ISO 8601 date string representing a future time (use express-validator or joi for production)
Calls schedulerService.scheduleSms with validated data
Responds with 202 Accepted (appropriate for tasks accepted for later processing) and the jobId
Note: Response omits message field for security – avoid returning sensitive message content in production APIs
Includes basic error handling for validation errors and service errors
GET / route: Calls schedulerService.listScheduledJobs to return pending job details
DELETE /:jobId route: Extracts jobId from req.params, calls schedulerService.cancelSms, returns success or 404 if job not found
Step 2: Mount the Router in app.js
Update src/app.js to use this router.
JavaScript
// src/app.jsrequire('dotenv').config();
const express = require('express');
const scheduleRoutes = require('./routes/scheduleRoutes'); // Import the router// Import other routers if you add more featuresconst app = express();
constPORT = process.env.PORT || 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.get('/', (req, res) => {
res.status(200).json({ message: 'Plivo SMS Scheduler API is running!' });
});
// --- Mount API Routes ---
app.use('/api/schedule', scheduleRoutes); // Use the schedule router for paths starting with /api/schedule// --- Global error handler (example) ---// Add AFTER your routes
app.use((err, req, res, next) => {
console.error('[Global Error Handler]:', err.stack || err);
// Avoid sending stack trace in productionconst statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({ error: message });
});
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
// Initialize Plivo SDK (if not already done elsewhere)require('./config/plivoClient');
// Initialize Scheduler (though node-cron tasks start automatically)console.log('Scheduler service initialized.');
});
module.exports = app;
This adds scheduleRoutes under the /api/schedule path and includes a basic global error handler middleware.
6. Error Handling, Logging, and Retries
Production apps need more robust error handling beyond the basics included here.
Consistent Error Strategy: Use the global error handler middleware (in app.js) to catch unhandled errors. Define custom error classes if needed (e.g., ValidationError, NotFoundError).
Logging: Replace console.log/console.error with a structured logger like pino or winston. This enables different log levels (debug, info, warn, error), JSON formatting, and sending logs to files or external services.
Retries: The current schedulerService logs errors but doesn't retry. For critical reminders, implement a retry mechanism within the catch block of the cron.schedule task function.
Simple retry: Basic for loop with delays
Exponential backoff: Increase delay between retries (e.g., 1 second, 2 seconds, 4 seconds, 8 seconds) – libraries like async-retry simplify this
Consider: How many retries? What maximum delay? What happens after final failure (log to error queue, notify admin)?
7. Adding Security Features
Input Validation: Use a dedicated library (express-validator, joi) for robust validation of recipient (E.164 format), message (length, content), and sendAt. Sanitize inputs to prevent injection attacks.
Rate Limiting: Protect the /api/schedule endpoint from abuse using express-rate-limit:
Bash
npm install express-rate-limit
JavaScript
// src/app.js (add before routes)const rateLimit = require('express-rate-limit');
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 100, // Limit each IP to 100 requests per 15 minutesmessage: 'Too many requests from this IP, try again after 15 minutes'
});
// Apply to all API routes or specific ones
app.use('/api/', apiLimiter);
API Key/Authentication: Currently the API is open. For production, protect it with API keys, JWT tokens, or other authentication mechanisms.
Secure Headers: Use middleware like helmet to set HTTP headers for security (XSS protection, content sniffing prevention, etc.):
8. Database Schema and Persistence (Production Consideration)
The in-memory scheduledJobs store is not persistent. For production:
Choose a Database:
Redis: Excellent for caching and simple key-value storage – good for job queues when combined with libraries like BullMQ
PostgreSQL/MongoDB: More robust for storing job details, status, results, and retry counts
Schema/Data Model: Create a table/collection for ScheduledJobs with fields:
jobId (Primary Key, UUID)
recipient (String)
message (Text)
sendAt (Timestamp/DateTime)
status (Enum: "PENDING", "SENT", "FAILED", "CANCELLED")
createdAt (Timestamp)
updatedAt (Timestamp)
plivoMessageUuid (String, nullable – store after successful send)
failureReason (Text, nullable)
retryCount (Integer, default 0)
Job Queue Library: Libraries like BullMQ (Redis-based) or Agenda (MongoDB-based) handle job persistence, scheduling, retries, concurrency, and worker processes more reliably than node-cron with manual persistence logic.
9. Verification and Testing
Step 1: Manual Verification (curl / Postman)
Start the server: npm start
Schedule an SMS: Send a POST request to http://localhost:3000/api/schedule
Using curl:
Bash
curl -X POST http://localhost:3000/api/schedule \
-H "Content-Type: application/json" \
-d '{
"recipient": "YOUR_PERSONAL_PHONE_NUMBER",
"message": "Hello from the Plivo Scheduler! Testing 123.",
"sendAt": "2025-04-20T18:30:00Z"
}'
Replace YOUR_PERSONAL_PHONE_NUMBER and adjust sendAt to be a minute or two in the future using ISO 8601 format (UTC "Z" or specify offset).
Using Postman: Create a POST request, set the URL, select Body → raw → JSON, and paste the JSON payload.
Check response: You should get a 202 Accepted response with a jobId
Check logs: Monitor the console output where npm start is running – you should see logs for scheduling, then later for execution and sending (or failure)
Check phone: Verify you receive the SMS on YOUR_PERSONAL_PHONE_NUMBER at approximately the scheduled time
Step 2: List Pending Jobs
Send a GET request to http://localhost:3000/api/schedule – you should see your scheduled job (until it executes):
Bash
curl http://localhost:3000/api/schedule
Step 3: Cancel a Job
Schedule another job far in the future – note its jobId from the response
Send a DELETE request to http://localhost:3000/api/schedule/YOUR_JOB_ID:
List jobs again (GET /api/schedule) – the cancelled job should be gone
Step 4: Unit & Integration Tests (Recommended)
For robust applications, write automated tests:
Unit tests (jest, mocha): Test individual functions in isolation – mock dependencies like node-cron and the plivo SDK, test validation logic, date parsing, error handling
Integration tests (supertest): Test API endpoints – start the Express server, send HTTP requests using supertest, assert responses, mock external dependencies to avoid actual API calls
10. Best Practices for SMS Appointment Reminders
Timing Considerations:
Appointment reminders should be sent 24-48 hours before the appointment, with an optional follow-up reminder 2-4 hours before
Time zone handling: Always store and process times in UTC, then convert to recipient's local time zone when scheduling
Compliance: Follow TCPA regulations – send SMS only between 8 AM and 9 PM in the recipient's local time zone
Message Content Best Practices:
Keep messages concise (under 160 characters when possible to fit in a single SMS segment)
Include key details: date, time, location, and opt-out instructions
Provide a callback number or link for rescheduling
Personalize with recipient name when available
Example reminder message:
Text
Hi John, reminder: Your appointment is tomorrow, Apr 20 at 2:30 PM at City Dental (123 Main St). Reply CONFIRM or call 555-0123. Reply STOP to opt out.
Production Enhancements:
Persistent storage: Use PostgreSQL or MongoDB to store scheduled jobs
Job queue: Implement BullMQ or Agenda for reliable job processing
Status webhooks: Set up Plivo delivery receipts to track message status
Retry logic: Implement exponential backoff for failed deliveries
Monitoring: Add health checks and alerting for failed jobs
You've built a complete SMS scheduling application with Plivo, Node.js, and Express. This foundation supports appointment reminders, time-sensitive alerts, and automated notification systems. For production deployment, implement persistent storage, robust error handling, and monitoring to ensure reliable message delivery.
Ready to scale your SMS capabilities? Sign up for Plivo and start building enterprise-grade communication solutions.
Frequently asked questions
Use the /schedule endpoint of the Node.js Express app, sending a POST request with recipient, message, and sendAt (ISO 8601 format) in the body. The app uses node-cron to schedule and the Vonage Messages API to send the SMS at the specified time. A unique job ID is returned for tracking.
The Vonage Messages API is a versatile communication API that allows sending messages across various channels, including SMS. This Node.js app utilizes it to deliver the scheduled SMS messages reliably and efficiently.
Vonage offers reliable, scalable communication APIs with global reach and various features. The Messages API provides a unified approach for different communication channels, though this guide focuses on SMS capabilities.
A persistent store like Redis or a database is essential in production. The in-memory storage used in this guide is only for development; server restarts will clear scheduled jobs. Production apps need persistence and ideally a queue manager (BullMQ, Agenda).
Yes, send a DELETE request to /api/schedule/{jobId}, providing the job ID you received when scheduling. This will stop the scheduled task if it hasn't executed yet and remove it from the system.
Create a Vonage Application, get a virtual number, and link them. Put API keys, Application ID, private key path, and Vonage number in a .env file (never commit to Git). The Node.js app loads these for Vonage API interaction.
Node-cron is a task scheduler for Node.js. It allows you to trigger functions at specific intervals or times. The provided example uses it to execute the SMS sending function at the user-defined 'sendAt' time.
The guide includes basic error handling and logging. For production, enhance this with a structured logger, retry logic within the scheduler, and custom error classes. Exponential backoff is a good practice for retries.
The /schedule endpoint is a POST endpoint that accepts the SMS scheduling requests. It takes the recipient number, the message content, and the desired sending time as input and schedules the SMS message accordingly.
Use tools like curl or Postman to send requests to the local server after starting it with npm start. Verify the responses, check your phone for the SMS, and monitor server logs. Automated unit and integration tests are recommended.
Implement input validation, rate limiting, proper authentication (API keys, JWTs), and secure headers. The provided code includes examples of rate limiting and helmet.js for header security.
The project uses Node.js, Express, the Vonage Messages API and Server SDK, node-cron for scheduling, dotenv for environment variables, and uuid for unique identifiers.
The client interacts with the Node.js/Express API server, which uses scheduling logic (node-cron) and interacts with the Vonage API via the SDK. Messages are then sent from the Vonage cloud to the recipient.
The guide recommends creating directories for src, src/routes, src/services, and src/config for organizing application code, route definitions, service logic, and configuration respectively.
Build a production-ready Node.js application using Express to schedule SMS messages for future delivery via the Plivo Messages API. Create an API endpoint that accepts scheduling requests, manages pending messages with node-cron, and sends automated appointment reminders at the specified time.
Project Overview and Goals
What You'll Build:
You'll create a backend API service that enables users to schedule SMS messages. The core functionality includes:
POST /schedule) that accepts SMS scheduling requests (recipient number, message content, desired send time)node-cron) that triggers SMS sending at the correct timeProblem Solved:
Send automated SMS communications at specific future dates and times – such as appointment reminders, follow-up messages, or time-sensitive alerts – without manual intervention when sending.
Technologies Used:
plivoSDK)node-cron: Simple cron-like job scheduler for Node.js that triggers tasks at specific timesdotenv: Manages environment variables securelyuuid: Generates unique identifiers for scheduled jobsWhy Plivo?
Plivo provides reliable and scalable communication APIs with extensive features and global reach. The Messages API offers a unified way to handle multiple channels, though this guide focuses on SMS scheduling and appointment reminders.
System Architecture:
Prerequisites:
npm install -g plivo-cliFinal Outcome:
You'll have a running Node.js Express application with a
/scheduleendpoint that accepts SMS scheduling requests and sends messages at the specified future time using Plivo.1. Setting Up the Project
Initialize your Node.js project and install the necessary dependencies.
Step 1: Create Project Directory
Create a new directory for your project and navigate into it:
mkdir plivo-sms-scheduler cd plivo-sms-schedulerStep 2: Initialize Node.js Project
Initialize the project using npm (or yarn) – the
-yflag accepts default settings:This creates a
package.jsonfile.Step 3: Install Dependencies
Install Express, the Plivo SDK,
node-cron,dotenv, anduuid:express: Web frameworkplivo: Official Plivo SDK for Node.jsnode-cron: Task schedulerdotenv: Loads environment variables from a.envfileuuid: Generates unique IDs for tracking jobsStep 4: Project Structure (Recommended)
Create a basic structure for better organization:
mkdir src mkdir src/routes mkdir src/services mkdir src/configsrc/: Contains your main application codesrc/routes/: Holds Express route definitionssrc/services/: Contains business logic (interacting with Plivo or the scheduler)src/config/: Configuration files (Plivo SDK initialization)Step 5: Create Basic Express Server (
src/app.js)Create
app.jsinside thesrcdirectory with this basic Express setup:// src/app.js require('dotenv').config(); // Load environment variables early const express = require('express'); const app = express(); const PORT = process.env.PORT || 3000; // Use port from env or default to 3000 // Middleware app.use(express.json()); // Parse JSON request bodies app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies // Simple root route for testing app.get('/', (req, res) => { res.status(200).json({ message: 'Plivo SMS Scheduler API is running!' }); }); // --- Routes will be added here later --- // Start the server app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); }); // Export app for potential testing module.exports = app;Step 6: Create Start Script
Modify the
scriptssection in yourpackage.json:// package.json { // ... other properties "main": "src/app.js", // Point to your entry file "scripts": { "start": "node src/app.js", "test": "echo \"Error: no test specified\" && exit 1" }, // ... other properties }Test the basic server by running
npm startand navigating tohttp://localhost:3000or usingcurl http://localhost:3000. You should see the JSON message. Stop the server withCtrl+C.2. Setting Up Plivo Credentials and SDK
Get credentials and a virtual phone number to interact with the Plivo API. The Plivo SDK uses Auth ID and Auth Token for authentication.
Step 1: Get Your Plivo API Credentials
.envfile)Step 2: Obtain a Plivo Virtual Number
Get a Plivo phone number to send SMS messages from.
Using the Plivo Console:
Step 3: Configure Environment Variables
Create
.envin your project root (same level aspackage.json). Never commit this file to Git.# .env # Plivo credentials PLIVO_AUTH_ID=YOUR_AUTH_ID PLIVO_AUTH_TOKEN=YOUR_AUTH_TOKEN # Plivo number to send SMS from PLIVO_SMS_FROM_NUMBER=YOUR_PLIVO_NUMBER # Optional: port for the server # PORT=3000Replace
YOUR_AUTH_ID,YOUR_AUTH_TOKEN, andYOUR_PLIVO_NUMBERwith your actual values from the Plivo Console.Add
.envto your.gitignore:Step 4: Initialize Plivo SDK (
src/config/plivoClient.js)Initialize the SDK instance using your Auth ID and Auth Token:
// src/config/plivoClient.js const plivo = require('plivo'); // Validate essential environment variables if (!process.env.PLIVO_AUTH_ID || !process.env.PLIVO_AUTH_TOKEN) { console.error('Error: PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN must be set in .env'); process.exit(1); // Exit if critical config is missing } const client = new plivo.Client( process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN ); console.log('Plivo SDK initialized successfully.'); module.exports = client;This creates a Plivo client instance using your Auth ID and Token. Basic checks ensure critical environment variables are present.
3. Implementing the SMS Scheduling Logic with Node-Cron
Use
node-cronto run tasks at specific times and a simple in-memory object to track scheduled jobs.Important Caveat: This in-memory storage is not suitable for production. If the server restarts, all scheduled jobs are lost. For production, use a persistent store like Redis or a database with a dedicated job queue library (e.g., BullMQ, Agenda).
Step 1: Create Scheduler Service (
src/services/schedulerService.js)Create a scheduler service to manage job scheduling and storage:
// src/services/schedulerService.js const cron = require('node-cron'); const { v4: uuidv4 } = require('uuid'); const smsService = require('./smsService'); // We will create this next // In-memory store for scheduled jobs { jobId: { task: cronTask, details: {...} } } const scheduledJobs = {}; /** * Schedule an SMS message to be sent at a specific time. * @param {string} recipient - The phone number to send the SMS to. * @param {string} message - The content of the SMS. * @param {Date} sendAt - The Date object representing when to send the message. * @returns {string} - The unique ID of the scheduled job. * @throws {Error} - If sendAt is in the past or invalid. */ const scheduleSms = (recipient, message, sendAt) => { const now = new Date(); if (!(sendAt instanceof Date) || isNaN(sendAt) || sendAt <= now) { throw new Error('Invalid or past date provided for scheduling.'); } const jobId = uuidv4(); // Use node-cron's ability to schedule based on a Date object const task = cron.schedule(sendAt, async () => { console.log(`[Scheduler] Executing job ${jobId} at ${new Date()}`); try { await smsService.sendSms(recipient, message); console.log(`[Scheduler] Job ${jobId}: SMS sent successfully to ${recipient}.`); } catch (error) { console.error(`[Scheduler] Job ${jobId}: Failed to send SMS to ${recipient}. Error: ${error.message}`); // Optional: implement retry logic or log for manual intervention } finally { // Clean up the job from memory after execution (or attempted execution) delete scheduledJobs[jobId]; console.log(`[Scheduler] Job ${jobId} removed from memory.`); } }, { scheduled: true, timezone: "Etc/UTC" // Explicitly use UTC or your desired timezone }); scheduledJobs[jobId] = { task: task, details: { jobId, recipient, message, // Storing message in memory; consider security for sensitive data sendAt: sendAt.toISOString(), createdAt: now.toISOString() } }; console.log(`[Scheduler] SMS for ${recipient} scheduled successfully. Job ID: ${jobId}, Send At: ${sendAt.toISOString()}`); // Optional: start the task explicitly if needed // task.start(); // Usually not needed with `scheduled: true` and a future date return jobId; }; /** * Cancel a previously scheduled job. * @param {string} jobId - The ID of the job to cancel. * @returns {boolean} - True if cancelled, false if job not found. */ const cancelSms = (jobId) => { const job = scheduledJobs[jobId]; if (job) { job.task.stop(); // Stop the cron task delete scheduledJobs[jobId]; console.log(`[Scheduler] Job ${jobId} cancelled successfully.`); return true; } console.log(`[Scheduler] Job ${jobId} not found for cancellation.`); return false; }; /** * List currently scheduled jobs (for debugging/monitoring). * @returns {object[]} - An array of job details. */ const listScheduledJobs = () => { return Object.values(scheduledJobs).map(job => job.details); }; module.exports = { scheduleSms, cancelSms, listScheduledJobs, };Explanation:
scheduledJobs: Simple in-memory database – keys arejobId, values contain thenode-crontask object and job detailsscheduleSms:Dateobject (sendAt)sendAtis a valid future datejobIdusinguuidcron.schedule(sendAt, ...)to schedule the task for the specified date and time – provides aDateobject, whichnode-cronsupports for one-off taskscron.scheduleruns atsendAtand callssmsService.sendSmsscheduledJobsafter execution (usingfinally)timezoneexplicitly (UTC recommended for servers)scheduledJobsjobIdcancelSms: Finds a job by ID, stops thecrontask usingtask.stop(), and removes it from memorylistScheduledJobs: Returns details of all pending jobs (useful for admin/status endpoints)4. Implementing the SMS Sending Service with Plivo API
Encapsulate the Plivo SDK interaction to send SMS messages.
Step 1: Create SMS Service (
src/services/smsService.js)// src/services/smsService.js const plivoClient = require('../config/plivoClient'); // Import the initialized SDK client // Ensure the sender number is configured const fromNumber = process.env.PLIVO_SMS_FROM_NUMBER; if (!fromNumber) { console.error('Error: PLIVO_SMS_FROM_NUMBER must be set in .env'); process.exit(1); } /** * Send an SMS message using the Plivo Messages API. * @param {string} recipient - The phone number to send the SMS to (E.164 format recommended). * @param {string} message - The content of the SMS. * @returns {Promise<object>} - The response from the Plivo API. * @throws {Error} - If sending fails. */ const sendSms = async (recipient, message) => { console.log(`[SMS Service] Attempting to send SMS to ${recipient}`); try { const response = await plivoClient.messages.create({ src: fromNumber, dst: recipient, text: message, }); console.log(`[SMS Service] Message sent successfully. Message UUID: ${response.messageUuid}`); return response; // Contains messageUuid } catch (error) { console.error(`[SMS Service] Error sending SMS to ${recipient}:`, error.response ? error.response : error.message); // Rethrow the error so the scheduler knows it failed throw new Error(`Failed to send SMS via Plivo: ${error.message}`); } }; module.exports = { sendSms, };Explanation:
plivoClientinstancePLIVO_SMS_FROM_NUMBERfrom environment variables and exits if not setsendSmsfunction isasync– the SDK call is asynchronous (plivoClient.messages.createreturns a Promise)plivoClient.messages.createwith required parameters:src,dst,texttry...catchfor error handling – logs detailed errors and rethrows a generic error to signal failure to the schedulermessageUuidreturned by Plivo (useful for tracking)5. Building the API Layer (Express Routes for SMS Scheduling)
Create the Express route that uses your scheduler service.
Step 1: Create Schedule Route (
src/routes/scheduleRoutes.js)// src/routes/scheduleRoutes.js const express = require('express'); const schedulerService = require('../services/schedulerService'); const router = express.Router(); // POST /api/schedule – schedule a new SMS router.post('/', (req, res, next) => { const { recipient, message, sendAt } = req.body; // --- Basic input validation --- if (!recipient || !message || !sendAt) { return res.status(400).json({ error: 'Missing required fields: recipient, message, sendAt (ISO 8601 format string).' }); } let sendAtDate; try { sendAtDate = new Date(sendAt); if (isNaN(sendAtDate)) { throw new Error('Invalid date format.'); } } catch (error) { return res.status(400).json({ error: 'Invalid date format for sendAt. Please use ISO 8601 format (e.g., YYYY-MM-DDTHH:mm:ssZ).' }); } const now = new Date(); if (sendAtDate <= now) { return res.status(400).json({ error: 'Scheduled time must be in the future.' }); } // --- End basic validation --- try { const jobId = schedulerService.scheduleSms(recipient, message, sendAtDate); res.status(202).json({ // 202 Accepted: request taken, processing will happen later message: 'SMS scheduled successfully.', jobId: jobId, details: { recipient: recipient, // message: message, // SECURITY NOTE: avoid returning sensitive message content in production APIs sendAt: sendAtDate.toISOString(), } }); } catch (error) { // Catch errors from schedulerService (e.g., invalid date) console.error('[API Route /schedule] Error scheduling SMS:', error); // Pass to global error handler or send specific response // Send 500 for now, but could be 400 if validation error caught in service res.status(500).json({ error: 'Failed to schedule SMS.', details: error.message }); // Alternatively: next(error); // if using global error middleware } }); // GET /api/schedule – list pending jobs (for admin/debug) router.get('/', (req, res) => { const jobs = schedulerService.listScheduledJobs(); res.status(200).json({ scheduledJobs: jobs }); }); // DELETE /api/schedule/:jobId – cancel a scheduled job router.delete('/:jobId', (req, res) => { const { jobId } = req.params; if (!jobId) { return res.status(400).json({ error: 'Job ID is required.' }); } const cancelled = schedulerService.cancelSms(jobId); if (cancelled) { res.status(200).json({ message: `Job ${jobId} cancelled successfully.` }); } else { res.status(404).json({ error: `Job ${jobId} not found or already executed/cancelled.` }); } }); module.exports = router;Explanation:
express.RouterandschedulerServicePOST /route:recipient,message, andsendAtfromreq.bodysendAtis a valid ISO 8601 date string representing a future time (useexpress-validatororjoifor production)schedulerService.scheduleSmswith validated data202 Accepted(appropriate for tasks accepted for later processing) and thejobIdmessagefield for security – avoid returning sensitive message content in production APIsGET /route: CallsschedulerService.listScheduledJobsto return pending job detailsDELETE /:jobIdroute: ExtractsjobIdfromreq.params, callsschedulerService.cancelSms, returns success or 404 if job not foundStep 2: Mount the Router in
app.jsUpdate
src/app.jsto use this router.// src/app.js require('dotenv').config(); const express = require('express'); const scheduleRoutes = require('./routes/scheduleRoutes'); // Import the router // Import other routers if you add more features const app = express(); const PORT = process.env.PORT || 3000; app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.get('/', (req, res) => { res.status(200).json({ message: 'Plivo SMS Scheduler API is running!' }); }); // --- Mount API Routes --- app.use('/api/schedule', scheduleRoutes); // Use the schedule router for paths starting with /api/schedule // --- Global error handler (example) --- // Add AFTER your routes app.use((err, req, res, next) => { console.error('[Global Error Handler]:', err.stack || err); // Avoid sending stack trace in production const statusCode = err.statusCode || 500; const message = err.message || 'Internal Server Error'; res.status(statusCode).json({ error: message }); }); app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); // Initialize Plivo SDK (if not already done elsewhere) require('./config/plivoClient'); // Initialize Scheduler (though node-cron tasks start automatically) console.log('Scheduler service initialized.'); }); module.exports = app;This adds
scheduleRoutesunder the/api/schedulepath and includes a basic global error handler middleware.6. Error Handling, Logging, and Retries
Production apps need more robust error handling beyond the basics included here.
Consistent Error Strategy: Use the global error handler middleware (in
app.js) to catch unhandled errors. Define custom error classes if needed (e.g.,ValidationError,NotFoundError).Logging: Replace
console.log/console.errorwith a structured logger likepinoorwinston. This enables different log levels (debug, info, warn, error), JSON formatting, and sending logs to files or external services.Example with
pino:// src/config/logger.js (Example) const pino = require('pino'); const logger = pino({ level: process.env.LOG_LEVEL || 'info', transport: { target: 'pino-pretty', // Makes logs readable in development options: { colorize: true } } }); module.exports = logger; // Use it like: // const logger = require('./config/logger'); // logger.info('Server started'); // logger.error({ err: error }, 'Failed to send SMS');Retries: The current
schedulerServicelogs errors but doesn't retry. For critical reminders, implement a retry mechanism within thecatchblock of thecron.scheduletask function.forloop with delaysasync-retrysimplify this7. Adding Security Features
Input Validation: Use a dedicated library (
express-validator,joi) for robust validation ofrecipient(E.164 format),message(length, content), andsendAt. Sanitize inputs to prevent injection attacks.Rate Limiting: Protect the
/api/scheduleendpoint from abuse usingexpress-rate-limit:// src/app.js (add before routes) const rateLimit = require('express-rate-limit'); const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per 15 minutes message: 'Too many requests from this IP, try again after 15 minutes' }); // Apply to all API routes or specific ones app.use('/api/', apiLimiter);API Key/Authentication: Currently the API is open. For production, protect it with API keys, JWT tokens, or other authentication mechanisms.
Secure Headers: Use middleware like
helmetto set HTTP headers for security (XSS protection, content sniffing prevention, etc.):// src/app.js const helmet = require('helmet'); app.use(helmet());8. Database Schema and Persistence (Production Consideration)
The in-memory
scheduledJobsstore is not persistent. For production:Choose a Database:
Schema/Data Model: Create a table/collection for
ScheduledJobswith fields:jobId(Primary Key, UUID)recipient(String)message(Text)sendAt(Timestamp/DateTime)status(Enum: "PENDING", "SENT", "FAILED", "CANCELLED")createdAt(Timestamp)updatedAt(Timestamp)plivoMessageUuid(String, nullable – store after successful send)failureReason(Text, nullable)retryCount(Integer, default 0)Job Queue Library: Libraries like
BullMQ(Redis-based) orAgenda(MongoDB-based) handle job persistence, scheduling, retries, concurrency, and worker processes more reliably thannode-cronwith manual persistence logic.9. Verification and Testing
Step 1: Manual Verification (
curl/ Postman)npm starthttp://localhost:3000/api/scheduleUsing
curl:curl -X POST http://localhost:3000/api/schedule \ -H "Content-Type: application/json" \ -d '{ "recipient": "YOUR_PERSONAL_PHONE_NUMBER", "message": "Hello from the Plivo Scheduler! Testing 123.", "sendAt": "2025-04-20T18:30:00Z" }'Replace
YOUR_PERSONAL_PHONE_NUMBERand adjustsendAtto be a minute or two in the future using ISO 8601 format (UTC "Z" or specify offset).Using Postman: Create a POST request, set the URL, select Body → raw → JSON, and paste the JSON payload.
202 Acceptedresponse with ajobIdnpm startis running – you should see logs for scheduling, then later for execution and sending (or failure)YOUR_PERSONAL_PHONE_NUMBERat approximately the scheduled timeStep 2: List Pending Jobs
Send a GET request to
http://localhost:3000/api/schedule– you should see your scheduled job (until it executes):Step 3: Cancel a Job
jobIdfrom the responsehttp://localhost:3000/api/schedule/YOUR_JOB_ID:/api/schedule) – the cancelled job should be goneStep 4: Unit & Integration Tests (Recommended)
For robust applications, write automated tests:
jest,mocha): Test individual functions in isolation – mock dependencies likenode-cronand theplivoSDK, test validation logic, date parsing, error handlingsupertest): Test API endpoints – start the Express server, send HTTP requests usingsupertest, assert responses, mock external dependencies to avoid actual API calls10. Best Practices for SMS Appointment Reminders
Timing Considerations:
Message Content Best Practices:
Example reminder message:
Production Enhancements:
Related Resources
Conclusion
You've built a complete SMS scheduling application with Plivo, Node.js, and Express. This foundation supports appointment reminders, time-sensitive alerts, and automated notification systems. For production deployment, implement persistent storage, robust error handling, and monitoring to ensure reliable message delivery.
Ready to scale your SMS capabilities? Sign up for Plivo and start building enterprise-grade communication solutions.