code examples

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

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.

Build SMS Appointment Reminders with MessageBird, Node.js & Fastify

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
  • Persistent storage with PostgreSQL and Prisma
  • Robust error handling and logging
  • Production-grade security (rate limiting, input validation)
  • Deployment-ready configuration

Technology Stack

  • Node.js: Runtime environment (v20 LTS "Iron" or v22 LTS "Jod" recommended)
  • Fastify: High-performance web framework (v5.x)
  • 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

Initialize Node.js Project

bash
npm init -y

Install Core Dependencies

bash
npm install fastify messagebird dotenv @fastify/env @fastify/sensible @fastify/rate-limit dayjs pino-pretty prisma @prisma/client

What each dependency does:

  • fastify – Web framework (v5.x compatible with Node.js v20+)
  • messagebird – Official Node.js SDK for MessageBird API
  • dotenv – Loads environment variables from .env file
  • @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 deprecated fastify-rate-limit v5.x)
  • dayjs – Date/time manipulation
  • pino-pretty – Improves Fastify log readability during development
  • prisma – Prisma CLI (v6.x, needed for generate/migrate)
  • @prisma/client – Prisma database client (v6.x)

Install Development Dependencies

bash
npm install --save-dev @types/node nodemon
  • @types/node – TypeScript definitions for Node.js (improves editor intellisense)
  • nodemon – Automatically restarts server during development when files change

Configure package.json Scripts

Open package.json and add the scripts section:

json
"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 production
  • npm run dev – Runs in development mode with auto-reloading
  • Prisma scripts for convenience

Enable ES Module Support

Node.js needs explicit configuration to treat .js files as ES Modules when using import/export syntax. Open package.json and add this top-level key:

json
"type": "module"

Alternatively, rename all .js files using ES Module syntax to .mjs, but setting "type": "module" is simpler for whole projects.

Create Project Structure

Organize your code for maintainability:

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

Configure .gitignore

Prevent committing sensitive files and unnecessary folders:

text
node_modules
.env
dist
npm-debug.log*
yarn-debug.log*
yarn-error.log*
prisma/dev.db*

Set Up Environment Variables

Define required environment variables.

.env.example (for documentation):

dotenv
# 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.

javascript
// 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/env fails fast if required configuration is missing, preventing runtime errors later.

Set Up Basic Fastify Server

Initialize Fastify and register essential plugins.

javascript
// 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 prisma directory with a schema.prisma file and configures the .env file for the database connection string.

bash
npx prisma init --datasource-provider postgresql

Ensure your DATABASE_URL in .env points correctly to your PostgreSQL instance.

Define the Database Schema

Open prisma/schema.prisma and define the model for storing appointments.

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())
  name           String
  phoneNumber    String
  treatment      String
  appointmentAt  DateTime
  reminderAt     DateTime
  messageBirdId  String?
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  @@index([appointmentAt])
  @@index([phoneNumber])
}

Design decisions:

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

javascript
// 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, making fastify.prisma available in all routes registered after this plugin.

Create Appointment Service

Encapsulate database interaction logic.

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

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

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

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

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

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

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

bash
npm install @fastify/helmet
javascript
// 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

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

dotenv
# .env.production
NODE_ENV=production
PORT=3000
HOST=0.0.0.0

MESSAGEBIRD_API_KEY=your_production_api_key
MESSAGEBIRD_ORIGINATOR=YourBrand
DEFAULT_COUNTRY_CODE=US

DATABASE_URL="postgresql://user:password@production-host:5432/database?schema=public&sslmode=require"

# Optional: Redis for distributed rate limiting
REDIS_URL=redis://redis-host:6379

Database Migration

Run migrations in production:

bash
npm run prisma:deploy

Process Management

Use PM2 for process management:

bash
npm install -g pm2

Create ecosystem.config.cjs:

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

bash
pm2 start ecosystem.config.cjs --env production
pm2 save
pm2 startup

Docker Deployment

Create Dockerfile:

dockerfile
FROM node:22-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

RUN npx prisma generate

EXPOSE 3000

CMD ["node", "src/server.js"]

Create docker-compose.yml:

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

bash
docker-compose up -d

Cloud Platform Deployment

Deploy to Heroku

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

Deploy to Railway

  1. Connect your GitHub repository
  2. Add environment variables in Railway dashboard
  3. Add PostgreSQL database plugin
  4. Deploy automatically on push

Deploy to Render

  1. Connect your GitHub repository
  2. Set build command: npm install && npx prisma generate
  3. Set start command: npm start
  4. Add environment variables
  5. Deploy

7. Testing & Validation

Test your application to ensure reliability.

Manual Testing

Test the booking endpoint:

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

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

bash
curl http://localhost:3000/health

Expected response:

json
{
  "status": "ok",
  "timestamp": "2025-01-05T12:00:00.000Z",
  "checks": {
    "database": "ok"
  }
}

Automated Testing

Install testing dependencies:

bash
npm install --save-dev tap @fastify/autoload

Create test file:

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

bash
npm test

8. Monitoring & Observability

Monitor your application in production.

Application Metrics

Use @fastify/metrics for Prometheus-compatible metrics:

bash
npm install @fastify/metrics
javascript
// 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:

bash
npm install @sentry/node
javascript
// 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:

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

bash
npm install dayjs-timezone

Update booking logic:

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

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

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

javascript
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 failed error

Solution: Verify your API key:

  • Check .env file contains correct MESSAGEBIRD_API_KEY
  • Ensure no extra spaces or quotes
  • Verify key is from correct environment (test vs. live)

Issue: Originator not allowed error

Solution: Check sender ID configuration:

  • Verify MESSAGEBIRD_ORIGINATOR is registered in MessageBird dashboard
  • Some countries require pre-registered sender IDs
  • Use a purchased phone number for US/Canada

Issue: Scheduled time in the past error

Solution: Verify time zone handling:

  • Ensure server time is correct: date
  • Check appointment time is in future
  • Verify UTC conversion logic

Database Connection Issues

Issue: Connection refused error

Solution: Check PostgreSQL connection:

bash
# Test connection
psql $DATABASE_URL

# Verify PostgreSQL is running
pg_isready -h localhost -p 5432

Issue: Too many connections error

Solution: Configure connection pooling:

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

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

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

Conclusion

You've built a production-ready SMS appointment reminder system with Node.js, Fastify, and MessageBird. This application includes:

  • ✅ Phone number validation
  • ✅ Automated SMS scheduling
  • ✅ Database persistence
  • ✅ Error handling
  • ✅ Security features
  • ✅ Production deployment

Next Steps

  1. Add authentication – Secure endpoints with JWT or OAuth
  2. Implement background jobs – Use Bull or BeeQueue for retry logic
  3. Add email notifications – Combine SMS with email reminders
  4. Build admin dashboard – View and manage appointments
  5. Add analytics – Track booking and reminder metrics

Resources

Support

If you encounter issues:

Build great things! 🚀