MessageBird SMS Scheduling with Node.js: Complete Guide to Appointment Reminders
Build an SMS appointment reminder system using MessageBird API, Node.js, Express, and PostgreSQL. Includes phone validation, scheduling, and production-ready code.
Build an SMS appointment reminder system using Node.js, Express, and the MessageBird API. This guide covers database integration, error handling, security, and deployment considerations for production environments.
The application exposes an API endpoint to accept appointment details, validate them, store them in a database, and schedule an SMS reminder via MessageBird at a specified time before the appointment.
Project Overview and Goals
What You'll Build: A Node.js backend service using Express that:
Provides an API endpoint to create appointments.
Validates appointment data, including phone numbers (using MessageBird Lookup).
Persists appointment details in PostgreSQL with Prisma.
Schedules an SMS reminder using MessageBird to send a configurable duration (e.g., 3 hours) before the appointment.
Includes error handling, logging, and security measures.
Problem Solved: Customer no-shows cost businesses time and revenue. Timely SMS reminders significantly reduce missed appointments.
Technologies:
Node.js: JavaScript runtime for the backend server.
Express: Web framework for Node.js API.
MessageBird: Communications platform for:
SMS API: Schedule and send reminder messages.
Lookup API: Validate phone number format and type.
Note: MessageBird evolved into Bird in 2024 (docs.bird.com). The legacy MessageBird API and Node.js SDK (messagebird npm package) remain fully functional and documented at developers.messagebird.com. Code examples work with both platforms.
PostgreSQL: Relational database for appointment data.
Prisma: Node.js ORM for database access and migrations.
dotenv: Load environment variables from .env files.
moment-timezone: Parse, validate, and manipulate dates with timezone support. (Note: Moment.js entered maintenance mode in September 2020 (security patches only). For new projects, consider date-fns v4.0+ (built-in timezone support), Luxon (modern, immutable, timezone-aware), or Day.js (lightweight alternative). For timezone operations with earlier date-fns versions, use date-fns-tz.)
JavaScript, Node.js, REST APIs, and database fundamentals
Code editor (VS Code recommended)
Terminal/Command line
Expected Outcome: A production-ready backend service that schedules SMS reminders for appointments, ready for frontend integration.
Time Required: 90–120 minutes for experienced developers, 2–3 hours for those learning the technologies.
Error Scenarios: The system handles phone validation failures, database connection issues, MessageBird API errors, and scheduling conflicts. Failed reminders are logged with specific error codes for troubleshooting.
1. Setting up the Project
Initialize your Node.js project and install dependencies.
Steps:
Create Project Directory:
Bash
mkdir node-messagebird-reminders
cd node-messagebird-reminders
src/server.js: Entry point (service initialization, HTTP server)
This structure follows the layered architecture pattern, separating concerns for maintainability and testing. Alternative structures include MVC (model-view-controller) or feature-based organization.
Build services for appointment logic and MessageBird integration.
Steps:
MessageBird Service:
JavaScript
// src/services/messagebirdService.jsconstMessageBird = require('messagebird');
const moment = require('moment-timezone');
const logger = require('../config/logger');
let messagebird;
constinitializeMessageBird = () => {
if (!process.env.MESSAGEBIRD_API_KEY) {
logger.error("MESSAGEBIRD_API_KEY environment variable not set.");
thrownewError("MessageBird API key is required for the application to function.");
}
try {
messagebird = MessageBird(process.env.MESSAGEBIRD_API_KEY);
logger.info("MessageBird SDK initialized successfully.");
} catch (error) {
logger.error("Failed to initialize MessageBird SDK:", { error });
thrownewError("MessageBird SDK initialization failed.");
}
};
constisInitialized = () => {
if (!messagebird) {
logger.error("MessageBird Service accessed before initialization.");
returnfalse;
}
returntrue;
};
/**
* Validates a phone number using MessageBird Lookup.
* @param {string} phoneNumber - The phone number to validate.
* @param {string} [countryCode=process.env.MESSAGEBIRD_LOOKUP_COUNTRY_CODE] - ISO country code.
* @returns {Promise<object>} Lookup result with number details (phoneNumber, type).
* @throws {Error} If SDK not initialized, lookup fails, or number is invalid/not mobile.
*/constvalidatePhoneNumber = (phoneNumber, countryCode = process.env.MESSAGEBIRD_LOOKUP_COUNTRY_CODE) => {
returnnewPromise((resolve, reject) => {
if (!isInitialized()) {
returnreject(newError("MessageBird Service not ready."));
}
messagebird.lookup.read(phoneNumber, countryCode, (err, response) => {
if (err) {
if (err.errors && err.errors[0].code === 21) {
logger.warn(`Lookup failed for ${phoneNumber}: Invalid format.`);
returnreject(newError("Invalid phone number format."));
}
logger.error(`MessageBird Lookup API error: ${err.message}`, { code: err.statusCode, errors: err.errors });
returnreject(newError("Failed to validate phone number via MessageBird Lookup."));
}
if (response.type !== 'mobile') {
logger.warn(`Lookup successful for ${phoneNumber}, but type is not mobile: ${response.type}`);
returnreject(newError("Please provide a mobile phone number for SMS reminders."));
}
logger.info(`Phone number ${phoneNumber} validated successfully. E.164 format: ${response.phoneNumber}`);
resolve(response);
});
});
};
/**
* Schedules an SMS reminder using MessageBird.
* @param {string} recipientNumber - Validated phone number (E.164 format preferred).
* @param {string} messageBody - SMS content.
* @param {Date|string} scheduledDateTime - Date object or ISO string for send time.
* @returns {Promise<object>} MessageBird API response for the scheduled message.
* @throws {Error} If SDK not initialized or scheduling fails.
*/constscheduleSmsReminder = (recipientNumber, messageBody, scheduledDateTime) => {
returnnewPromise((resolve, reject) => {
if (!isInitialized()) {
returnreject(newError("MessageBird Service not ready."));
}
const originator = process.env.MESSAGEBIRD_ORIGINATOR;
if (!originator) {
logger.warn("MESSAGEBIRD_ORIGINATOR not set in .env, SMS might fail or use MessageBird default.");
}
const scheduleTimestamp = moment(scheduledDateTime).toISOString();
const params = {
originator: originator || 'MessageBird',
recipients: [recipientNumber],
body: messageBody,
scheduledDatetime: scheduleTimestamp
};
messagebird.messages.create(params, (err, response) => {
if (err) {
logger.error(`Failed to schedule SMS via MessageBird: ${err.message}`, {
code: err.statusCode,
errors: err.errors,
recipient: recipientNumber
});
returnreject(newError("Failed to schedule SMS reminder via MessageBird."));
}
logger.info(`SMS reminder scheduled successfully for ${recipientNumber}. Message ID: ${response.id}`);
resolve(response);
});
});
};
module.exports = {
initializeMessageBird,
validatePhoneNumber,
scheduleSmsReminder,
};
Provide phone in international format (+country code)
25
Invalid API key
Verify MESSAGEBIRD_API_KEY in .env
105
Insufficient balance
Add credits to MessageBird account
Rate Limits: MessageBird Lookup API has rate limits (typically 10–50 requests/second). For production with high volume, implement caching for previously validated numbers or use bulk validation endpoints.
Costs: MessageBird Lookup costs $0.005–0.01 per request. SMS pricing varies by country ($0.02–0.10 per message). Monitor usage in the MessageBird Dashboard.
Retry Logic: For transient API failures (network issues, temporary outages), implement exponential backoff:
JavaScript
// Example retry wrapper (not included in main code)constretryWithBackoff = async (fn, maxRetries = 3) => {
for (let i = 0; i < maxRetries; i++) {
try {
returnawaitfn();
} catch (error) {
if (i === maxRetries - 1) throw error;
awaitnewPromise(resolve =>setTimeout(resolve, 1000 * Math.pow(2, i)));
}
}
};
Appointment Service:
JavaScript
// src/services/appointmentService.jsconst prisma = require('../config/prisma');
const moment = require('moment-timezone');
const messagebirdService = require('./messagebirdService');
const logger = require('../config/logger');
const { AppError } = require('../utils/errorHandler');
/**
* Creates an appointment, validates the number, stores it, and schedules a reminder.
* @param {object} appointmentData - Appointment details.
* @param {string} appointmentData.customerName
* @param {string} appointmentData.customerNumberInput - Raw phone number input.
* @param {string} appointmentData.treatment
* @param {string} appointmentData.appointmentDate - Format: "YYYY-MM-DD"
* @param {string} appointmentData.appointmentTime - Format: "HH:mm"
* @param {string} appointmentData.timezone - IANA timezone (e.g., "America/New_York")
* @returns {Promise<object>} Created appointment object from database.
* @throws {AppError|Error} If validation, database operation, or scheduling fails.
*/constcreateAndScheduleReminder = async ({
customerName,
customerNumberInput,
treatment,
appointmentDate,
appointmentTime,
timezone,
}) => {
// 1. Validate and combine date/time using provided timezoneconst appointmentDateTimeStr = `${appointmentDate}${appointmentTime}`;
const appointmentMoment = moment.tz(appointmentDateTimeStr, "YYYY-MM-DD HH:mm", true, timezone);
if (!appointmentMoment.isValid()) {
thrownewAppError("Invalid date, time, or timezone provided.", 400);
}
const appointmentAtUTC = appointmentMoment.utc().toDate();
// 2. Check if appointment time is sufficiently in the futureconst reminderHours = parseInt(process.env.REMINDER_HOURS_BEFORE || '3', 10);
const earliestPossibleAppointment = moment().add(reminderHours, 'hours').add(10, 'minutes');
if (appointmentMoment.isBefore(earliestPossibleAppointment)) {
thrownewAppError(`Appointment must be at least ${reminderHours} hours and 10 minutes in the future.`, 400);
}
// 3. Validate phone number using MessageBird Lookuplet validatedNumber;
try {
const lookupResult = await messagebirdService.validatePhoneNumber(customerNumberInput);
validatedNumber = lookupResult.phoneNumber;
} catch (error) {
logger.warn(`Phone number validation failed for input ${customerNumberInput}: ${error.message}`);
thrownewAppError(`Phone number validation failed: ${error.message}`, 400);
}
// 4. Create appointment record in databaselet newAppointment;
try {
newAppointment = await prisma.appointment.create({
data: {
customerName,
customerNumber: validatedNumber,
treatment,
appointmentAt: appointmentAtUTC,
},
});
logger.info(`Appointment created successfully in DB with ID: ${newAppointment.id}`);
} catch (dbError) {
logger.error("Database error creating appointment:", { error: dbError });
thrownewError("Failed to save appointment to the database.");
}
// 5. Calculate reminder time and schedule SMSconst reminderMoment = appointmentMoment.clone().subtract(reminderHours, 'hours');
const reminderDateTime = reminderMoment.toDate();
const localAppointmentTime = appointmentMoment.format('h:mm A');
const localAppointmentDate = appointmentMoment.format('MMM D, YYYY');
const reminderBody = `Hi ${customerName}, reminder for your ${treatment} appointment on ${localAppointmentDate} at ${localAppointmentTime}. See you soon!`;
try {
const scheduleResponse = await messagebirdService.scheduleSmsReminder(
validatedNumber,
reminderBody,
reminderDateTime
);
// 6. Update appointment with scheduling infoawait prisma.appointment.update({
where: { id: newAppointment.id },
data: {
reminderSentAt: newDate(),
messagebirdId: scheduleResponse.id,
},
});
logger.info(`Successfully scheduled reminder for appointment ${newAppointment.id}. MessageBird ID: ${scheduleResponse.id}`);
} catch (scheduleError) {
logger.error(`Failed to schedule reminder for appointment ${newAppointment.id} (DB record exists!): ${scheduleError.message}`, { error: scheduleError });
newAppointment.schedulingError = `Failed to schedule SMS reminder: ${scheduleError.message}`;
}
return newAppointment;
};
module.exports = {
createAndScheduleReminder,
};
Transaction Management: This implementation creates the database record before scheduling the SMS. If MessageBird fails, the appointment exists but has no reminder. For critical systems, consider:
Message Queue: Use Redis, RabbitMQ, or AWS SQS to queue reminder tasks separately from appointment creation
Retry Job: Schedule background jobs to retry failed reminders (e.g., with BullMQ or Agenda)
Two-Phase Commit: Create appointment, schedule SMS, update status – rollback if any step fails
Idempotency: This function doesn't prevent duplicate appointments. Add idempotency by:
Generating idempotency keys from request data
Checking for existing appointments with same customer + date + time
Storing idempotency keys in database with 24-hour expiration
Retry Mechanism: For production, implement a background job system:
JavaScript
// Example with BullMQ (not included in main code)constQueue = require('bullmq').Queue;
const reminderQueue = newQueue('reminders', { connection: redisConnection });
// Add failed reminders to queueawait reminderQueue.add('send-reminder', {
appointmentId: newAppointment.id,
recipientNumber: validatedNumber,
messageBody: reminderBody,
scheduledTime: reminderDateTime
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 60000 }
});
4. Building the API Layer (Routes and Controllers)
Expose appointment creation via an Express API endpoint.
Steps:
Validation Middleware:
JavaScript
// src/middleware/validationMiddleware.jsconst { body, validationResult } = require('express-validator');
const moment = require('moment-timezone');
const validateAppointment = [
body('customerName')
.trim()
.notEmpty().withMessage('Customer name is required.')
.isString().withMessage('Customer name must be a string.')
.isLength({ min: 1, max: 100 }).withMessage('Customer name must be between 1 and 100 characters.'),
body('customerNumberInput')
.trim()
.notEmpty().withMessage('Customer phone number is required.'),
body('treatment')
.trim()
.notEmpty().withMessage('Treatment description is required.')
.isString().withMessage('Treatment must be a string.')
.isLength({ min: 1, max: 200 }).withMessage('Treatment must be between 1 and 200 characters.'),
body('appointmentDate')
.isDate({ format: 'YYYY-MM-DD', strictMode: true }).withMessage('Appointment date must be in YYYY-MM-DD format.')
.custom((value) => {
if (moment(value, 'YYYY-MM-DD').isBefore(moment(), 'day')) {
thrownewError('Appointment date cannot be in the past.');
}
returntrue;
}),
body('appointmentTime')
.matches(/^([01]\d|2[0-3]):([0-5]\d)$/, 'g').withMessage('Appointment time must be in HH:mm format (24-hour).'),
body('timezone')
.trim()
.notEmpty().withMessage('Timezone is required.')
.custom((value) => {
if (!moment.tz.zone(value)) {
thrownewError('Invalid timezone provided. Use IANA format (e.g., America/New_York).');
}
returntrue;
}).withMessage('Invalid timezone provided. Use IANA format (e.g., America/New_York).'),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
},
];
module.exports = {
validateAppointment,
};
Security Considerations:
Input Sanitization:express-validator automatically trims and escapes HTML. Prisma ORM prevents SQL injection through parameterized queries.
Length Limits: Enforced on customerName (100 chars) and treatment (200 chars) to prevent oversized payloads.
Phone Validation: Delegated to MessageBird Lookup for authoritative validation.
Rate Limiting: Add to src/app.js:
JavaScript
const rateLimit = require('express-rate-limit');
const appointmentLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 10, // 10 requests per window per IPmessage: "Too many appointment requests from this IP, please try again later."
});
app.use('/api/appointments', appointmentLimiter);
{"message":"Appointment created and reminder scheduled successfully.","appointment":{"id":"clxyz123abc","customerName":"Jane Smith","treatment":"Haircut","appointmentAtLocal":"2025-01-20 14:30 -05:00","reminderScheduled":true,"messagebirdId":"msg_abc123xyz"}}
Error Response (Invalid Phone):
JSON
{"status":"error","message":"Phone number validation failed: Invalid phone number format."}
Postman Collection:
Create new request: POST http://localhost:8080/api/appointments
Test full API flow with test database and MessageBird test API key.
Load Tests (with Artillery):
YAML
# artillery.ymlconfig:target:'http://localhost:8080'phases:-duration:60arrivalRate:10scenarios:-flow:-post:url:'/api/appointments'json:customerName:'Load Test User'customerNumberInput:'+12025551234'treatment:'Test'appointmentDate:'2025-12-31'appointmentTime:'10:00'timezone:'America/New_York'
Run: artillery run artillery.yml
Conclusion
You've built a production-ready SMS appointment reminder system with:
Use Node.js with Express, MessageBird API, and a database like PostgreSQL. Build an API endpoint to accept appointment details, validate data, store it, and schedule SMS reminders via MessageBird at specified times before appointments, handling time zones correctly.
MessageBird is a Communications Platform as a Service (CPaaS) that provides the SMS API for sending reminders and the Lookup API for validating phone numbers. It handles the actual sending of the SMS messages based on your schedule.
PostgreSQL is a robust and reliable open-source relational database suitable for storing appointment data securely and efficiently. It provides persistence and data integrity for the application.
Reminders are sent a configurable duration before the appointment, for example, 3 hours prior, as set by the REMINDER_HOURS_BEFORE environment variable. The system ensures the appointment is sufficiently in the future to schedule the reminder reliably.
Yes, the reminder message is customizable. The message body is constructed using the customer's name, treatment details, and the localized date and time of the appointment to provide a personalized reminder.
Use the MessageBird Lookup API to validate phone numbers before scheduling reminders. This ensures the numbers are in the correct format and of the mobile type, improving the reliability of SMS delivery. An example country code is demonstrated in the code.
The project leverages Express.js as the web framework to handle API requests and routing, allowing the creation of a structured and maintainable Node.js backend application.
The system uses moment-timezone (or alternatives like date-fns-tz or the built-in Intl object) to handle timezones. It expects client input with timezone information, stores appointments in UTC, and sends reminders formatted to the user's specified local time.
Prisma is a next-generation ORM (Object-Relational Mapper). It simplifies database interactions by allowing developers to define their data models using a schema and then interact with the database using generated JavaScript code.
The app includes robust error handling and logging using winston. Validation errors are handled by express-validator. For operational errors, like database or API issues, custom AppError objects provide detailed information for logging and appropriate HTTP responses.
The express-rate-limit middleware provides basic rate limiting to protect the API from abuse. This feature helps to prevent excessive requests from a single client, mitigating potential security risks and improving the application's resilience.
Set up by initializing a Node.js project, installing dependencies (Express, MessageBird SDK, Prisma, etc.), defining the database schema with Prisma, and configuring environment variables like API keys and database connection details.
Incoming request data is validated using the express-validator middleware. It checks for required fields, data types, formats, and performs basic checks like preventing past appointment dates, ensuring timezone strings are valid, and provides informative error messages.
Build an SMS appointment reminder system using Node.js, Express, and the MessageBird API. This guide covers database integration, error handling, security, and deployment considerations for production environments.
The application exposes an API endpoint to accept appointment details, validate them, store them in a database, and schedule an SMS reminder via MessageBird at a specified time before the appointment.
Project Overview and Goals
What You'll Build: A Node.js backend service using Express that:
Problem Solved: Customer no-shows cost businesses time and revenue. Timely SMS reminders significantly reduce missed appointments.
Technologies:
messagebirdnpm package) remain fully functional and documented at developers.messagebird.com. Code examples work with both platforms.dotenv: Load environment variables from.envfiles.moment-timezone: Parse, validate, and manipulate dates with timezone support. (Note: Moment.js entered maintenance mode in September 2020 (security patches only). For new projects, consider date-fns v4.0+ (built-in timezone support), Luxon (modern, immutable, timezone-aware), or Day.js (lightweight alternative). For timezone operations with earlier date-fns versions, usedate-fns-tz.)express-validator: Validate incoming request data.express-rate-limit: Rate limiting to prevent abuse.winston: Logging library.System Architecture:
Data Flow:
Prerequisites:
Expected Outcome: A production-ready backend service that schedules SMS reminders for appointments, ready for frontend integration.
Time Required: 90–120 minutes for experienced developers, 2–3 hours for those learning the technologies.
Error Scenarios: The system handles phone validation failures, database connection issues, MessageBird API errors, and scheduling conflicts. Failed reminders are logged with specific error codes for troubleshooting.
1. Setting up the Project
Initialize your Node.js project and install dependencies.
Steps:
Create Project Directory:
mkdir node-messagebird-reminders cd node-messagebird-remindersInitialize Node.js Project:
Install Dependencies:
Install Development Dependencies:
Initialize Prisma:
This creates:
prisma/schema.prismafor database schema.envfor environment variablesConfigure
.env: Open.envand add your configuration:Configuration Details:
DATABASE_URLpostgresql://user:pass@localhost:5432/mydbMESSAGEBIRD_API_KEYlive_aBcDeFgHiJkLmNoPqRsTuVwXyZMESSAGEBIRD_ORIGINATORBeautyBirdor+12025550134MESSAGEBIRD_LOOKUP_COUNTRY_CODEUS,GB,NLREMINDER_HOURS_BEFORE3SERVER_PORT8080LOG_LEVELerror,warn,info,debugNODE_ENVdevelopment,productionCreate Project Structure:
mkdir src mkdir src/config src/routes src/controllers src/services src/utils src/middleware touch src/server.js src/app.js touch src/config/logger.js src/config/prisma.js touch src/routes/appointmentRoutes.js touch src/controllers/appointmentController.js touch src/services/messagebirdService.js src/services/appointmentService.js touch src/utils/errorHandler.js touch src/middleware/validationMiddleware.jsStructure Rationale:
src/config: Centralized configuration (logging, database client)src/routes: Express route definitions (separates routing from logic)src/controllers: Request handling (thin layer between routes and services)src/services: Business logic and third-party integrations (testable, reusable)src/utils: Helper functions (error handling, date formatting)src/middleware: Express middleware (validation, rate limiting)src/app.js: Express app setup (middleware, routes)src/server.js: Entry point (service initialization, HTTP server)This structure follows the layered architecture pattern, separating concerns for maintainability and testing. Alternative structures include MVC (model-view-controller) or feature-based organization.
Create
.gitignore:2. Creating a Database Schema and Data Layer
Define your database schema and set up Prisma for database access.
Steps:
Define Schema: Open
prisma/schema.prisma:Field Details:
id: Unique identifier (CUID format for distributed systems)customerNumber: E.164 international format (+12025551234) from MessageBird LookupappointmentAt: UTC timestamp (avoids timezone ambiguity)reminderSentAt: Timestamp when MessageBird successfully scheduled the SMSmessagebirdId: MessageBird message ID for tracking delivery status@@index([appointmentAt]): Improves query performance for time-based lookupsApply Schema (Migration):
This command:
prisma/migrationsIf Migration Fails:
DATABASE_URLin.envpsql -U USER -d DATABASE -c "SELECT 1;"Production Migration Strategy:
npx prisma migrate deployin production (no interactive prompts)Create Prisma Client Instance:
// src/config/prisma.js const { PrismaClient } = require('@prisma/client'); const prismaOptions = {}; if (process.env.NODE_ENV !== 'production') { prismaOptions.log = ['query', 'info', 'warn', 'error']; } else { prismaOptions.log = ['warn', 'error']; } const prisma = new PrismaClient(prismaOptions); module.exports = prisma;3. Implementing Core Functionality & Services
Build services for appointment logic and MessageBird integration.
Steps:
MessageBird Service:
// src/services/messagebirdService.js const MessageBird = require('messagebird'); const moment = require('moment-timezone'); const logger = require('../config/logger'); let messagebird; const initializeMessageBird = () => { if (!process.env.MESSAGEBIRD_API_KEY) { logger.error("MESSAGEBIRD_API_KEY environment variable not set."); throw new Error("MessageBird API key is required for the application to function."); } try { messagebird = MessageBird(process.env.MESSAGEBIRD_API_KEY); logger.info("MessageBird SDK initialized successfully."); } catch (error) { logger.error("Failed to initialize MessageBird SDK:", { error }); throw new Error("MessageBird SDK initialization failed."); } }; const isInitialized = () => { if (!messagebird) { logger.error("MessageBird Service accessed before initialization."); return false; } return true; }; /** * Validates a phone number using MessageBird Lookup. * @param {string} phoneNumber - The phone number to validate. * @param {string} [countryCode=process.env.MESSAGEBIRD_LOOKUP_COUNTRY_CODE] - ISO country code. * @returns {Promise<object>} Lookup result with number details (phoneNumber, type). * @throws {Error} If SDK not initialized, lookup fails, or number is invalid/not mobile. */ const validatePhoneNumber = (phoneNumber, countryCode = process.env.MESSAGEBIRD_LOOKUP_COUNTRY_CODE) => { return new Promise((resolve, reject) => { if (!isInitialized()) { return reject(new Error("MessageBird Service not ready.")); } messagebird.lookup.read(phoneNumber, countryCode, (err, response) => { if (err) { if (err.errors && err.errors[0].code === 21) { logger.warn(`Lookup failed for ${phoneNumber}: Invalid format.`); return reject(new Error("Invalid phone number format.")); } logger.error(`MessageBird Lookup API error: ${err.message}`, { code: err.statusCode, errors: err.errors }); return reject(new Error("Failed to validate phone number via MessageBird Lookup.")); } if (response.type !== 'mobile') { logger.warn(`Lookup successful for ${phoneNumber}, but type is not mobile: ${response.type}`); return reject(new Error("Please provide a mobile phone number for SMS reminders.")); } logger.info(`Phone number ${phoneNumber} validated successfully. E.164 format: ${response.phoneNumber}`); resolve(response); }); }); }; /** * Schedules an SMS reminder using MessageBird. * @param {string} recipientNumber - Validated phone number (E.164 format preferred). * @param {string} messageBody - SMS content. * @param {Date|string} scheduledDateTime - Date object or ISO string for send time. * @returns {Promise<object>} MessageBird API response for the scheduled message. * @throws {Error} If SDK not initialized or scheduling fails. */ const scheduleSmsReminder = (recipientNumber, messageBody, scheduledDateTime) => { return new Promise((resolve, reject) => { if (!isInitialized()) { return reject(new Error("MessageBird Service not ready.")); } const originator = process.env.MESSAGEBIRD_ORIGINATOR; if (!originator) { logger.warn("MESSAGEBIRD_ORIGINATOR not set in .env, SMS might fail or use MessageBird default."); } const scheduleTimestamp = moment(scheduledDateTime).toISOString(); const params = { originator: originator || 'MessageBird', recipients: [recipientNumber], body: messageBody, scheduledDatetime: scheduleTimestamp }; messagebird.messages.create(params, (err, response) => { if (err) { logger.error(`Failed to schedule SMS via MessageBird: ${err.message}`, { code: err.statusCode, errors: err.errors, recipient: recipientNumber }); return reject(new Error("Failed to schedule SMS reminder via MessageBird.")); } logger.info(`SMS reminder scheduled successfully for ${recipientNumber}. Message ID: ${response.id}`); resolve(response); }); }); }; module.exports = { initializeMessageBird, validatePhoneNumber, scheduleSmsReminder, };MessageBird Error Codes:
MESSAGEBIRD_API_KEYin.envRate Limits: MessageBird Lookup API has rate limits (typically 10–50 requests/second). For production with high volume, implement caching for previously validated numbers or use bulk validation endpoints.
Costs: MessageBird Lookup costs $0.005–0.01 per request. SMS pricing varies by country ($0.02–0.10 per message). Monitor usage in the MessageBird Dashboard.
Retry Logic: For transient API failures (network issues, temporary outages), implement exponential backoff:
// Example retry wrapper (not included in main code) const retryWithBackoff = async (fn, maxRetries = 3) => { for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { if (i === maxRetries - 1) throw error; await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); } } };Appointment Service:
// src/services/appointmentService.js const prisma = require('../config/prisma'); const moment = require('moment-timezone'); const messagebirdService = require('./messagebirdService'); const logger = require('../config/logger'); const { AppError } = require('../utils/errorHandler'); /** * Creates an appointment, validates the number, stores it, and schedules a reminder. * @param {object} appointmentData - Appointment details. * @param {string} appointmentData.customerName * @param {string} appointmentData.customerNumberInput - Raw phone number input. * @param {string} appointmentData.treatment * @param {string} appointmentData.appointmentDate - Format: "YYYY-MM-DD" * @param {string} appointmentData.appointmentTime - Format: "HH:mm" * @param {string} appointmentData.timezone - IANA timezone (e.g., "America/New_York") * @returns {Promise<object>} Created appointment object from database. * @throws {AppError|Error} If validation, database operation, or scheduling fails. */ const createAndScheduleReminder = async ({ customerName, customerNumberInput, treatment, appointmentDate, appointmentTime, timezone, }) => { // 1. Validate and combine date/time using provided timezone const appointmentDateTimeStr = `${appointmentDate} ${appointmentTime}`; const appointmentMoment = moment.tz(appointmentDateTimeStr, "YYYY-MM-DD HH:mm", true, timezone); if (!appointmentMoment.isValid()) { throw new AppError("Invalid date, time, or timezone provided.", 400); } const appointmentAtUTC = appointmentMoment.utc().toDate(); // 2. Check if appointment time is sufficiently in the future const reminderHours = parseInt(process.env.REMINDER_HOURS_BEFORE || '3', 10); const earliestPossibleAppointment = moment().add(reminderHours, 'hours').add(10, 'minutes'); if (appointmentMoment.isBefore(earliestPossibleAppointment)) { throw new AppError(`Appointment must be at least ${reminderHours} hours and 10 minutes in the future.`, 400); } // 3. Validate phone number using MessageBird Lookup let validatedNumber; try { const lookupResult = await messagebirdService.validatePhoneNumber(customerNumberInput); validatedNumber = lookupResult.phoneNumber; } catch (error) { logger.warn(`Phone number validation failed for input ${customerNumberInput}: ${error.message}`); throw new AppError(`Phone number validation failed: ${error.message}`, 400); } // 4. Create appointment record in database let newAppointment; try { newAppointment = await prisma.appointment.create({ data: { customerName, customerNumber: validatedNumber, treatment, appointmentAt: appointmentAtUTC, }, }); logger.info(`Appointment created successfully in DB with ID: ${newAppointment.id}`); } catch (dbError) { logger.error("Database error creating appointment:", { error: dbError }); throw new Error("Failed to save appointment to the database."); } // 5. Calculate reminder time and schedule SMS const reminderMoment = appointmentMoment.clone().subtract(reminderHours, 'hours'); const reminderDateTime = reminderMoment.toDate(); const localAppointmentTime = appointmentMoment.format('h:mm A'); const localAppointmentDate = appointmentMoment.format('MMM D, YYYY'); const reminderBody = `Hi ${customerName}, reminder for your ${treatment} appointment on ${localAppointmentDate} at ${localAppointmentTime}. See you soon!`; try { const scheduleResponse = await messagebirdService.scheduleSmsReminder( validatedNumber, reminderBody, reminderDateTime ); // 6. Update appointment with scheduling info await prisma.appointment.update({ where: { id: newAppointment.id }, data: { reminderSentAt: new Date(), messagebirdId: scheduleResponse.id, }, }); logger.info(`Successfully scheduled reminder for appointment ${newAppointment.id}. MessageBird ID: ${scheduleResponse.id}`); } catch (scheduleError) { logger.error(`Failed to schedule reminder for appointment ${newAppointment.id} (DB record exists!): ${scheduleError.message}`, { error: scheduleError }); newAppointment.schedulingError = `Failed to schedule SMS reminder: ${scheduleError.message}`; } return newAppointment; }; module.exports = { createAndScheduleReminder, };Transaction Management: This implementation creates the database record before scheduling the SMS. If MessageBird fails, the appointment exists but has no reminder. For critical systems, consider:
Idempotency: This function doesn't prevent duplicate appointments. Add idempotency by:
Retry Mechanism: For production, implement a background job system:
// Example with BullMQ (not included in main code) const Queue = require('bullmq').Queue; const reminderQueue = new Queue('reminders', { connection: redisConnection }); // Add failed reminders to queue await reminderQueue.add('send-reminder', { appointmentId: newAppointment.id, recipientNumber: validatedNumber, messageBody: reminderBody, scheduledTime: reminderDateTime }, { attempts: 3, backoff: { type: 'exponential', delay: 60000 } });4. Building the API Layer (Routes and Controllers)
Expose appointment creation via an Express API endpoint.
Steps:
Validation Middleware:
// src/middleware/validationMiddleware.js const { body, validationResult } = require('express-validator'); const moment = require('moment-timezone'); const validateAppointment = [ body('customerName') .trim() .notEmpty().withMessage('Customer name is required.') .isString().withMessage('Customer name must be a string.') .isLength({ min: 1, max: 100 }).withMessage('Customer name must be between 1 and 100 characters.'), body('customerNumberInput') .trim() .notEmpty().withMessage('Customer phone number is required.'), body('treatment') .trim() .notEmpty().withMessage('Treatment description is required.') .isString().withMessage('Treatment must be a string.') .isLength({ min: 1, max: 200 }).withMessage('Treatment must be between 1 and 200 characters.'), body('appointmentDate') .isDate({ format: 'YYYY-MM-DD', strictMode: true }).withMessage('Appointment date must be in YYYY-MM-DD format.') .custom((value) => { if (moment(value, 'YYYY-MM-DD').isBefore(moment(), 'day')) { throw new Error('Appointment date cannot be in the past.'); } return true; }), body('appointmentTime') .matches(/^([01]\d|2[0-3]):([0-5]\d)$/, 'g').withMessage('Appointment time must be in HH:mm format (24-hour).'), body('timezone') .trim() .notEmpty().withMessage('Timezone is required.') .custom((value) => { if (!moment.tz.zone(value)) { throw new Error('Invalid timezone provided. Use IANA format (e.g., America/New_York).'); } return true; }).withMessage('Invalid timezone provided. Use IANA format (e.g., America/New_York).'), (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } next(); }, ]; module.exports = { validateAppointment, };Security Considerations:
express-validatorautomatically trims and escapes HTML. Prisma ORM prevents SQL injection through parameterized queries.customerName(100 chars) andtreatment(200 chars) to prevent oversized payloads.Rate Limiting: Add to
src/app.js:const rateLimit = require('express-rate-limit'); const appointmentLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // 10 requests per window per IP message: "Too many appointment requests from this IP, please try again later." }); app.use('/api/appointments', appointmentLimiter);Appointment Controller:
// src/controllers/appointmentController.js const appointmentService = require('../services/appointmentService'); const logger = require('../config/logger'); const moment = require('moment-timezone'); const createAppointment = async (req, res, next) => { try { const appointmentData = req.body; logger.info("Processing request to create appointment:", { customer: appointmentData.customerName, date: appointmentData.appointmentDate }); const newAppointment = await appointmentService.createAndScheduleReminder(appointmentData); if (newAppointment.schedulingError) { logger.warn(`Appointment ${newAppointment.id} created, but reminder scheduling failed.`); res.status(201).json({ message: "Appointment created successfully, BUT failed to schedule SMS reminder. Check logs or retry scheduling manually.", appointment: { id: newAppointment.id, customerName: newAppointment.customerName, treatment: newAppointment.treatment, appointmentAtLocal: moment(newAppointment.appointmentAt).tz(appointmentData.timezone).format('YYYY-MM-DD HH:mm Z'), reminderScheduled: false, schedulingError: newAppointment.schedulingError } }); } else { logger.info(`Successfully created appointment ${newAppointment.id} and scheduled reminder.`); res.status(201).json({ message: "Appointment created and reminder scheduled successfully.", appointment: { id: newAppointment.id, customerName: newAppointment.customerName, treatment: newAppointment.treatment, appointmentAtLocal: moment(newAppointment.appointmentAt).tz(appointmentData.timezone).format('YYYY-MM-DD HH:mm Z'), reminderScheduled: true, messagebirdId: newAppointment.messagebirdId } }); } } catch (error) { next(error); } }; module.exports = { createAppointment, };Appointment Routes:
// src/routes/appointmentRoutes.js const express = require('express'); const appointmentController = require('../controllers/appointmentController'); const { validateAppointment } = require('../middleware/validationMiddleware'); const router = express.Router(); router.post('/', validateAppointment, appointmentController.createAppointment); module.exports = router;5. Logger Configuration
Set up Winston for structured logging.
// src/config/logger.js const winston = require('winston'); const logLevel = process.env.LOG_LEVEL || 'info'; const logger = winston.createLogger({ level: logLevel, format: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.errors({ stack: true }), winston.format.splat(), winston.format.json() ), defaultMeta: { service: 'appointment-reminder-service' }, transports: [ new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), new winston.transports.File({ filename: 'logs/combined.log' }), ], }); if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), winston.format.simple() ) })); } module.exports = logger;Create logs directory:
mkdir logs6. Error Handler Utility
Create a centralized error handler.
// src/utils/errorHandler.js class AppError extends Error { constructor(message, statusCode) { super(message); this.statusCode = statusCode; this.isOperational = true; Error.captureStackTrace(this, this.constructor); } } const errorHandler = (err, req, res, next) => { const logger = require('../config/logger'); err.statusCode = err.statusCode || 500; err.status = err.status || 'error'; if (process.env.NODE_ENV === 'development') { logger.error('Error:', { message: err.message, stack: err.stack, statusCode: err.statusCode }); res.status(err.statusCode).json({ status: err.status, error: err, message: err.message, stack: err.stack }); } else { logger.error('Error:', { message: err.message, statusCode: err.statusCode }); if (err.isOperational) { res.status(err.statusCode).json({ status: err.status, message: err.message }); } else { res.status(500).json({ status: 'error', message: 'Something went wrong' }); } } }; module.exports = { AppError, errorHandler };7. Express Application Setup
Configure Express with middleware and routes.
// src/app.js const express = require('express'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const appointmentRoutes = require('./routes/appointmentRoutes'); const { errorHandler } = require('./utils/errorHandler'); const app = express(); // Security middleware app.use(helmet()); // Body parser app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Rate limiting const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, message: 'Too many requests from this IP, please try again later.' }); app.use(limiter); // Health check endpoint app.get('/health', (req, res) => { res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() }); }); // API routes app.use('/api/appointments', appointmentRoutes); // 404 handler app.use((req, res) => { res.status(404).json({ message: 'Route not found' }); }); // Global error handler app.use(errorHandler); module.exports = app;8. Server Entry Point
Initialize services and start the HTTP server.
// src/server.js const app = require('./app'); const prisma = require('./config/prisma'); const logger = require('./config/logger'); const { initializeMessageBird } = require('./services/messagebirdService'); const PORT = process.env.SERVER_PORT || 8080; const startServer = async () => { try { // Test database connection await prisma.$connect(); logger.info('Database connected successfully'); // Initialize MessageBird SDK initializeMessageBird(); // Start HTTP server app.listen(PORT, () => { logger.info(`Server running on port ${PORT}`); logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`); }); } catch (error) { logger.error('Failed to start server:', { error: error.message }); process.exit(1); } }; // Graceful shutdown process.on('SIGTERM', async () => { logger.info('SIGTERM received, closing gracefully'); await prisma.$disconnect(); process.exit(0); }); process.on('SIGINT', async () => { logger.info('SIGINT received, closing gracefully'); await prisma.$disconnect(); process.exit(0); }); startServer();9. Testing the Application
Test your appointment reminder system.
Start the Server:
Create an Appointment (curl):
curl -X POST http://localhost:8080/api/appointments \ -H "Content-Type: application/json" \ -d '{ "customerName": "Jane Smith", "customerNumberInput": "+12025551234", "treatment": "Haircut", "appointmentDate": "2025-01-20", "appointmentTime": "14:30", "timezone": "America/New_York" }'Success Response:
{ "message": "Appointment created and reminder scheduled successfully.", "appointment": { "id": "clxyz123abc", "customerName": "Jane Smith", "treatment": "Haircut", "appointmentAtLocal": "2025-01-20 14:30 -05:00", "reminderScheduled": true, "messagebirdId": "msg_abc123xyz" } }Error Response (Invalid Phone):
{ "status": "error", "message": "Phone number validation failed: Invalid phone number format." }Postman Collection:
Create new request:
POST http://localhost:8080/api/appointmentsSet header:
Content-Type: application/jsonAdd body (raw JSON):
{ "customerName": "John Doe", "customerNumberInput": "+447700900123", "treatment": "Dental Cleaning", "appointmentDate": "2025-01-25", "appointmentTime": "10:00", "timezone": "Europe/London" }Send request and verify 201 status code
10. Troubleshooting Common Errors
MESSAGEBIRD_API_KEY environment variable not set.envconfigurationMESSAGEBIRD_API_KEYto.envfileDatabase error creating appointmentDATABASE_URLand checkpsqlconnectionPhone number validation failed: Invalid format+[country][number]Appointment must be at least 3 hours in the futureFailed to schedule SMS via MessageBirdMESSAGEBIRD_ORIGINATORInvalid timezone providedAmerica/New_York, notESTToo many requestsexpress-rate-limitsettingsDebug Checklist:
logs/error.logandlogs/combined.log.envnpx prisma studiomoment.tz.names()11. Deployment Considerations
Environment Variables:
Store production secrets securely:
.envto version controlMESSAGEBIRD_API_KEYregularlyProduction Setup:
Set
NODE_ENV=productionin production environmentUse process manager: PM2 or Docker for automatic restarts
Enable HTTPS: Use nginx or a cloud load balancer
Database connection pooling: Prisma default pool size is 10; adjust in
prisma/schema.prisma:Monitoring: Set up alerts for failed reminders, API errors, database issues
Deployment with PM2:
Docker Deployment:
Build and run:
docker build -t appointment-reminders . docker run -d -p 8080:8080 --env-file .env appointment-reminders12. Monitoring and Observability
Track SMS Delivery:
Query MessageBird API for message status:
messagebird.messages.read(messagebirdId, (err, response) => { console.log(response.status); // 'sent', 'delivered', 'failed' });Failed Reminder Dashboard:
Query appointments without reminders:
const failedReminders = await prisma.appointment.findMany({ where: { reminderSentAt: null, appointmentAt: { gt: new Date() } } });Performance Metrics:
Logging Strategy:
13. Cost Analysis
MessageBird Pricing (Approximate):
Infrastructure Costs:
14. Performance Considerations
Capacity:
appointmentAtenables efficient queriesOptimization:
15. Testing Strategy
Unit Tests (with Jest):
// __tests__/services/appointmentService.test.js const appointmentService = require('../../src/services/appointmentService'); describe('createAndScheduleReminder', () => { it('should reject appointments in the past', async () => { await expect(appointmentService.createAndScheduleReminder({ customerName: 'Test', customerNumberInput: '+12025551234', treatment: 'Test', appointmentDate: '2020-01-01', appointmentTime: '10:00', timezone: 'America/New_York' })).rejects.toThrow('future'); }); });Integration Tests:
Test full API flow with test database and MessageBird test API key.
Load Tests (with Artillery):
# artillery.yml config: target: 'http://localhost:8080' phases: - duration: 60 arrivalRate: 10 scenarios: - flow: - post: url: '/api/appointments' json: customerName: 'Load Test User' customerNumberInput: '+12025551234' treatment: 'Test' appointmentDate: '2025-12-31' appointmentTime: '10:00' timezone: 'America/New_York'Run:
artillery run artillery.ymlConclusion
You've built a production-ready SMS appointment reminder system with:
Next Steps:
Resources: