code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / MessageBird

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 SMS Appointment Reminders with MessageBird and Node.js

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:

  1. Provides an API endpoint to create appointments.
  2. Validates appointment data, including phone numbers (using MessageBird Lookup).
  3. Persists appointment details in PostgreSQL with Prisma.
  4. Schedules an SMS reminder using MessageBird to send a configurable duration (e.g., 3 hours) before the appointment.
  5. 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.)
  • express-validator: Validate incoming request data.
  • express-rate-limit: Rate limiting to prevent abuse.
  • winston: Logging library.

System Architecture:

text
+-------------+       +-----------------+       +----------------------+       +-----------------+
| User/Client | ----> | Node.js/Express | ----> | MessageBird Lookup API | ----> | Validation Result|
| (API Call)  |       | (API Endpoint)  |       +----------------------+       +-----------------+
+-------------+       +-----------------+
                           |       ^
                           |       | (Store Appointment)
                           v       |
                      +----------+ | +-----------------------+       +-----------------+
                      |  Prisma  | | | MessageBird SMS API   | ----> | User's Phone    |
                      +----------+ | | (Schedule Reminder)   |       | (Receives SMS)  |
                           |       | +-----------------------+       +-----------------+
                           v       |
                     +-------------+
                     | PostgreSQL  |
                     |  Database   |
                     +-------------+

Data Flow:

  1. Client sends appointment data to Express API
  2. Express validates input format and phone number via MessageBird Lookup
  3. Prisma stores appointment in PostgreSQL
  4. Express calculates reminder time and schedules SMS via MessageBird
  5. MessageBird sends SMS at scheduled time
  6. Database tracks scheduling status and MessageBird message ID

Prerequisites:

  • Node.js and npm installed (Download Node.js)
  • PostgreSQL database (local or cloud)
  • MessageBird account (Sign up)
  • 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:

  1. Create Project Directory:

    bash
    mkdir node-messagebird-reminders
    cd node-messagebird-reminders
  2. Initialize Node.js Project:

    bash
    npm init -y
  3. Install Dependencies:

    bash
    npm install express messagebird dotenv moment-timezone express-validator express-rate-limit winston helmet @prisma/client
  4. Install Development Dependencies:

    bash
    npm install prisma --save-dev
  5. Initialize Prisma:

    bash
    npx prisma init --datasource-provider postgresql

    This creates:

    • prisma/schema.prisma for database schema
    • .env for environment variables
  6. Configure .env: Open .env and add your configuration:

    dotenv
    # Database Connection
    DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public"
    
    # MessageBird Credentials
    MESSAGEBIRD_API_KEY=YOUR_MESSAGEBIRD_API_KEY
    MESSAGEBIRD_ORIGINATOR=BeautyBird
    MESSAGEBIRD_LOOKUP_COUNTRY_CODE=US
    
    # Application Settings
    REMINDER_HOURS_BEFORE=3
    SERVER_PORT=8080
    LOG_LEVEL=info
    NODE_ENV=development

    Configuration Details:

    VariableDescriptionExample
    DATABASE_URLPostgreSQL connection stringpostgresql://user:pass@localhost:5432/mydb
    MESSAGEBIRD_API_KEYLive API key from MessageBird Dashboardlive_aBcDeFgHiJkLmNoPqRsTuVwXyZ
    MESSAGEBIRD_ORIGINATORSender ID (alphanumeric or phone number)BeautyBird or +12025550134
    MESSAGEBIRD_LOOKUP_COUNTRY_CODEDefault country for phone parsing (ISO 3166-1 alpha-2)US, GB, NL
    REMINDER_HOURS_BEFOREHours before appointment to send reminder3
    SERVER_PORTExpress server port8080
    LOG_LEVELWinston log levelerror, warn, info, debug
    NODE_ENVEnvironment modedevelopment, production
  7. Create Project Structure:

    bash
    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.js

    Structure 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.

  8. Create .gitignore:

    text
    # .gitignore
    node_modules
    .env
    dist
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*

2. Creating a Database Schema and Data Layer

