How to Build SMS Appointment Reminders with MessageBird, Node.js & Fastify
Learn how to build automated SMS appointment reminder systems with MessageBird API, Node.js, and Fastify. Complete tutorial with phone validation, scheduling, PostgreSQL database, error handling, and production deployment.
Learn how to build a production-ready SMS appointment reminder system using Node.js, Fastify, and MessageBird. This comprehensive tutorial covers project setup, phone number validation, SMS scheduling, database integration, error handling, security best practices, and deployment.
Missed appointments cost you time and money. SMS reminders reduce no-shows by providing timely nudges. This application lets users book appointments via a web form and automatically schedules SMS reminders through MessageBird before each appointment.
Important Platform Update (2025): MessageBird rebranded as Bird in February 2024. The legacy platform (dashboard.messagebird.com) shuts down March 31, 2025. For new implementations, use the Bird next-gen platform (app.bird.com), though the MessageBird API and Node.js SDK remain functional. See Bird's migration documentation for details.
Key Features
User-friendly appointment booking form
Phone number validation using MessageBird Lookup
Automated SMS reminder scheduling via MessageBird API
MessageBird: SMS API for sending messages and Lookup API for validation
PostgreSQL: Relational database for storing appointments (v9.6 or later)
Prisma: Modern ORM for database interaction (v6.x)
dayjs: Library for date/time manipulation
dotenv: Manages environment variables
@fastify/env: Validates environment variables
@fastify/sensible: Provides sensible defaults and HTTP errors
@fastify/rate-limit: Rate limiting for security (v6.0.0+, replaces deprecated fastify-rate-limit)
System Architecture
Text
+-------------+ +-----------------+ +----------------------+ +-----------------+ +-----------------+
| User via | ----> | Fastify API | ----> | MessageBird Lookup | ----> | Fastify API | ----> | PostgreSQL DB |
| Browser | | (POST /book) | | (Validate Number) | | (Store Booking) | | (Prisma) |
+-------------+ +-----------------+ +----------------------+ +-----------------+ +-----------------+
| | |
| | |
| +--------------------------------------------------------+
| |
| v
| +----------------------+
| | MessageBird SMS API |
| | (Schedule Reminder) |
| +----------------------+
| |
| v (at scheduled time)
+-----------------------------------------+ SMS to User Phone
Prerequisites
Node.js (v20 or v22 LTS): Node.js v18 reaches end-of-life in April 2025. Use v20 LTS ("Iron", maintenance mode) or v22 LTS ("Jod", active support until October 2025). Check your version: node -v. Install from nodejs.org.
npm or yarn: Comes with Node.js. Check installation: npm -v.
MessageBird/Bird account: Sign up at messagebird.com or bird.com. For new projects after March 31, 2025, use the Bird next-gen platform.
PostgreSQL database (v9.6+): Prisma 6.x requires PostgreSQL 9.6 minimum (current versions 12+ strongly recommended). Run locally using Docker or use a cloud provider (AWS RDS, Heroku Postgres, Supabase).
Basic familiarity with Node.js, APIs, and databases
What You'll Build
You'll create a functional Fastify application that accepts appointment bookings, validates phone numbers, schedules SMS reminders via MessageBird, and stores appointment data securely in a database.
1. Project Setup
Initialize your project, install dependencies, and set up the basic structure.
Create Project Directory
Bash
mkdir fastify-messagebird-reminders
cd fastify-messagebird-reminders
# Server Configuration
PORT=3000
HOST=0.0.0.0
# MessageBird API Configuration
# Get from MessageBird Dashboard > Developers > API access
# Note: For new projects after March 31, 2025, obtain keys from Bird platform (app.bird.com)
MESSAGEBIRD_API_KEY=YOUR_LIVE_OR_TEST_API_KEY
# Default sender ID for SMS. Check MessageBird docs for country restrictions.
# Can be alphanumeric (e.g., "MyApp") or purchased number.
MESSAGEBIRD_ORIGINATOR=BeautyBird
# Default country code for phone lookup (ISO 3166-1 alpha-2)
# Example: US, GB, NL
DEFAULT_COUNTRY_CODE=US
# Database Configuration (Prisma standard format)
# PostgreSQL minimum v9.6, current versions 12+ recommended
DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
.env (create locally, do not commit):
Copy .env.example to .env and fill in your actual credentials and database URL.
Validate Environment Variables
Define the schema for expected environment variables.
Store all timestamps in UTC to avoid ambiguity and make time zone conversions predictable
Store messageBirdId returned by MessageBird for tracking message status later
Index appointmentAt for querying by appointment time
Index phoneNumber if searching by phone number is common
Create the Initial Migration
This command compares your schema to the database and generates the SQL needed to create the Appointment table.
Bash
npx prisma migrate dev --name init_appointment
Prisma will ask you to confirm. Review the generated SQL and proceed. This creates the table in your database and generates the Prisma Client based on your schema.
Create Prisma Plugin for Fastify
Integrate the Prisma Client instance into the Fastify application context.
Why fastify-plugin? It prevents Fastify's encapsulation, making fastify.prisma available in all routes registered after this plugin.
Create Appointment Service
Encapsulate database interaction logic.
JavaScript
// src/db/appointmentService.jsimport { Prisma } from'@prisma/client';
/**
* Create a new appointment record in the database.
*/exportasyncfunctioncreateAppointment(prisma, logger, appointmentData) {
try {
const appointment = await prisma.appointment.create({
data: appointmentData,
});
logger.info({ appointmentId: appointment.id }, 'Appointment created in database');
return appointment;
} catch (error) {
if (error instanceofPrisma.PrismaClientKnownRequestError) {
logger.error({ code: error.code, meta: error.meta, ...appointmentData }, 'Prisma error creating appointment');
} else {
logger.error({ error, ...appointmentData }, 'Generic error creating appointment in DB');
}
thrownewError('Failed to save appointment details to the database.');
}
}
3. Core Reminder Logic Implementation
Implement the logic within the booking route handler.
Initialize MessageBird Client
Create a dedicated file to initialize the SDK using the API key from environment variables.
JavaScript
// src/config/messagebirdClient.jsimport { messagebird } from'messagebird';
let mbClient = null;
/**
* Initialize the MessageBird client instance (Singleton).
* Call this once during application startup.
*/exportfunctioninitializeMessageBirdClient(apiKey) {
if (!apiKey) {
thrownewError('MessageBird API key is required for initialization.');
}
if (mbClient) {
console.warn('MessageBird client already initialized.');
return;
}
mbClient = messagebird(apiKey);
}
/**
* Get the initialized MessageBird client instance.
*/exportfunctiongetMessageBirdClient() {
if (!mbClient) {
thrownewError('MessageBird client has not been initialized. Call initializeMessageBirdClient during server setup.');
}
return mbClient;
}
Why a separate file? This promotes modularity and allows easy initialization (in server.js after loading config) and retrieval wherever needed, enforcing the API key requirement and using a singleton pattern.
Implement the Booking Route Handler
This involves validation, phone number lookup, date calculation, scheduling the SMS, and saving to the database.
JavaScript
// src/routes/bookingRoutes.jsimport dayjs from'dayjs';
import utc from'dayjs/plugin/utc.js';
import isSameOrAfter from'dayjs/plugin/isSameOrAfter.js';
import { getMessageBirdClient } from'../config/messagebirdClient.js';
import { createAppointment } from'../db/appointmentService.js';
dayjs.extend(utc);
dayjs.extend(isSameOrAfter);
const bookingBodySchema = {
type: 'object',
required: ['name', 'phoneNumber', 'treatment', 'appointmentDate', 'appointmentTime'],
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
phoneNumber: { type: 'string', minLength: 5, maxLength: 20 },
treatment: { type: 'string', minLength: 3, maxLength: 100 },
appointmentDate: { type: 'string', format: 'date' },
appointmentTime: {
type: 'string',
pattern: '^([01]?[0-9]|2[0-3]):[0-5][0-9]$'
},
},
additionalProperties: false,
};
asyncfunctionbookingRoutes(fastify, options) {
const messagebird = getMessageBirdClient();
const config = fastify.config;
const logger = fastify.log;
const prisma = fastify.prisma;
fastify.post('/book', { schema: { body: bookingBodySchema } }, async (request, reply) => {
const { name, phoneNumber, treatment, appointmentDate, appointmentTime } = request.body;
// 1. Validate Appointment Date/Time Logicconst reminderBufferHours = 3;
const minimumLeadTimeMinutes = 5;
const dateTimeString = `${appointmentDate}${appointmentTime}`;
// Parse the combined string// CRITICAL: Time Zone Assumption// dayjs(dateTimeString) parses using the SERVER's local timezone// For global apps, COLLECT USER TIMEZONE and use dayjs-timezoneconst appointmentDateTimeLocal = dayjs(dateTimeString);
if (!appointmentDateTimeLocal.isValid()) {
throw fastify.httpErrors.badRequest('Invalid date or time format provided. Use YYYY-MM-DD and HH:MM.');
}
// Convert to UTC for all calculations and storageconst appointmentDateTimeUTC = appointmentDateTimeLocal.utc();
// Calculate the earliest allowed booking time in UTCconst earliestPossibleBookingUTC = dayjs.utc().add(reminderBufferHours, 'hour').add(minimumLeadTimeMinutes, 'minute');
if (appointmentDateTimeUTC.isBefore(earliestPossibleBookingUTC)) {
logger.warn({
requestedUTC: appointmentDateTimeUTC.toISOString(),
earliestUTC: earliestPossibleBookingUTC.toISOString()
}, 'Booking attempt too soon');
throw fastify.httpErrors.badRequest(
`Appointment must be scheduled at least ${reminderBufferHours} hours and ${minimumLeadTimeMinutes} minutes from now.`
);
}
const reminderDateTimeUTC = appointmentDateTimeUTC.subtract(reminderBufferHours, 'hour');
// 2. Validate Phone Number via MessageBird Lookuplet normalizedPhoneNumber;
try {
logger.info({ phoneNumber, country: config.DEFAULT_COUNTRY_CODE }, 'Attempting MessageBird Lookup');
const lookupResponse = awaitnewPromise((resolve, reject) => {
messagebird.lookup.read(phoneNumber, config.DEFAULT_COUNTRY_CODE, (err, response) => {
if (err) {
if (err.errors && err.errors[0].code === 21) {
logger.warn({ err, phoneNumber }, 'MessageBird Lookup: Invalid number format');
returnreject(fastify.httpErrors.badRequest(`The phone number '${phoneNumber}' appears invalid. Check the format and include a country code if necessary.`));
}
logger.error({ err, phoneNumber }, 'MessageBird Lookup API error');
returnreject(fastify.httpErrors.internalServerError('Could not validate the phone number at this time. Try again later.'));
}
resolve(response);
});
});
if (lookupResponse.type !== 'mobile') {
logger.warn({ number: phoneNumber, type: lookupResponse.type }, 'Non-mobile number provided for SMS reminder');
throw fastify.httpErrors.badRequest(
`Phone number type '${lookupResponse.type}' is not suitable for SMS reminders. Provide a valid mobile number.`
);
}
normalizedPhoneNumber = lookupResponse.phoneNumber;
logger.info({ original: phoneNumber, normalized: normalizedPhoneNumber, type: lookupResponse.type }, 'Phone number validated via MessageBird Lookup');
} catch (error) {
if (error.statusCode) throw error;
logger.error({ error: error.message, stack: error.stack, phoneNumber }, 'Unexpected error during phone number lookup');
throw fastify.httpErrors.internalServerError('An unexpected error occurred during phone number validation.');
}
// 3. Schedule SMS Reminder via MessageBirdlet messageBirdResponse;
try {
const reminderBody = `Hi ${name}, this is a reminder for your ${treatment} appointment at ${appointmentDateTimeLocal.format('h:mm A')} today with ${config.MESSAGEBIRD_ORIGINATOR}. See you soon!`;
const scheduledTimestampISO = reminderDateTimeUTC.toISOString();
logger.info({ recipient: normalizedPhoneNumber, scheduledAt: scheduledTimestampISO }, 'Attempting to schedule MessageBird SMS');
messageBirdResponse = awaitnewPromise((resolve, reject) => {
messagebird.messages.create({
originator: config.MESSAGEBIRD_ORIGINATOR,
recipients: [normalizedPhoneNumber],
scheduledDatetime: scheduledTimestampISO,
body: reminderBody,
}, (err, response) => {
if (err) {
logger.error({ err, recipient: normalizedPhoneNumber, originator: config.MESSAGEBIRD_ORIGINATOR }, 'MessageBird Messages API error during scheduling');
if (err.statusCode === 422) {
returnreject(fastify.httpErrors.badRequest('Failed to schedule SMS. There might be an issue with the sender ID configuration for this destination.'));
}
returnreject(fastify.httpErrors.internalServerError('Failed to schedule the reminder SMS due to an API error. Try booking again later.'));
}
resolve(response);
});
});
logger.info({ msgId: messageBirdResponse?.id, scheduledAt: scheduledTimestampISO, recipient: normalizedPhoneNumber, status: messageBirdResponse?.recipients?.items[0]?.status }, 'SMS reminder scheduled successfully via MessageBird');
} catch (error) {
if (error.statusCode) throw error;
logger.error({ error: error.message, stack: error.stack, recipient: normalizedPhoneNumber }, 'Unexpected error scheduling reminder SMS');
throw fastify.httpErrors.internalServerError('An unexpected error occurred while scheduling the reminder SMS.');
}
// 4. Store Appointment in Databaselet savedAppointment;
try {
const appointmentData = {
name,
phoneNumber: normalizedPhoneNumber,
treatment,
appointmentAt: appointmentDateTimeUTC.toDate(),
reminderAt: reminderDateTimeUTC.toDate(),
messageBirdId: messageBirdResponse?.id,
};
savedAppointment = awaitcreateAppointment(prisma, logger, appointmentData);
} catch (dbError) {
logger.error({ error: dbError.message, stack: dbError.stack }, 'Database error saving appointment after scheduling SMS');
throw fastify.httpErrors.internalServerError('Appointment booked and reminder scheduled, but failed to save details. Contact support.');
}
// 5. Send Success Response
reply.code(201);
return {
message: 'Appointment booked and reminder scheduled successfully.',
appointmentId: savedAppointment.id,
appointmentAt: savedAppointment.appointmentAt,
reminderScheduledAt: savedAppointment.reminderAt,
messageBirdStatus: messageBirdResponse?.recipients?.items[0]?.status ?? 'unknown',
};
});
}
exportdefault bookingRoutes;
4. Error Handling & Logging Best Practices
Implement robust error handling and logging throughout your application.
Global Error Handler
Add a custom error handler to catch unhandled errors:
Use Node.js with the Fastify framework and the MessageBird API. This combination allows you to build a robust SMS reminder system by handling appointment bookings, validating phone numbers, scheduling messages, and managing data persistence. The article provides a step-by-step guide for setting up this system.
MessageBird is the SMS API used for sending text message reminders and validating phone numbers. Its Lookup API verifies phone number validity and format, while the Messages API handles scheduling and sending the actual SMS reminders. This integration ensures reliable communication with users.
Fastify is a high-performance web framework for Node.js. Chosen for its speed and efficiency, Fastify handles the API routes and server logic for the SMS reminder application. It provides a solid foundation for building production-ready applications.
Validate phone numbers upon appointment booking using MessageBird's Lookup API. This ensures the provided number is valid and correctly formatted before attempting to schedule a reminder, reducing potential errors and wasted messages. This step occurs before saving appointment details.
While the tutorial utilizes PostgreSQL and Prisma, you could adapt it to other databases. You would need to adjust the database connection setup and Prisma schema definitions, and potentially rewrite the database interaction logic in the appointmentService.js file, to match your chosen database system.
Create a .env file in the project's root directory and include your MESSAGEBIRD_API_KEY and MESSAGEBIRD_ORIGINATOR. These values are obtained from your MessageBird Dashboard under Developers > API access. Ensure this .env file is not committed to version control for security.
The project leverages Node.js with Fastify, MessageBird API, PostgreSQL database, and Prisma ORM. Additionally, it employs dayjs for time manipulation, dotenv and @fastify/env for environment variable management, @fastify/sensible for defaults and errors, and @fastify/rate-limit for security.
Initialize a Fastify instance, register necessary plugins (like @fastify/env, @fastify/sensible, and the custom Prisma plugin), define routes (including a health check), and configure the server to listen on specified port and host. Ensure your environment variables are validated before server startup.
Storing times in UTC (Coordinated Universal Time) avoids time zone ambiguity and simplifies time zone conversions. All appointment and reminder times are stored and processed using UTC for consistent and predictable behavior.
Prisma serves as an Object-Relational Mapper (ORM) for interacting with the PostgreSQL database. It simplifies database operations by mapping database tables to JavaScript objects and providing a type-safe API for querying and managing data. It creates, reads, updates, and deletes data from the database through its simplified API.
Implement robust error handling for MessageBird API calls and database operations within the booking route handler. Provide specific feedback to the user if phone number validation or SMS scheduling fails, and log errors for debugging. For database inconsistencies, attempt to cancel the scheduled SMS or implement background tasks for issue resolution.
Phone number normalization ensures consistent formatting (E.164) for reliable SMS delivery. The MessageBird Lookup API provides the normalized phone number, which is then used for scheduling reminders and storing in the database. This prevents issues caused by variations in input formats.
Learn how to build a production-ready SMS appointment reminder system using Node.js, Fastify, and MessageBird. This comprehensive tutorial covers project setup, phone number validation, SMS scheduling, database integration, error handling, security best practices, and deployment.
Missed appointments cost you time and money. SMS reminders reduce no-shows by providing timely nudges. This application lets users book appointments via a web form and automatically schedules SMS reminders through MessageBird before each appointment.
Important Platform Update (2025): MessageBird rebranded as Bird in February 2024. The legacy platform (dashboard.messagebird.com) shuts down March 31, 2025. For new implementations, use the Bird next-gen platform (app.bird.com), though the MessageBird API and Node.js SDK remain functional. See Bird's migration documentation for details.
Key Features
Technology Stack
dayjs: Library for date/time manipulationdotenv: Manages environment variables@fastify/env: Validates environment variables@fastify/sensible: Provides sensible defaults and HTTP errors@fastify/rate-limit: Rate limiting for security (v6.0.0+, replaces deprecatedfastify-rate-limit)System Architecture
Prerequisites
node -v. Install from nodejs.org.npm -v.What You'll Build
You'll create a functional Fastify application that accepts appointment bookings, validates phone numbers, schedules SMS reminders via MessageBird, and stores appointment data securely in a database.
1. Project Setup
Initialize your project, install dependencies, and set up the basic structure.
Create Project Directory
mkdir fastify-messagebird-reminders cd fastify-messagebird-remindersInitialize Node.js Project
Install Core Dependencies
What each dependency does:
fastify– Web framework (v5.x compatible with Node.js v20+)messagebird– Official Node.js SDK for MessageBird APIdotenv– Loads environment variables from.envfile@fastify/env– Validates required environment variables on startup@fastify/sensible– Adds HTTP errors (fastify.httpErrors)@fastify/rate-limit– Rate limiting plugin (v6.0.0+, replaces deprecatedfastify-rate-limitv5.x)dayjs– Date/time manipulationpino-pretty– Improves Fastify log readability during developmentprisma– Prisma CLI (v6.x, needed for generate/migrate)@prisma/client– Prisma database client (v6.x)Install Development Dependencies
@types/node– TypeScript definitions for Node.js (improves editor intellisense)nodemon– Automatically restarts server during development when files changeConfigure
package.jsonScriptsOpen
package.jsonand add thescriptssection:"scripts": { "start": "node src/server.js", "dev": "nodemon --watch src --exec 'node src/server.js'", "prisma:generate": "prisma generate", "prisma:migrate:dev": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy" }npm start– Runs the application for productionnpm run dev– Runs in development mode with auto-reloadingEnable ES Module Support
Node.js needs explicit configuration to treat
.jsfiles as ES Modules when usingimport/exportsyntax. Openpackage.jsonand add this top-level key:"type": "module"Alternatively, rename all
.jsfiles using ES Module syntax to.mjs, but setting"type": "module"is simpler for whole projects.Create Project Structure
Organize your code for maintainability:
mkdir src mkdir src/config mkdir src/routes mkdir src/services mkdir src/db mkdir prisma touch src/server.js touch src/config/envSchema.js touch src/config/messagebirdClient.js touch src/routes/bookingRoutes.js touch src/db/prismaPlugin.js touch src/db/appointmentService.js touch .env touch .env.example touch .gitignoreConfigure
.gitignorePrevent committing sensitive files and unnecessary folders:
Set Up Environment Variables
Define required environment variables.
.env.example(for documentation):.env(create locally, do not commit):Copy
.env.exampleto.envand fill in your actual credentials and database URL.Validate Environment Variables
Define the schema for expected environment variables.
// src/config/envSchema.js const envSchema = { type: 'object', required: [ 'PORT', 'HOST', 'MESSAGEBIRD_API_KEY', 'MESSAGEBIRD_ORIGINATOR', 'DEFAULT_COUNTRY_CODE', 'DATABASE_URL', ], properties: { PORT: { type: 'string', default: '3000' }, HOST: { type: 'string', default: '0.0.0.0' }, MESSAGEBIRD_API_KEY: { type: 'string' }, MESSAGEBIRD_ORIGINATOR: { type: 'string' }, DEFAULT_COUNTRY_CODE: { type: 'string', minLength: 2, maxLength: 2 }, DATABASE_URL: { type: 'string' }, }, }; export default envSchema;Why this approach?
@fastify/envfails fast if required configuration is missing, preventing runtime errors later.Set Up Basic Fastify Server
Initialize Fastify and register essential plugins.
// src/server.js import Fastify from 'fastify'; import sensible from '@fastify/sensible'; import rateLimit from '@fastify/rate-limit'; import fastifyEnv from '@fastify/env'; import envSchema from './config/envSchema.js'; import bookingRoutes from './routes/bookingRoutes.js'; import prismaPlugin from './db/prismaPlugin.js'; import { initializeMessageBirdClient } from './config/messagebirdClient.js'; // Configure logger based on environment const isProduction = process.env.NODE_ENV === 'production'; const loggerConfig = isProduction ? true : { transport: { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss Z', ignore: 'pid,hostname', }, }, }; const fastify = Fastify({ logger: loggerConfig }); async function buildServer() { // Register @fastify/env to validate environment variables await fastify.register(fastifyEnv, { dotenv: true, schema: envSchema, confKey: 'config', }); // Initialize MessageBird Client after config loads try { initializeMessageBirdClient(fastify.config.MESSAGEBIRD_API_KEY); fastify.log.info('MessageBird client initialized successfully.'); } catch (err) { fastify.log.error('Failed to initialize MessageBird client:', err); throw err; } // Register sensible plugin for utility decorators await fastify.register(sensible); // Register rate limiting (global configuration) await fastify.register(rateLimit, { max: 100, timeWindow: '15 minutes', skipOnError: false, cache: 10000, allowList: [], }); // Register Prisma plugin await fastify.register(prismaPlugin); // Register API routes await fastify.register(bookingRoutes, { prefix: '/api/v1' }); // Health check route fastify.get('/health', async (request, reply) => { try { await fastify.prisma.$queryRaw`SELECT 1`; return { status: 'ok', timestamp: new Date().toISOString(), checks: { database: 'ok' } }; } catch (error) { fastify.log.error('Health check failed:', error); reply.code(503); return { status: 'error', timestamp: new Date().toISOString(), message: 'Dependency check failed', error: error.message }; } }); return fastify; } async function start() { let server; try { server = await buildServer(); await server.listen({ port: server.config.PORT, host: server.config.HOST, }); } catch (err) { if (server && server.log) { server.log.error({ err }, 'Error starting server'); } else { console.error('Error starting server:', err); } process.exit(1); } } start();2. Database Setup with PostgreSQL and Prisma
Use Prisma to manage your database schema and interactions.
Initialize Prisma
This command creates a
prismadirectory with aschema.prismafile and configures the.envfile for the database connection string.Ensure your
DATABASE_URLin.envpoints correctly to your PostgreSQL instance.Define the Database Schema
Open
prisma/schema.prismaand define the model for storing appointments.Design decisions:
messageBirdIdreturned by MessageBird for tracking message status laterappointmentAtfor querying by appointment timephoneNumberif searching by phone number is commonCreate the Initial Migration
This command compares your schema to the database and generates the SQL needed to create the
Appointmenttable.Prisma will ask you to confirm. Review the generated SQL and proceed. This creates the table in your database and generates the Prisma Client based on your schema.
Create Prisma Plugin for Fastify
Integrate the Prisma Client instance into the Fastify application context.
// src/db/prismaPlugin.js import fp from 'fastify-plugin'; import { PrismaClient } from '@prisma/client'; async function prismaPlugin(fastify, options) { let prismaLogLevels = ['warn', 'error']; if (fastify.log && (fastify.log.level === 'debug' || fastify.log.level === 'trace')) { prismaLogLevels = ['query', 'info', 'warn', 'error']; } else if (fastify.log) { prismaLogLevels = ['info', 'warn', 'error']; } const prisma = new PrismaClient({ log: prismaLogLevels, }); try { await prisma.$connect(); fastify.log.info('Prisma client connected successfully.'); } catch (err) { fastify.log.error({ err }, 'Prisma client connection failed'); throw new Error('Failed to connect to database'); } fastify.decorate('prisma', prisma); fastify.addHook('onClose', async (instance) => { await instance.prisma.$disconnect(); instance.log.info('Prisma client disconnected.'); }); } export default fp(prismaPlugin);Why
fastify-plugin? It prevents Fastify's encapsulation, makingfastify.prismaavailable in all routes registered after this plugin.Create Appointment Service
Encapsulate database interaction logic.
// src/db/appointmentService.js import { Prisma } from '@prisma/client'; /** * Create a new appointment record in the database. */ export async function createAppointment(prisma, logger, appointmentData) { try { const appointment = await prisma.appointment.create({ data: appointmentData, }); logger.info({ appointmentId: appointment.id }, 'Appointment created in database'); return appointment; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { logger.error({ code: error.code, meta: error.meta, ...appointmentData }, 'Prisma error creating appointment'); } else { logger.error({ error, ...appointmentData }, 'Generic error creating appointment in DB'); } throw new Error('Failed to save appointment details to the database.'); } }3. Core Reminder Logic Implementation
Implement the logic within the booking route handler.
Initialize MessageBird Client
Create a dedicated file to initialize the SDK using the API key from environment variables.
// src/config/messagebirdClient.js import { messagebird } from 'messagebird'; let mbClient = null; /** * Initialize the MessageBird client instance (Singleton). * Call this once during application startup. */ export function initializeMessageBirdClient(apiKey) { if (!apiKey) { throw new Error('MessageBird API key is required for initialization.'); } if (mbClient) { console.warn('MessageBird client already initialized.'); return; } mbClient = messagebird(apiKey); } /** * Get the initialized MessageBird client instance. */ export function getMessageBirdClient() { if (!mbClient) { throw new Error('MessageBird client has not been initialized. Call initializeMessageBirdClient during server setup.'); } return mbClient; }Why a separate file? This promotes modularity and allows easy initialization (in
server.jsafter loading config) and retrieval wherever needed, enforcing the API key requirement and using a singleton pattern.Implement the Booking Route Handler
This involves validation, phone number lookup, date calculation, scheduling the SMS, and saving to the database.
// src/routes/bookingRoutes.js import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter.js'; import { getMessageBirdClient } from '../config/messagebirdClient.js'; import { createAppointment } from '../db/appointmentService.js'; dayjs.extend(utc); dayjs.extend(isSameOrAfter); const bookingBodySchema = { type: 'object', required: ['name', 'phoneNumber', 'treatment', 'appointmentDate', 'appointmentTime'], properties: { name: { type: 'string', minLength: 1, maxLength: 100 }, phoneNumber: { type: 'string', minLength: 5, maxLength: 20 }, treatment: { type: 'string', minLength: 3, maxLength: 100 }, appointmentDate: { type: 'string', format: 'date' }, appointmentTime: { type: 'string', pattern: '^([01]?[0-9]|2[0-3]):[0-5][0-9]$' }, }, additionalProperties: false, }; async function bookingRoutes(fastify, options) { const messagebird = getMessageBirdClient(); const config = fastify.config; const logger = fastify.log; const prisma = fastify.prisma; fastify.post('/book', { schema: { body: bookingBodySchema } }, async (request, reply) => { const { name, phoneNumber, treatment, appointmentDate, appointmentTime } = request.body; // 1. Validate Appointment Date/Time Logic const reminderBufferHours = 3; const minimumLeadTimeMinutes = 5; const dateTimeString = `${appointmentDate} ${appointmentTime}`; // Parse the combined string // CRITICAL: Time Zone Assumption // dayjs(dateTimeString) parses using the SERVER's local timezone // For global apps, COLLECT USER TIMEZONE and use dayjs-timezone const appointmentDateTimeLocal = dayjs(dateTimeString); if (!appointmentDateTimeLocal.isValid()) { throw fastify.httpErrors.badRequest('Invalid date or time format provided. Use YYYY-MM-DD and HH:MM.'); } // Convert to UTC for all calculations and storage const appointmentDateTimeUTC = appointmentDateTimeLocal.utc(); // Calculate the earliest allowed booking time in UTC const earliestPossibleBookingUTC = dayjs.utc().add(reminderBufferHours, 'hour').add(minimumLeadTimeMinutes, 'minute'); if (appointmentDateTimeUTC.isBefore(earliestPossibleBookingUTC)) { logger.warn({ requestedUTC: appointmentDateTimeUTC.toISOString(), earliestUTC: earliestPossibleBookingUTC.toISOString() }, 'Booking attempt too soon'); throw fastify.httpErrors.badRequest( `Appointment must be scheduled at least ${reminderBufferHours} hours and ${minimumLeadTimeMinutes} minutes from now.` ); } const reminderDateTimeUTC = appointmentDateTimeUTC.subtract(reminderBufferHours, 'hour'); // 2. Validate Phone Number via MessageBird Lookup let normalizedPhoneNumber; try { logger.info({ phoneNumber, country: config.DEFAULT_COUNTRY_CODE }, 'Attempting MessageBird Lookup'); const lookupResponse = await new Promise((resolve, reject) => { messagebird.lookup.read(phoneNumber, config.DEFAULT_COUNTRY_CODE, (err, response) => { if (err) { if (err.errors && err.errors[0].code === 21) { logger.warn({ err, phoneNumber }, 'MessageBird Lookup: Invalid number format'); return reject(fastify.httpErrors.badRequest(`The phone number '${phoneNumber}' appears invalid. Check the format and include a country code if necessary.`)); } logger.error({ err, phoneNumber }, 'MessageBird Lookup API error'); return reject(fastify.httpErrors.internalServerError('Could not validate the phone number at this time. Try again later.')); } resolve(response); }); }); if (lookupResponse.type !== 'mobile') { logger.warn({ number: phoneNumber, type: lookupResponse.type }, 'Non-mobile number provided for SMS reminder'); throw fastify.httpErrors.badRequest( `Phone number type '${lookupResponse.type}' is not suitable for SMS reminders. Provide a valid mobile number.` ); } normalizedPhoneNumber = lookupResponse.phoneNumber; logger.info({ original: phoneNumber, normalized: normalizedPhoneNumber, type: lookupResponse.type }, 'Phone number validated via MessageBird Lookup'); } catch (error) { if (error.statusCode) throw error; logger.error({ error: error.message, stack: error.stack, phoneNumber }, 'Unexpected error during phone number lookup'); throw fastify.httpErrors.internalServerError('An unexpected error occurred during phone number validation.'); } // 3. Schedule SMS Reminder via MessageBird let messageBirdResponse; try { const reminderBody = `Hi ${name}, this is a reminder for your ${treatment} appointment at ${appointmentDateTimeLocal.format('h:mm A')} today with ${config.MESSAGEBIRD_ORIGINATOR}. See you soon!`; const scheduledTimestampISO = reminderDateTimeUTC.toISOString(); logger.info({ recipient: normalizedPhoneNumber, scheduledAt: scheduledTimestampISO }, 'Attempting to schedule MessageBird SMS'); messageBirdResponse = await new Promise((resolve, reject) => { messagebird.messages.create({ originator: config.MESSAGEBIRD_ORIGINATOR, recipients: [normalizedPhoneNumber], scheduledDatetime: scheduledTimestampISO, body: reminderBody, }, (err, response) => { if (err) { logger.error({ err, recipient: normalizedPhoneNumber, originator: config.MESSAGEBIRD_ORIGINATOR }, 'MessageBird Messages API error during scheduling'); if (err.statusCode === 422) { return reject(fastify.httpErrors.badRequest('Failed to schedule SMS. There might be an issue with the sender ID configuration for this destination.')); } return reject(fastify.httpErrors.internalServerError('Failed to schedule the reminder SMS due to an API error. Try booking again later.')); } resolve(response); }); }); logger.info({ msgId: messageBirdResponse?.id, scheduledAt: scheduledTimestampISO, recipient: normalizedPhoneNumber, status: messageBirdResponse?.recipients?.items[0]?.status }, 'SMS reminder scheduled successfully via MessageBird'); } catch (error) { if (error.statusCode) throw error; logger.error({ error: error.message, stack: error.stack, recipient: normalizedPhoneNumber }, 'Unexpected error scheduling reminder SMS'); throw fastify.httpErrors.internalServerError('An unexpected error occurred while scheduling the reminder SMS.'); } // 4. Store Appointment in Database let savedAppointment; try { const appointmentData = { name, phoneNumber: normalizedPhoneNumber, treatment, appointmentAt: appointmentDateTimeUTC.toDate(), reminderAt: reminderDateTimeUTC.toDate(), messageBirdId: messageBirdResponse?.id, }; savedAppointment = await createAppointment(prisma, logger, appointmentData); } catch (dbError) { logger.error({ error: dbError.message, stack: dbError.stack }, 'Database error saving appointment after scheduling SMS'); throw fastify.httpErrors.internalServerError('Appointment booked and reminder scheduled, but failed to save details. Contact support.'); } // 5. Send Success Response reply.code(201); return { message: 'Appointment booked and reminder scheduled successfully.', appointmentId: savedAppointment.id, appointmentAt: savedAppointment.appointmentAt, reminderScheduledAt: savedAppointment.reminderAt, messageBirdStatus: messageBirdResponse?.recipients?.items[0]?.status ?? 'unknown', }; }); } export default bookingRoutes;4. Error Handling & Logging Best Practices
Implement robust error handling and logging throughout your application.
Global Error Handler
Add a custom error handler to catch unhandled errors:
// src/server.js (add after plugin registrations) fastify.setErrorHandler((error, request, reply) => { fastify.log.error({ err: error, reqId: request.id }, 'Unhandled error'); if (error.validation) { return reply.code(400).send({ error: 'Validation Error', message: 'Invalid request data', details: error.validation, }); } if (error.statusCode) { return reply.code(error.statusCode).send({ error: error.name, message: error.message, }); } return reply.code(500).send({ error: 'Internal Server Error', message: 'An unexpected error occurred. Please try again later.', }); });Structured Logging
Use Fastify's built-in Pino logger for structured logging:
// Log important events with context logger.info({ userId, action: 'booking_created' }, 'User booked appointment'); logger.warn({ phoneNumber, issue: 'invalid_format' }, 'Phone number validation failed'); logger.error({ err, context: 'messagebird_api' }, 'MessageBird API call failed');Request ID Tracking
Fastify automatically generates request IDs. Include them in logs:
fastify.addHook('onRequest', async (request, reply) => { request.log.info({ reqId: request.id }, 'Incoming request'); });5. Security & Rate Limiting
Secure your application against common threats.
Rate Limiting Configuration
Configure rate limiting per route or globally:
// Per-route rate limiting fastify.post('/book', { config: { rateLimit: { max: 10, timeWindow: '10 minutes', } }, schema: { body: bookingBodySchema } }, async (request, reply) => { // Route handler });Input Validation & Sanitization
Use Fastify's schema validation:
const bookingBodySchema = { type: 'object', required: ['name', 'phoneNumber', 'treatment', 'appointmentDate', 'appointmentTime'], properties: { name: { type: 'string', minLength: 1, maxLength: 100, pattern: '^[a-zA-Z\\s]+$' // Only letters and spaces }, phoneNumber: { type: 'string', minLength: 5, maxLength: 20, pattern: '^\\+?[1-9]\\d{4,19}$' // E.164 format }, treatment: { type: 'string', minLength: 3, maxLength: 100 }, appointmentDate: { type: 'string', format: 'date' }, appointmentTime: { type: 'string', pattern: '^([01]?[0-9]|2[0-3]):[0-5][0-9]$' }, }, additionalProperties: false, };Security Headers
Add security headers using
@fastify/helmet:// src/server.js import helmet from '@fastify/helmet'; await fastify.register(helmet, { contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'"], imgSrc: ["'self'", 'data:', 'https:'], }, }, });Environment-Specific Security
// src/server.js const isProduction = process.env.NODE_ENV === 'production'; await fastify.register(rateLimit, { max: isProduction ? 50 : 1000, timeWindow: '15 minutes', redis: isProduction ? new Redis(process.env.REDIS_URL) : undefined, });6. Production Deployment
Prepare your application for production deployment.
Environment Configuration
Create production environment variables:
Database Migration
Run migrations in production:
Process Management
Use PM2 for process management:
Create
ecosystem.config.cjs:module.exports = { apps: [{ name: 'fastify-messagebird-reminders', script: './src/server.js', instances: 'max', exec_mode: 'cluster', env_production: { NODE_ENV: 'production', }, error_file: './logs/err.log', out_file: './logs/out.log', log_date_format: 'YYYY-MM-DD HH:mm:ss Z', }], };Start the application:
pm2 start ecosystem.config.cjs --env production pm2 save pm2 startupDocker Deployment
Create
Dockerfile:Create
docker-compose.yml:version: '3.8' services: app: build: . ports: - "3000:3000" environment: NODE_ENV: production DATABASE_URL: postgresql://postgres:password@db:5432/reminders MESSAGEBIRD_API_KEY: ${MESSAGEBIRD_API_KEY} MESSAGEBIRD_ORIGINATOR: ${MESSAGEBIRD_ORIGINATOR} DEFAULT_COUNTRY_CODE: ${DEFAULT_COUNTRY_CODE} depends_on: - db db: image: postgres:16-alpine environment: POSTGRES_DB: reminders POSTGRES_USER: postgres POSTGRES_PASSWORD: password volumes: - postgres_data:/var/lib/postgresql/data volumes: postgres_data:Build and run:
Cloud Platform Deployment
Deploy to Heroku
heroku create your-app-name heroku addons:create heroku-postgresql:mini heroku config:set MESSAGEBIRD_API_KEY=your_api_key heroku config:set MESSAGEBIRD_ORIGINATOR=YourBrand heroku config:set DEFAULT_COUNTRY_CODE=US git push heroku main heroku run npm run prisma:deployDeploy to Railway
Deploy to Render
npm install && npx prisma generatenpm start7. Testing & Validation
Test your application to ensure reliability.
Manual Testing
Test the booking endpoint:
curl -X POST http://localhost:3000/api/v1/book \ -H "Content-Type: application/json" \ -d '{ "name": "John Doe", "phoneNumber": "+1234567890", "treatment": "Haircut", "appointmentDate": "2025-01-10", "appointmentTime": "14:30" }'Expected response:
{ "message": "Appointment booked and reminder scheduled successfully.", "appointmentId": "clx123abc...", "appointmentAt": "2025-01-10T14:30:00.000Z", "reminderScheduledAt": "2025-01-10T11:30:00.000Z", "messageBirdStatus": "scheduled" }Health Check
Test the health endpoint:
Expected response:
{ "status": "ok", "timestamp": "2025-01-05T12:00:00.000Z", "checks": { "database": "ok" } }Automated Testing
Install testing dependencies:
Create test file:
// test/routes/booking.test.js import { test } from 'tap'; import { buildServer } from '../../src/server.js'; test('POST /api/v1/book creates appointment', async (t) => { const server = await buildServer(); t.teardown(() => server.close()); const response = await server.inject({ method: 'POST', url: '/api/v1/book', payload: { name: 'Test User', phoneNumber: '+1234567890', treatment: 'Test Treatment', appointmentDate: '2025-01-15', appointmentTime: '10:00', }, }); t.equal(response.statusCode, 201); t.ok(response.json().appointmentId); });Run tests:
npm test8. Monitoring & Observability
Monitor your application in production.
Application Metrics
Use
@fastify/metricsfor Prometheus-compatible metrics:// src/server.js import metrics from '@fastify/metrics'; await fastify.register(metrics, { endpoint: '/metrics', defaultMetrics: { enabled: true }, routeMetrics: { enabled: true }, });Error Tracking
Integrate Sentry for error tracking:
// src/server.js import * as Sentry from '@sentry/node'; Sentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV, tracesSampleRate: 1.0, }); fastify.addHook('onError', async (request, reply, error) => { Sentry.captureException(error); });Logging Best Practices
Structure logs for easy querying:
logger.info({ event: 'appointment_booked', appointmentId: savedAppointment.id, phoneNumber: normalizedPhoneNumber, appointmentAt: appointmentDateTimeUTC.toISOString(), reminderAt: reminderDateTimeUTC.toISOString(), duration: Date.now() - startTime, }, 'Appointment booking completed');9. Advanced Features & Extensions
Extend your application with advanced features.
Time Zone Support
Install time zone plugin:
Update booking logic:
import timezone from 'dayjs/plugin/timezone.js'; dayjs.extend(timezone); // Add timezone to schema const bookingBodySchema = { properties: { // ... existing properties timezone: { type: 'string', default: 'America/New_York' }, }, }; // Use timezone in handler const appointmentDateTimeLocal = dayjs.tz(`${appointmentDate} ${appointmentTime}`, timezone);SMS Status Webhooks
Handle MessageBird delivery status callbacks:
// src/routes/webhookRoutes.js async function webhookRoutes(fastify, options) { fastify.post('/messagebird/status', async (request, reply) => { const { id, status, recipient } = request.body; fastify.log.info({ msgId: id, status, recipient }, 'Received MessageBird status update'); // Update appointment record await fastify.prisma.appointment.updateMany({ where: { messageBirdId: id }, data: { status, updatedAt: new Date() }, }); return { received: true }; }); } export default webhookRoutes;Cancellation & Rescheduling
Add cancellation endpoint:
fastify.delete('/book/:id', async (request, reply) => { const { id } = request.params; const appointment = await fastify.prisma.appointment.findUnique({ where: { id }, }); if (!appointment) { throw fastify.httpErrors.notFound('Appointment not found'); } // Cancel MessageBird scheduled message if (appointment.messageBirdId) { await new Promise((resolve, reject) => { messagebird.messages.delete(appointment.messageBirdId, (err) => { if (err) { fastify.log.error({ err, msgId: appointment.messageBirdId }, 'Failed to cancel MessageBird message'); return reject(err); } resolve(); }); }); } // Delete appointment await fastify.prisma.appointment.delete({ where: { id }, }); return { message: 'Appointment cancelled successfully' }; });Reminder Customization
Allow users to customize reminder timing:
const bookingBodySchema = { properties: { // ... existing properties reminderBufferHours: { type: 'number', minimum: 1, maximum: 48, default: 3 }, }, }; // Use in handler const reminderBufferHours = request.body.reminderBufferHours || 3; const reminderDateTimeUTC = appointmentDateTimeUTC.subtract(reminderBufferHours, 'hour');10. Troubleshooting Common Issues
Resolve common problems you might encounter.
MessageBird API Errors
Issue:
Authentication failederrorSolution: Verify your API key:
.envfile contains correctMESSAGEBIRD_API_KEYIssue:
Originator not allowederrorSolution: Check sender ID configuration:
MESSAGEBIRD_ORIGINATORis registered in MessageBird dashboardIssue:
Scheduled time in the pasterrorSolution: Verify time zone handling:
dateDatabase Connection Issues
Issue:
Connection refusederrorSolution: Check PostgreSQL connection:
# Test connection psql $DATABASE_URL # Verify PostgreSQL is running pg_isready -h localhost -p 5432Issue:
Too many connectionserrorSolution: Configure connection pooling:
// src/db/prismaPlugin.js const prisma = new PrismaClient({ log: prismaLogLevels, datasources: { db: { url: process.env.DATABASE_URL, }, }, // Connection pool configuration connectionLimit: 10, });Rate Limiting Issues
Issue: Getting rate limited during development
Solution: Add localhost to allowlist:
await fastify.register(rateLimit, { max: 100, timeWindow: '15 minutes', allowList: process.env.NODE_ENV === 'development' ? ['127.0.0.1'] : [], });Validation Errors
Issue: Date/time validation fails
Solution: Verify input format:
// Valid formats appointmentDate: "2025-01-10" // YYYY-MM-DD appointmentTime: "14:30" // HH:MM (24-hour) // Invalid formats appointmentDate: "01/10/2025" // Wrong date format appointmentTime: "2:30 PM" // Wrong time formatConclusion
You've built a production-ready SMS appointment reminder system with Node.js, Fastify, and MessageBird. This application includes:
Next Steps
Resources
Support
If you encounter issues:
Build great things! 🚀