This guide provides a comprehensive, step-by-step walkthrough for building a robust SMS scheduling and reminder application using Node.js, Express, and the Vonage Messages API. You'll learn how to schedule SMS messages to be sent at specific future times, manage scheduled messages via an API, persist schedules in a database, and handle potential issues like errors and time zones.
By the end of this tutorial, you will have a functional backend system capable of:
- Accepting API requests to schedule SMS messages.
- Storing scheduled messages persistently.
- Reliably sending messages at their scheduled times using Vonage.
- Providing API endpoints to manage (view, cancel) scheduled messages.
- Implementing basic error handling and logging.
Target Audience: Developers familiar with Node.js and basic web concepts looking to implement scheduled tasks, specifically SMS notifications.
Technologies Used:
- Node.js: JavaScript runtime environment.
- Express: Minimalist web framework for Node.js, used for building the API.
- Vonage Messages API: Used to send SMS messages.
node-cron
: Task scheduler library for Node.js based on cron syntax.dotenv
: Module to load environment variables from a.env
file.- Prisma: Next-generation ORM for Node.js and TypeScript (used for database interaction).
- SQLite: Simple file-based database (for easy setup in this guide, easily swappable for PostgreSQL, MySQL, etc.).
uuid
: To generate unique identifiers.
System Architecture:
graph LR
A[User / Client<br/>(e.g., curl, UI)] --> B(Express API<br/>(Node.js));
B --> C{Schedule Service<br/>(node-cron)};
C --> D[Vonage<br/>Messages API];
D --> E[User Phone<br/>(SMS Delivery)];
subgraph Express API Components
B --> B1(Input Validation);
B --> B2(Error Handling);
B --> B3(Logging);
end
subgraph Schedule Service Components
C --> C1[(Database<br/>(Prisma))];
end
B --> C1; // Express interacts with DB via Service/Prisma
C --> C1; // Cron job interacts with DB
Prerequisites:
- Node.js and npm (or yarn): Installed on your system. Download from nodejs.org.
- Vonage API Account: Sign up for free at Vonage API Dashboard. You'll need your API Key, API Secret, and a Vonage virtual phone number.
- Basic Terminal/Command Line Knowledge: Familiarity with navigating directories and running commands.
- (Optional)
curl
or Postman: For testing the API endpoints.
1. Project Setup
Let's initialize the Node.js project and install necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for your project, then navigate into it.
mkdir sms-scheduler cd sms-scheduler
-
Initialize Node.js Project: This creates a
package.json
file to manage project dependencies and scripts.npm init -y
-
Install Dependencies: We need Express for the server, the Vonage SDK,
node-cron
for scheduling,dotenv
for environment variables, Prisma for database interactions, anduuid
.npm install express @vonage/server-sdk node-cron dotenv @prisma/client uuid npm install --save-dev prisma # Install Prisma CLI as a dev dependency
express
: Web framework.@vonage/server-sdk
: Official Vonage Node.js SDK.node-cron
: Task scheduler.dotenv
: Loads environment variables from.env
.@prisma/client
: Prisma client library.uuid
: To generate unique IDs for scheduled messages.prisma
: Prisma CLI (dev dependency).
-
Initialize Prisma: Set up Prisma with SQLite for simplicity.
npx prisma init --datasource-provider sqlite
This creates:
- A
prisma
directory with aschema.prisma
file (defines database models). - A
.env
file (pre-filled withDATABASE_URL
). We'll add more variables here.
- A
-
Create
.gitignore
: Prevent sensitive files and generated files from being committed to version control. Create a file named.gitignore
in the project root:# Dependencies node_modules/ # Prisma prisma/dev.db prisma/dev.db-journal # Environment variables .env # Private Key private.key # Logs *.log # OS generated files .DS_Store Thumbs.db
-
Project Structure: Create a basic source directory structure.
mkdir src mkdir src/services mkdir src/routes mkdir src/controllers
Your structure should look like this:
sms-scheduler/ ├── node_modules/ ├── prisma/ │ ├── migrations/ # Created later if using prisma migrate │ └── schema.prisma ├── src/ │ ├── controllers/ │ ├── routes/ │ └── services/ ├── .env ├── .gitignore ├── package.json ├── package-lock.json └── private.key # Will be added in the next step
2. Vonage Configuration
Configure your Vonage account and store credentials securely.
-
Get Vonage Credentials:
- Log in to your Vonage API Dashboard.
- Find your API Key and API Secret on the main dashboard page.
- Buy a Vonage Number: Navigate to
Numbers
->Buy numbers
. Search for and purchase a number with SMS capabilities in your desired country. Note this number down.
-
Create a Vonage Application: While this guide focuses on sending scheduled SMS (which primarily requires API Key/Secret or Application ID/Private Key), setting up an application is good practice and necessary if you later want to handle delivery receipts or incoming messages. We'll use Application ID/Private Key authentication as recommended by Vonage.
- In the Dashboard, go to
Applications
->Create a new application
. - Give it a name (e.g.,
SMS Scheduler App
). - Click
Generate public and private key
. Save the downloadedprivate.key
file immediately. Place this file in the root of your project directory (e.g., alongsidepackage.json
). - (Optional but recommended) Enable the
Messages
capability. You can leave the webhook URLs blank for now, or if you havengrok
set up, point them to temporary URLs likeYOUR_NGROK_URL/webhooks/status
andYOUR_NGROK_URL/webhooks/inbound
. - Click
Generate new application
. - Copy the generated Application ID.
- Link Your Number: Go back to the Applications list, find your new application, click
Edit
. Scroll down toLink virtual numbers
and link the Vonage number you purchased earlier. ClickSave changes
.
- In the Dashboard, go to
-
Configure Environment Variables: Open the
.env
file (created byprisma init
) and add your Vonage credentials and configuration. Ensure theDATABASE_URL
points to the SQLite file and use standard.env
quoting.# .env # Database (Prisma) # Points to the SQLite file in the prisma directory DATABASE_URL=""file:./prisma/dev.db"" # Vonage Credentials VONAGE_API_KEY=""YOUR_API_KEY"" # Replace with your actual API Key VONAGE_API_SECRET=""YOUR_API_SECRET"" # Replace with your actual API Secret VONAGE_APPLICATION_ID=""YOUR_APPLICATION_ID"" # Replace with your generated Application ID VONAGE_PRIVATE_KEY_PATH=""./private.key"" # Path to your downloaded private key file in project root VONAGE_NUMBER=""YOUR_VONAGE_NUMBER"" # Replace with your purchased Vonage number (e.g., 14155550100) # Application Configuration PORT=3000 # Run check every 10 seconds (adjust for production, e.g., ""* * * * *"") CRON_SCHEDULE=""*/10 * * * * *"" # Explicitly set timezone, UTC is recommended for scheduling logic APP_TIMEZONE=""UTC""
VONAGE_API_KEY
,VONAGE_API_SECRET
: Found on dashboard. Used for some SDK initializations or fallback.VONAGE_APPLICATION_ID
,VONAGE_PRIVATE_KEY_PATH
: Generated when creating the application. Preferred authentication method for Messages API. Make sureprivate.key
is in your project root and the path is correct.VONAGE_NUMBER
: The Vonage number messages will be sent from. Use digits only or E.164 format.PORT
: Port the Express server will listen on.CRON_SCHEDULE
: How often the scheduler checks for due messages.*/10 * * * * *
means every 10 seconds (good for testing, use something like* * * * *
- every minute - for production). See crontab.guru for syntax.APP_TIMEZONE
: Set explicitly. UsingUTC
is highly recommended for storing and scheduling times to avoid ambiguity.
Security Note: Never commit your
.env
file orprivate.key
file to version control. Ensure they are listed in your.gitignore
.
3. Database Schema and Data Layer
Define the database model using Prisma to store scheduled messages.
-
Define Schema: Open
prisma/schema.prisma
and define the model for storing scheduled SMS jobs.// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" // Or "postgresql", "mysql", etc. url = env("DATABASE_URL") } model ScheduledMessage { id String @id @default(uuid()) // Unique identifier (DB default) recipient String // Phone number to send to message String // Message content sendAt DateTime // Scheduled time (stored in UTC) status String @default("PENDING") // PENDING, SENT, FAILED, CANCELED vonageMsgId String? // Optional: Store Vonage message ID after sending createdAt DateTime @default(now()) updatedAt DateTime @updatedAt errorMessage String? // Store error message on failure // retryCount Int? @default(0) // Optional: Add for retry logic (see Section 7) }
id
: Unique ID generated using UUID (database default).recipient
: Target phone number.message
: SMS content.sendAt
: The crucial field – when the message should be sent (stored as UTC).status
: Tracks the job's state.vonageMsgId
: To correlate with Vonage's system if needed.errorMessage
: For debugging failed sends.
-
Apply Schema to Database: Run the Prisma command to create the SQLite database file (
dev.db
) and theScheduledMessage
table based on your schema.npx prisma db push
This command synchronizes your database schema with the Prisma schema definition. Important:
db push
is primarily for prototyping and development. For production environments or collaborative projects with existing data, you must useprisma migrate dev
(during development) andprisma migrate deploy
(in deployment pipelines) to generate and apply SQL migration files safely.
4. Core Scheduling Logic
Implement the service responsible for checking the database and triggering message sends.
-
Create Schedule Service File: Create
src/services/scheduleService.js
. -
Implement the Service: This service will:
- Initialize Prisma Client.
- Initialize Vonage SDK using Application ID and Private Key.
- Define a function (
checkAndSendDueMessages
) to find pending messages scheduled for now or earlier. - Attempt to send each due message via Vonage.
- Update the message status (SENT or FAILED) in the database.
- Use
node-cron
to run this check function periodically based on theCRON_SCHEDULE
from.env
.
// src/services/scheduleService.js const { PrismaClient } = require('@prisma/client'); const { Vonage } = require('@vonage/server-sdk'); const { Auth } = require('@vonage/auth'); const { readFileSync } = require('fs'); const cron = require('node-cron'); const { v4: uuidv4 } = require('uuid'); // Used to generate IDs in the service require('dotenv').config(); // Ensure environment variables are loaded const prisma = new PrismaClient(); // --- Initialize Vonage SDK --- // Preferred method: Application ID and Private Key let vonage; try { const privateKey = readFileSync(process.env.VONAGE_PRIVATE_KEY_PATH); const credentials = new Auth({ applicationId: process.env.VONAGE_APPLICATION_ID, privateKey: privateKey, }); vonage = new Vonage(credentials); console.log('Vonage SDK initialized with Application ID and Private Key.'); } catch (error) { console.error('CRITICAL: Error initializing Vonage SDK:', error.message); console.error('Ensure VONAGE_APPLICATION_ID and VONAGE_PRIVATE_KEY_PATH are set correctly in .env and the key file exists.'); // Option: Fall back to API Key/Secret if App ID/Key fails // console.log('Attempting fallback to API Key/Secret...'); // vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET }); // if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) { // console.error('CRITICAL: Vonage API Key/Secret also missing or invalid. Cannot send messages.'); process.exit(1); // Exit if critical setup fails - cannot function without Vonage auth. // } else { // console.log('Vonage SDK initialized with API Key/Secret (fallback).'); // } } const FROM_NUMBER = process.env.VONAGE_NUMBER; // --- Core Scheduling Function --- async function checkAndSendDueMessages() { console.log(`[${new Date().toISOString()}] Checking for due messages...`); const now = new Date(); // Current time in UTC (default for new Date()) try { const dueMessages = await prisma.scheduledMessage.findMany({ where: { status: 'PENDING', sendAt: { lte: now, // Less than or equal to current time }, }, }); if (dueMessages.length === 0) { // console.log('No due messages found.'); // Reduce noise in logs return; } console.log(`Found ${dueMessages.length} due messages. Processing...`); for (const msg of dueMessages) { console.log(`Attempting to send message ID: ${msg.id} to ${msg.recipient}`); try { // Ensure recipient is digits only if required by API/region, // although Vonage Messages API often handles formats like E.164 well. const cleanRecipient = msg.recipient.replace(/\D/g, ''); const resp = await vonage.messages.send({ message_type: ""text"", to: cleanRecipient, // Use cleaned number or original msg.recipient from: FROM_NUMBER, channel: ""sms"", text: msg.message, // Optional: client_ref for tracking (must be unique per message) // client_ref: `schedule_${msg.id}_${Date.now()}` }); console.log(`Message ${msg.id} sent successfully. Vonage ID: ${resp.message_uuid}`); await prisma.scheduledMessage.update({ where: { id: msg.id }, data: { status: 'SENT', vonageMsgId: resp.message_uuid, updatedAt: new Date(), }, }); } catch (err) { // Log detailed Vonage error let errorMessage = `Failed to send message ID: ${msg.id}. Error: ${err.message}`; if (err.response && err.response.data) { errorMessage += ` | Vonage Response: ${JSON.stringify(err.response.data)}`; console.error(`Vonage Error Details for ${msg.id}:`, err.response.data); } else { console.error(`Error sending message ID ${msg.id}:`, err); } await prisma.scheduledMessage.update({ where: { id: msg.id }, data: { status: 'FAILED', errorMessage: errorMessage.substring(0, 1000), // Limit error message length for DB updatedAt: new Date(), }, }); } } } catch (error) { console.error('Error during message check/send process:', error); } } // --- Schedule the Task --- // Use 'let' to allow reassignment if validation fails let cronSchedule = process.env.CRON_SCHEDULE || '*/30 * * * * *'; // Default every 30s if not set const appTimezone = process.env.APP_TIMEZONE || ""UTC""; if (!cron.validate(cronSchedule)) { console.error(`Invalid CRON_SCHEDULE: ""${cronSchedule}"". Defaulting to every minute ('* * * * *').`); cronSchedule = '* * * * *'; // Reassign the variable used below } cron.schedule(cronSchedule, checkAndSendDueMessages, { scheduled: true, timezone: appTimezone }); console.log(`SMS Scheduler service started. Cron job scheduled with pattern: ""${cronSchedule}"" in timezone ""${appTimezone}""`); // --- Service Functions (called by API controllers) --- async function scheduleNewMessage(recipient, message, sendAt) { if (!recipient || !message || !sendAt) { throw new Error('Recipient, message, and sendAt time are required.'); } if (!(sendAt instanceof Date) || isNaN(sendAt.getTime())) { throw new Error('Invalid sendAt date object.'); } if (sendAt <= new Date()) { throw new Error('Scheduled time must be in the future.'); } try { // Generate UUID here to have it available immediately for the return value_ // even though the DB has a default. This avoids needing a separate query // or relying on Prisma returning the full created object (which it does by default). const messageId = uuidv4(); const newScheduledMessage = await prisma.scheduledMessage.create({ data: { id: messageId_ recipient_ message_ sendAt: sendAt_ // sendAt is already a Date object here status: 'PENDING'_ }_ }); console.log(`Scheduled new message ID: ${newScheduledMessage.id} for ${sendAt.toISOString()}`); return newScheduledMessage; // Prisma returns the created record by default } catch (error) { console.error('Error saving scheduled message to DB:'_ error); throw new Error('Failed to schedule message due to database error.'); } } async function getScheduledMessage(id) { try { const message = await prisma.scheduledMessage.findUnique({ where: { id }_ }); if (!message) { // Throw a specific error or return null based on desired controller handling throw new Error('Message not found.'); } return message; } catch (error) { console.error(`Error retrieving message ${id}:`_ error); // Rethrow specific errors or a generic one if (error.message === 'Message not found.') throw error; throw new Error('Failed to retrieve message due to database error.'); } } async function cancelScheduledMessage(id) { try { // Use transaction to ensure atomicity: find and update const updatedMessage = await prisma.$transaction(async (tx) => { const message = await tx.scheduledMessage.findUnique({ where: { id }, }); if (!message) { throw new Error('Message not found.'); } if (message.status !== 'PENDING') { // Throw specific error for non-pending state throw new Error(`Cannot cancel message with status: ${message.status}`); } return tx.scheduledMessage.update({ where: { id }, data: { status: 'CANCELED', updatedAt: new Date(), }, }); }); console.log(`Canceled message ID: ${id}`); return updatedMessage; } catch (error) { console.error(`Error canceling message ${id}:`, error); // Rethrow specific known errors for controller handling if (error.message === 'Message not found.' || error.message.startsWith('Cannot cancel message')) { throw error; } throw new Error('Failed to cancel message due to database error.'); } } module.exports = { checkAndSendDueMessages, // Exported mainly for potential manual trigger/testing scheduleNewMessage, getScheduledMessage, cancelScheduledMessage, };
- Vonage Initialization: Uses Application ID/Private Key first. Includes error checking and
process.exit(1)
if critical config fails, with explanatory comments. checkAndSendDueMessages
: Queries the DB forPENDING
messages wheresendAt
is past. Iterates, callsvonage.messages.send
, and updates the status (SENT
orFAILED
) with details. Includes more detailed error logging.- Cron Job:
cron.schedule
runs the check function based on.env
configuration. Correctly handles invalidCRON_SCHEDULE
by reassigning thecronSchedule
variable.timezone
is explicitly set. - Service Functions:
scheduleNewMessage
,getScheduledMessage
,cancelScheduledMessage
handle database interactions and basic validation.scheduleNewMessage
now explicitly generates the UUID in the service with a comment explaining why. Error handling inget
andcancel
throws specific errors for known conditions like 'Not Found' or 'Cannot Cancel'.
5. Building the Express API Layer
Create API endpoints to schedule, view, and cancel messages.
-
Create Controller File:
src/controllers/scheduleController.js
// src/controllers/scheduleController.js const scheduleService = require('../services/scheduleService'); exports.createSchedule = async (req, res, next) => { const { recipient, message, sendAt } = req.body; // e.g., sendAt = "2025-04-20T18:30:00Z" (ISO 8601 format) // Basic Input Validation (More robust validation recommended for production) if (!recipient || !message || !sendAt) { return res.status(400).json({ error: 'Missing required fields: recipient, message, sendAt (ISO 8601 format)' }); } // Validate phone number format (simple example: check for 10-15 digits after removing non-digits) // For production, use a library like libphonenumber-js for stricter E.164 validation. const digitsOnlyRecipient = recipient.replace(/\D/g, ''); if (!/^\d{10,15}$/.test(digitsOnlyRecipient)) { return res.status(400).json({ error: 'Invalid recipient phone number format (must contain 10-15 digits).' }); } let sendAtDate; try { // Ensure sendAt is parsed correctly (assuming ISO 8601 string input) sendAtDate = new Date(sendAt); if (isNaN(sendAtDate.getTime())) { throw new Error('Invalid date format'); // Caught below } } catch (e) { return res.status(400).json({ error: 'Invalid date format for sendAt. Use ISO 8601 format (e.g., YYYY-MM-DDTHH:mm:ssZ).' }); } if (sendAtDate <= new Date()) { return res.status(400).json({ error: 'Scheduled time must be in the future.' }); } try { // Pass the validated Date object to the service const scheduledMessage = await scheduleService.scheduleNewMessage(recipient_ message_ sendAtDate); res.status(201).json({ message: 'SMS scheduled successfully.'_ scheduleId: scheduledMessage.id_ details: scheduledMessage_ // Return the full object }); } catch (error) { console.error('Error in createSchedule controller:'_ error); // Pass error to the centralized error handler // Consider more specific status codes based on service errors if needed next(error); } }; exports.getScheduleStatus = async (req_ res_ next) => { const { id } = req.params; if (!id) { return res.status(400).json({ error: 'Schedule ID is required.' }); } try { const message = await scheduleService.getScheduledMessage(id); // Service now throws 'Message not found.', so no need to check for null here. res.status(200).json(message); } catch (error) { // Note: Checking error.message is brittle. In production, prefer custom error types // thrown by the service layer and check using `instanceof`. if (error.message === 'Message not found.') { return res.status(404).json({ error: 'Scheduled message not found.' }); } console.error(`Error in getScheduleStatus controller for ID ${id}:`, error); next(error); // Pass other errors to the centralized handler } }; exports.cancelSchedule = async (req, res, next) => { const { id } = req.params; if (!id) { return res.status(400).json({ error: 'Schedule ID is required.' }); } try { const updatedMessage = await scheduleService.cancelScheduledMessage(id); res.status(200).json({ message: 'Scheduled message canceled successfully.', details: updatedMessage }); } catch (error) { // Note: Checking error.message is brittle. Prefer custom error types and `instanceof`. if (error.message === 'Message not found.') { return res.status(404).json({ error: 'Scheduled message not found.' }); } if (error.message.startsWith('Cannot cancel message')) { // Bad request because the message is in a state that cannot be canceled return res.status(400).json({ error: error.message }); } console.error(`Error in cancelSchedule controller for ID ${id}:`, error); next(error); // Pass other errors to the centralized handler } };
- Imports
scheduleService
. - Defines async functions for routes.
- Performs input validation, including checking phone number digit count after stripping non-digits, and validating the
sendAt
date format and ensuring it's in the future. - Calls service functions.
- Sends JSON responses.
- Uses
next(error)
for centralized error handling. - Includes comments in
catch
blocks regarding the brittleness of checkingerror.message
.
- Imports
-
Create Router File:
src/routes/scheduleRoutes.js
// src/routes/scheduleRoutes.js const express = require('express'); const scheduleController = require('../controllers/scheduleController'); const router = express.Router(); // POST /api/schedule - Schedule a new SMS router.post('/', scheduleController.createSchedule); // GET /api/schedule/:id - Get status of a scheduled SMS router.get('/:id', scheduleController.getScheduleStatus); // DELETE /api/schedule/:id - Cancel a pending scheduled SMS router.delete('/:id', scheduleController.cancelSchedule); module.exports = router;
- Defines API routes and maps them to controller functions.
-
Create Main Server File:
src/server.js
// src/server.js require('dotenv').config(); // Load .env variables first const express = require('express'); const scheduleRoutes = require('./routes/scheduleRoutes'); // Import scheduleService here to ensure the cron job starts when the server starts. require('./services/scheduleService'); const app = express(); const PORT = process.env.PORT || 3000; // --- Middleware --- app.use(express.json()); // Parse JSON request bodies app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies // --- Basic Logging Middleware --- app.use((req, res, next) => { console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); // In production, consider more structured logging (e.g., using Winston or Pino) next(); }); // --- Routes --- app.get('/', (req, res) => { res.status(200).json({ message: 'SMS Scheduler API is running!' }); }); app.use('/api/schedule', scheduleRoutes); // Mount schedule routes // --- Centralized Error Handler --- // This should be defined *after* all other app.use() and routes // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { console.error('Unhandled Error:', err.stack || err); // Log stack trace // Avoid sending detailed internal errors in production responses const statusCode = err.statusCode || 500; // Use custom status code if available const message = (process.env.NODE_ENV === 'production' && statusCode === 500) ? 'An internal server error occurred.' : err.message || 'An unexpected error occurred.'; res.status(statusCode).json({ error: message }); }); // --- 404 Handler for unmatched routes --- app.use((req, res) => { res.status(404).json({ error: 'Not Found' }); }); // --- Start Server --- app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); // The scheduleService automatically starts its cron job upon being required above. console.log(`SMS scheduling service is active.`); });
- Loads
dotenv
. - Initializes Express.
- Uses middleware.
- Mounts routes.
- Includes a centralized error handler and a 404 handler.
- Starts the server.
- Crucially:
require('./services/scheduleService');
ensures the cron job starts.
- Loads
-
Update
package.json
Start Script:// package.json (scripts section) "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", // Optional: using nodemon for development "test": "echo \"Error: no test specified\" && exit 1" }