Define your database schema and set up Prisma for database access.

Steps:

  1. Define Schema: Open prisma/schema.prisma:

    prisma
    // prisma/schema.prisma
    
    generator client {
      provider = "prisma-client-js"
    }
    
    datasource db {
      provider = "postgresql"
      url      = env("DATABASE_URL")
    }
    
    model Appointment {
      id             String   @id @default(cuid())
      customerName   String
      customerNumber String
      treatment      String
      appointmentAt  DateTime
      reminderSentAt DateTime?
      messagebirdId  String?
      createdAt      DateTime @default(now())
      updatedAt      DateTime @updatedAt
    
      @@index([appointmentAt])
    }

    Field Details:

    • id: Unique identifier (CUID format for distributed systems)
    • customerNumber: E.164 international format (+12025551234) from MessageBird Lookup
    • appointmentAt: UTC timestamp (avoids timezone ambiguity)
    • reminderSentAt: Timestamp when MessageBird successfully scheduled the SMS
    • messagebirdId: MessageBird message ID for tracking delivery status
    • @@index([appointmentAt]): Improves query performance for time-based lookups
  2. Apply Schema (Migration):

    bash
    npx prisma migrate dev --name init_appointment

    This command:

    • Creates SQL migration file in prisma/migrations
    • Applies migration to database
    • Generates Prisma Client

    If Migration Fails:

    • Verify DATABASE_URL in .env
    • Check PostgreSQL is running: psql -U USER -d DATABASE -c "SELECT 1;"
    • Review error message for connection or permission issues
    • Rollback if needed: Delete migration folder and fix schema errors

    Production Migration Strategy:

    • Test migrations in staging environment first
    • Backup database before applying migrations
    • Use npx prisma migrate deploy in production (no interactive prompts)
    • Monitor migration status with transaction logs
  3. Create Prisma Client Instance:

    javascript
    // 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:

  1. MessageBird Service:

    javascript
    // 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:

    CodeMeaningResolution
    2Missing parameterCheck required fields (originator, recipients, body)
    9Invalid originatorUse registered sender ID or valid phone number
    21Unknown phone formatProvide phone in international format (+country code)
    25Invalid API keyVerify MESSAGEBIRD_API_KEY in .env
    105Insufficient balanceAdd 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)
    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)));
            }
        }
    };
  2. Appointment Service:

    javascript
    // 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:

    • 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)
    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:

  1. Validation Middleware:

    javascript
    // 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:

    • 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 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);
  2. Appointment Controller:

    javascript
    // 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,
    };
  3. Appointment Routes:

    javascript
    // 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.

javascript
// 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:

bash
mkdir logs

6. Error Handler Utility

Create a centralized error handler.

javascript
// 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.

javascript
// 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.

javascript
// 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:

bash
node src/server.js

Create an Appointment (curl):

bash
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:

json
{
  "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:

  1. Create new request: POST http://localhost:8080/api/appointments

  2. Set header: Content-Type: application/json

  3. Add body (raw JSON):

    json
    {
      "customerName": "John Doe",
      "customerNumberInput": "+447700900123",
      "treatment": "Dental Cleaning",
      "appointmentDate": "2025-01-25",
      "appointmentTime": "10:00",
      "timezone": "Europe/London"
    }
  4. Send request and verify 201 status code

10. Troubleshooting Common Errors

ErrorCauseSolution
MESSAGEBIRD_API_KEY environment variable not setMissing .env configurationAdd MESSAGEBIRD_API_KEY to .env file
Database error creating appointmentPostgreSQL not running or wrong credentialsVerify DATABASE_URL and check psql connection
Phone number validation failed: Invalid formatPhone number not in international formatUse E.164 format: +[country][number]
Appointment must be at least 3 hours in the futureAppointment time too soonSchedule appointment further in future
Failed to schedule SMS via MessageBirdInsufficient balance or invalid originatorCheck MessageBird balance and MESSAGEBIRD_ORIGINATOR
Invalid timezone providedWrong timezone stringUse IANA format: America/New_York, not EST
Too many requestsRate limit exceededWait 15 minutes or adjust express-rate-limit settings

Debug Checklist:

  1. Check server logs in logs/error.log and logs/combined.log
  2. Verify all environment variables in .env
  3. Test database connection: npx prisma studio
  4. Validate MessageBird API key in Dashboard
  5. Check phone number format with MessageBird Lookup tool
  6. Review timezone list: moment.tz.names()

11. Deployment Considerations

Environment Variables:

Store production secrets securely:

  • Use AWS Secrets Manager, Azure Key Vault, or Vault by HashiCorp
  • Never commit .env to version control
  • Rotate MESSAGEBIRD_API_KEY regularly

Production Setup:

  1. Set NODE_ENV=production in production environment

  2. Use process manager: PM2 or Docker for automatic restarts

  3. Enable HTTPS: Use nginx or a cloud load balancer

  4. Database connection pooling: Prisma default pool size is 10; adjust in prisma/schema.prisma:

    prisma
    datasource db {
      provider = "postgresql"
      url      = env("DATABASE_URL")
      connection_limit = 20
    }
  5. Monitoring: Set up alerts for failed reminders, API errors, database issues

Deployment with PM2:

bash
npm install -g pm2
pm2 start src/server.js --name appointment-reminders
pm2 startup
pm2 save

Docker Deployment:

dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npx prisma generate
EXPOSE 8080
CMD ["node", "src/server.js"]

Build and run:

bash
docker build -t appointment-reminders .
docker run -d -p 8080:8080 --env-file .env appointment-reminders

12. Monitoring and Observability

Track SMS Delivery:

Query MessageBird API for message status:

javascript
messagebird.messages.read(messagebirdId, (err, response) => {
    console.log(response.status); // 'sent', 'delivered', 'failed'
});

Failed Reminder Dashboard:

Query appointments without reminders:

javascript
const failedReminders = await prisma.appointment.findMany({
    where: {
        reminderSentAt: null,
        appointmentAt: { gt: new Date() }
    }
});

Performance Metrics:

  • Track average API response time (<200ms ideal)
  • Monitor database query performance (use Prisma logging)
  • Set up alerts for error rate >5%
  • Monitor MessageBird balance and usage

Logging Strategy:

  • Error logs: All MessageBird failures, database errors
  • Info logs: Successful appointments, scheduled reminders
  • Warn logs: Phone validation failures, scheduling issues

13. Cost Analysis

MessageBird Pricing (Approximate):

ServiceCost per RequestMonthly Cost (1000 appointments)
Lookup API$0.005–0.01$5–10
SMS (US)$0.0075$7.50
SMS (UK)$0.04$40
SMS (Global avg)$0.02–0.10$20–100

Infrastructure Costs:

  • PostgreSQL (AWS RDS db.t3.micro): ~$15/month
  • Server (AWS EC2 t3.small): ~$15/month
  • Total estimated cost for 1000 appointments/month: $40–140

14. Performance Considerations

Capacity:

  • Current architecture handles ~100 appointments/minute
  • Database index on appointmentAt enables efficient queries
  • Consider sharding database for >1M appointments

Optimization:

  • Cache validated phone numbers (Redis) for 24 hours
  • Batch SMS scheduling for off-peak processing
  • Use database connection pooling
  • Implement read replicas for high-traffic scenarios

15. Testing Strategy

Unit Tests (with Jest):

javascript
// __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):

yaml
# 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.yml

Conclusion

You've built a production-ready SMS appointment reminder system with:

  • Phone validation using MessageBird Lookup
  • Scheduled SMS reminders via MessageBird API
  • PostgreSQL database with Prisma ORM
  • Comprehensive error handling and logging
  • Security measures (rate limiting, input validation)
  • Deployment configuration for production

Next Steps:

  • Add authentication (JWT or API keys)
  • Implement reminder retry queue (BullMQ, Redis)
  • Create admin dashboard for appointment management
  • Add SMS delivery status webhooks
  • Implement multi-language reminder templates
  • Set up monitoring with Datadog, New Relic, or Grafana

Resources: