code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

MessageBird SMS Appointment Reminders with Next.js: Complete Tutorial 2025

Build automated SMS appointment reminders using MessageBird API, Next.js 14, and Prisma. Step-by-step guide with scheduling, webhooks, and production deployment for Node.js applications.

MessageBird + Node.js + Next.js: Build Scheduled SMS Appointment Reminders (Complete Tutorial)

Overview: Building an Appointment Reminder System with MessageBird SMS API

You'll build a complete appointment reminder system that sends automated SMS notifications using MessageBird's SMS API, Next.js 14, and Prisma ORM. This comprehensive tutorial takes you from initial setup through production deployment, teaching you how to schedule SMS messages, manage appointments in a PostgreSQL database, and handle delivery webhooks for real-time status tracking.

What You'll Build:

  • Full-stack Next.js 14 application with App Router and TypeScript
  • Appointment booking system with PostgreSQL database and Prisma ORM
  • Automated SMS reminders sent 24 hours before appointments using MessageBird
  • Webhook integration for delivery status tracking and failure handling
  • Production-ready error handling with exponential backoff retry logic

Prerequisites:

  • Node.js 18+ installed on your system
  • MessageBird account with API key (get one at messagebird.com)
  • Basic knowledge of React, TypeScript, and Next.js
  • PostgreSQL database (local or hosted on Supabase/Vercel)

Why MessageBird for Appointment Reminders?

MessageBird stands out for scheduled messaging:

  • Global SMS Reach: Send messages to 200+ countries with reliable delivery rates
  • Scheduled Messaging: Schedule SMS messages up to 30 days in advance using RFC3339 timestamps
  • Delivery Tracking: Track message status through webhooks and delivery reports
  • Developer Experience: Clean REST API with official Node.js SDK and comprehensive documentation
  • Rate Limits: Handle 500 requests per second for POST operations and 50 req/s for GET operations
  • Lookup API Integration: Verify phone numbers before sending to reduce costs and improve delivery

Section 1: Project Setup and MessageBird Configuration

Step 1: Create Your Next.js Project

Create a new Next.js 14 project with TypeScript and App Router:

bash
npx create-next-app@latest messagebird-reminders
cd messagebird-reminders

Select these options during setup:

  • TypeScript: Yes
  • ESLint: Yes
  • Tailwind CSS: Yes
  • src/ directory: No
  • App Router: Yes
  • Import alias: Default (@/*)

Step 2: Install Required Dependencies

Install MessageBird SDK, Prisma ORM, and utility libraries:

bash
npm install messagebird prisma @prisma/client moment-timezone
npm install -D @types/node tsx

Package breakdown:

  • messagebird: Official Node.js SDK for MessageBird API (requires Node.js >= 0.10)
  • prisma and @prisma/client: Type-safe database ORM
  • moment-timezone: Handle timezone conversions for scheduled messages
  • tsx: Run TypeScript files directly during development

Step 3: Configure Environment Variables

Create .env.local in your project root:

bash
# MessageBird Configuration
MESSAGEBIRD_API_KEY=your_messagebird_live_api_key_here
MESSAGEBIRD_ORIGINATOR=YourBrand  # Sender ID (max 11 alphanumeric chars)

# Database Configuration
DATABASE_URL="postgresql://user:password@localhost:5432/appointments?schema=public"

# Application Configuration
NEXT_PUBLIC_APP_URL=http://localhost:3000
WEBHOOK_SECRET=your_webhook_secret_here

Sender ID Configuration:

The MESSAGEBIRD_ORIGINATOR (Sender ID) can be either a phone number or alphanumeric string (max 11 characters). Alphanumeric Sender IDs are not supported in the US and Canada—you must use a purchased phone number instead. Many countries require pre-registration of alphanumeric Sender IDs before use:

  • Pre-registration required: United Arab Emirates (promotional messages need "AD-" prefix), India, Saudi Arabia, Turkey, Singapore, and others. Check MessageBird's country restrictions documentation for your target countries.
  • No registration needed: Most European countries (UK, Germany, Netherlands, France), Australia, and many others allow instant provisioning.
  • Sender ID format: Alphanumeric IDs support upper/lowercase ASCII letters, digits 0-9, and spaces (11 characters max). Examples: "YourBrand", "Acme Corp", "Shop24".

Get your MessageBird API key:

  1. Sign up at messagebird.com
  2. Navigate to Developers → API Keys
  3. Copy your Live API key (starts with live_)
  4. Use Test API key for development (starts with test_)

Important: Never commit .env.local to version control. Add it to .gitignore.

Section 2: Database Schema with Prisma

Step 1: Initialize Prisma

Set up Prisma in your project:

bash
npx prisma init

This creates prisma/schema.prisma and adds DATABASE_URL to your .env file.

Step 2: Define Your Appointment Schema

Open prisma/schema.prisma and replace the contents:

prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Appointment {
  id              String   @id @default(cuid())
  customerName    String
  customerPhone   String   // E.164 format: +1234567890
  appointmentDate DateTime
  serviceType     String?
  reminderStatus  ReminderStatus @default(PENDING)
  messageBirdId   String?  @unique // MessageBird message ID for tracking
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  @@index([appointmentDate])
  @@index([reminderStatus])
}

enum ReminderStatus {
  PENDING
  SENT
  DELIVERED
  FAILED
  CANCELED
}

Schema features:

  • customerPhone stores E.164 formatted numbers (+1234567890)
  • appointmentDate uses ISO 8601 datetime format
  • messageBirdId tracks the MessageBird message ID for status updates
  • Indexes on appointmentDate and reminderStatus optimize queries
  • ReminderStatus enum tracks the full lifecycle of reminder messages

Step 3: Run Database Migrations

Create and apply your database schema:

bash
npx prisma migrate dev --name init
npx prisma generate

This creates your PostgreSQL tables and generates the Prisma Client with full TypeScript types.

Section 3: MessageBird SMS Service Implementation

Step 1: Create the MessageBird Service

Create lib/messagebird.ts:

typescript
import messagebird from 'messagebird';

// Initialize MessageBird client
const client = messagebird(process.env.MESSAGEBIRD_API_KEY!);

export interface SendSMSParams {
  to: string;
  body: string;
  scheduledDatetime?: Date;
  reference?: string;
}

export interface SMSResponse {
  id: string;
  recipients: {
    recipient: string;
    status: string;
  }[];
}

/**
 * Send SMS via MessageBird API
 * @param params - SMS parameters
 * @returns Promise with MessageBird response
 */
export async function sendSMS(params: SendSMSParams): Promise<SMSResponse> {
  return new Promise((resolve, reject) => {
    const messageParams: any = {
      originator: process.env.MESSAGEBIRD_ORIGINATOR!,
      recipients: [params.to],
      body: params.body,
    };

    // Add scheduled datetime in RFC3339 format if provided
    if (params.scheduledDatetime) {
      messageParams.scheduledDatetime = params.scheduledDatetime.toISOString();
    }

    // Add reference for webhook tracking
    if (params.reference) {
      messageParams.reference = params.reference;
    }

    client.messages.create(messageParams, (err, response) => {
      if (err) {
        reject(new Error(`MessageBird API Error: ${err.errors?.[0]?.description || err.message}`));
      } else {
        resolve({
          id: response.id,
          recipients: response.recipients.items.map((item: any) => ({
            recipient: item.recipient.toString(),
            status: item.status,
          })),
        });
      }
    });
  });
}

/**
 * Send scheduled reminder SMS
 * @param phone - E.164 formatted phone number
 * @param name - Customer name
 * @param appointmentDate - Appointment datetime
 * @param appointmentId - Database appointment ID for reference
 * @returns Promise with MessageBird message ID
 */
export async function sendAppointmentReminder(
  phone: string,
  name: string,
  appointmentDate: Date,
  appointmentId: string
): Promise<string> {
  const formattedDate = appointmentDate.toLocaleString('en-US', {
    weekday: 'long',
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: 'numeric',
    minute: '2-digit',
    timeZone: 'America/New_York', // Adjust based on customer timezone
  });

  const message = `Hi ${name}, this is a reminder about your appointment on ${formattedDate}. Reply CANCEL to cancel.`;

  // Calculate reminder time (24 hours before appointment)
  const reminderTime = new Date(appointmentDate.getTime() - 24 * 60 * 60 * 1000);

  const response = await sendSMS({
    to: phone,
    body: message,
    scheduledDatetime: reminderTime,
    reference: appointmentId,
  });

  return response.id;
}

Key implementation details:

1. Scheduled Message Format: MessageBird requires RFC3339 format for scheduledDatetime:

typescript
// Correct: 2024-01-15T10:30:00Z (JavaScript .toISOString())
// Incorrect: 2024-01-15 10:30:00

2. Rate Limiting: MessageBird enforces rate limits: 500 requests per second for POST operations (message creation) and 50 req/s for GET operations. Implement exponential backoff for production systems.

3. Phone Number Format: Always use E.164 format: +[country code][number]

typescript
// Correct: +14155552671
// Incorrect: (415) 555-2671, 415-555-2671

4. Recipient Limits: Maximum 50 recipients per request. For bulk sending, batch your requests.

Step 2: Implement Retry Logic with Exponential Backoff

Add retry functionality for failed API calls in lib/messagebird.ts:

typescript
/**
 * Retry wrapper with exponential backoff
 * @param fn - Async function to retry
 * @param maxRetries - Maximum retry attempts (default: 3)
 * @param initialDelay - Initial delay in ms (default: 1000)
 * @returns Promise with function result
 */
export async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3,
  initialDelay: number = 1000
): Promise<T> {
  let lastError: Error;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;

      // Don't retry on client errors (4xx) - only network/server errors
      if (error instanceof Error && error.message.includes('4')) {
        throw error;
      }

      if (attempt < maxRetries - 1) {
        const delay = initialDelay * Math.pow(2, attempt);
        console.log(`Retry attempt ${attempt + 1} after ${delay}ms...`);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }

  throw lastError!;
}

/**
 * Send SMS with automatic retry
 */
export async function sendSMSWithRetry(params: SendSMSParams): Promise<SMSResponse> {
  return withRetry(() => sendSMS(params));
}

Retry strategy:

  • Initial delay: 1 second
  • Maximum retries: 3 attempts
  • Exponential backoff: Doubles delay each retry (1s, 2s, 4s)
  • Only retries on network errors and 5xx status codes
  • Logs all retry attempts for debugging

Section 4: Appointment API Routes

Step 1: Create Appointment Booking Endpoint

Create app/api/appointments/route.ts:

typescript
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { sendAppointmentReminder } from '@/lib/messagebird';

const prisma = new PrismaClient();

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const { customerName, customerPhone, appointmentDate, serviceType } = body;

    // Validation
    if (!customerName || customerName.length < 2) {
      return NextResponse.json(
        { error: 'Customer name must be at least 2 characters' },
        { status: 400 }
      );
    }

    // Validate E.164 phone format
    const phoneRegex = /^\+[1-9]\d{1,14}$/;
    if (!phoneRegex.test(customerPhone)) {
      return NextResponse.json(
        { error: 'Phone number must be in E.164 format (+1234567890)' },
        { status: 400 }
      );
    }

    // Parse and validate appointment date
    const apptDate = new Date(appointmentDate);
    if (isNaN(apptDate.getTime())) {
      return NextResponse.json(
        { error: 'Invalid appointment date format. Use ISO 8601.' },
        { status: 400 }
      );
    }

    // Ensure appointment is at least 1 hour in the future
    const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000);
    if (apptDate < oneHourFromNow) {
      return NextResponse.json(
        { error: 'Appointment must be at least 1 hour in the future' },
        { status: 400 }
      );
    }

    // Create appointment in database
    const appointment = await prisma.appointment.create({
      data: {
        customerName,
        customerPhone,
        appointmentDate: apptDate,
        serviceType: serviceType || null,
      },
    });

    // Schedule SMS reminder via MessageBird
    try {
      const messageBirdId = await sendAppointmentReminder(
        customerPhone,
        customerName,
        apptDate,
        appointment.id
      );

      // Update appointment with MessageBird message ID
      await prisma.appointment.update({
        where: { id: appointment.id },
        data: {
          messageBirdId,
          reminderStatus: 'SENT'
        },
      });

      return NextResponse.json(
        {
          success: true,
          appointment: { ...appointment, messageBirdId },
          message: 'Appointment created and reminder scheduled'
        },
        { status: 201 }
      );
    } catch (smsError) {
      // Appointment created but SMS failed
      console.error('SMS scheduling failed:', smsError);
      return NextResponse.json(
        {
          success: true,
          appointment,
          warning: 'Appointment created but reminder scheduling failed'
        },
        { status: 201 }
      );
    }
  } catch (error) {
    console.error('Appointment creation error:', error);
    return NextResponse.json(
      { error: 'Failed to create appointment' },
      { status: 500 }
    );
  }
}

export async function GET(req: NextRequest) {
  try {
    const appointments = await prisma.appointment.findMany({
      orderBy: { appointmentDate: 'asc' },
      where: {
        appointmentDate: {
          gte: new Date(), // Only future appointments
        },
      },
    });

    return NextResponse.json({ appointments });
  } catch (error) {
    console.error('Error fetching appointments:', error);
    return NextResponse.json(
      { error: 'Failed to fetch appointments' },
      { status: 500 }
    );
  }
}

Validation rules:

  • Phone number must start with + and contain 10–15 digits
  • Appointment date must be in ISO 8601 format
  • Customer name required (minimum 2 characters)
  • Appointment must be at least 1 hour in the future

Response codes:

  • 201: Appointment created successfully
  • 400: Validation error or invalid input
  • 500: Server error (database or MessageBird API failure)

Step 2: Implement Appointment Cancellation

Create app/api/appointments/[id]/route.ts:

typescript
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export async function DELETE(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const { id } = params;

    // Mark appointment as canceled (MessageBird doesn't support API cancellation)
    const appointment = await prisma.appointment.update({
      where: { id },
      data: { reminderStatus: 'CANCELED' },
    });

    return NextResponse.json({
      success: true,
      message: 'Appointment canceled. Reminder will not be sent.',
      appointment,
    });
  } catch (error) {
    console.error('Cancellation error:', error);
    return NextResponse.json(
      { error: 'Failed to cancel appointment' },
      { status: 500 }
    );
  }
}

export async function GET(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const { id } = params;

    const appointment = await prisma.appointment.findUnique({
      where: { id },
    });

    if (!appointment) {
      return NextResponse.json(
        { error: 'Appointment not found' },
        { status: 404 }
      );
    }

    return NextResponse.json({ appointment });
  } catch (error) {
    console.error('Error fetching appointment:', error);
    return NextResponse.json(
      { error: 'Failed to fetch appointment' },
      { status: 500 }
    );
  }
}

Note: MessageBird doesn't support canceling scheduled messages via API. Mark appointments as canceled in your database and filter them when processing reminders.

Section 5: Scheduled Job for Sending Reminders

Step 1: Create Reminder Scheduler

Create lib/scheduler.ts:

typescript
import { PrismaClient } from '@prisma/client';
import { sendAppointmentReminder } from './messagebird';

const prisma = new PrismaClient();

export async function sendScheduledReminders() {
  const now = new Date();
  const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);

  // Find appointments 24 hours from now that need reminders
  const appointments = await prisma.appointment.findMany({
    where: {
      appointmentDate: {
        gte: tomorrow,
        lte: new Date(tomorrow.getTime() + 60 * 60 * 1000), // 1 hour window
      },
      reminderStatus: 'PENDING',
    },
  });

  console.log(`Found ${appointments.length} appointments needing reminders`);

  const results = {
    success: 0,
    failed: 0,
    errors: [] as string[],
  };

  for (const appointment of appointments) {
    try {
      const messageBirdId = await sendAppointmentReminder(
        appointment.customerPhone,
        appointment.customerName,
        appointment.appointmentDate,
        appointment.id
      );

      await prisma.appointment.update({
        where: { id: appointment.id },
        data: {
          messageBirdId,
          reminderStatus: 'SENT',
        },
      });

      results.success++;
      console.log(`✓ Reminder sent for appointment ${appointment.id}`);
    } catch (error) {
      results.failed++;
      const errorMsg = `Failed to send reminder for ${appointment.id}: ${error}`;
      results.errors.push(errorMsg);
      console.error('✗', errorMsg);

      await prisma.appointment.update({
        where: { id: appointment.id },
        data: { reminderStatus: 'FAILED' },
      });
    }
  }

  return results;
}

Scheduler logic:

  1. Query database for appointments 24 hours in the future
  2. Filter out canceled appointments and already-sent reminders
  3. Send SMS via MessageBird with scheduled delivery time
  4. Update database with MessageBird message ID
  5. Log all operations for monitoring

Step 2: Set Up Cron Job

Option A: Vercel Cron (Production)

Create app/api/cron/send-reminders/route.ts:

typescript
import { NextRequest, NextResponse } from 'next/server';
import { sendScheduledReminders } from '@/lib/scheduler';

export async function GET(req: NextRequest) {
  // Verify Vercel Cron secret
  const authHeader = req.headers.get('authorization');
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  try {
    const result = await sendScheduledReminders();
    return NextResponse.json(result);
  } catch (error) {
    console.error('Cron job error:', error);
    return NextResponse.json(
      { error: 'Cron job failed' },
      { status: 500 }
    );
  }
}

Vercel Cron Limitations:

  • Maximum 20 cron jobs per project on all plans
  • Duration limits match Vercel Function limits (10s Hobby, 15s Pro, 60s Enterprise by default, up to 900s with configuration)
  • Runs only on production deployments (not preview deployments)
  • Timezone is always UTC—adjust your cron schedule accordingly
  • Requires CRON_SECRET environment variable for authentication

Add to vercel.json:

json
{
  "crons": [{
    "path": "/api/cron/send-reminders",
    "schedule": "0 * * * *"
  }]
}

Option B: Node-Cron (Development)

Install node-cron:

bash
npm install node-cron

Create lib/cron.ts:

typescript
import cron from 'node-cron';
import { sendScheduledReminders } from './scheduler';

// Run every hour
export function startCronJobs() {
  cron.schedule('0 * * * *', async () => {
    console.log('Running scheduled reminder job...');
    await sendScheduledReminders();
  });

  console.log('Cron jobs started');
}

Why node-cron doesn't work on Vercel: Vercel uses serverless functions that spin up on-demand and shut down after handling requests. Node-cron requires a persistent Node.js process to run scheduled tasks, which is incompatible with Vercel's stateless, ephemeral execution model. For production on Vercel, always use Vercel Cron Jobs configured via vercel.json.

Add to app/layout.tsx:

typescript
import { startCronJobs } from '@/lib/cron';

// Only run in development
if (process.env.NODE_ENV === 'development') {
  startCronJobs();
}

Section 6: Webhook Integration for Delivery Status

Create Webhook Handler

Create app/api/webhooks/messagebird/route.ts:

typescript
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import crypto from 'crypto';

const prisma = new PrismaClient();

/**
 * Verify webhook signature (if configured in MessageBird dashboard)
 */
function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const hmac = crypto.createHmac('sha256', secret);
  const digest = hmac.update(payload).digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(digest)
  );
}

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);

  // MessageBird sends status via query parameters
  const messageBirdId = searchParams.get('id');
  const status = searchParams.get('status'); // delivered, buffered, sent, expired, delivery_failed
  const reference = searchParams.get('reference'); // appointment ID
  const statusReason = searchParams.get('statusReason');

  if (!messageBirdId) {
    return NextResponse.json({ error: 'Missing message ID' }, { status: 400 });
  }

  console.log(`Webhook received: ${messageBirdId} - ${status} (${statusReason})`);

  try {
    // Map MessageBird status to our ReminderStatus enum
    let reminderStatus: 'SENT' | 'DELIVERED' | 'FAILED' = 'SENT';

    if (status === 'delivered') {
      reminderStatus = 'DELIVERED';
    } else if (status === 'delivery_failed' || status === 'expired') {
      reminderStatus = 'FAILED';
    } else if (status === 'sent' || status === 'buffered') {
      reminderStatus = 'SENT';
    }

    // Update appointment status
    await prisma.appointment.updateMany({
      where: { messageBirdId },
      data: { reminderStatus },
    });

    return NextResponse.json({
      success: true,
      message: `Status updated to ${reminderStatus}`
    });
  } catch (error) {
    console.error('Webhook processing error:', error);
    return NextResponse.json(
      { error: 'Failed to process webhook' },
      { status: 500 }
    );
  }
}

MessageBird SMS Status Values: According to MessageBird's API documentation, the possible status values are:

  • scheduled: Message is scheduled to be sent at a future time
  • sent: Message has been sent to downstream carrier, pending delivery report (DLR)
  • buffered: Message is queued for delivery, recipient temporarily unavailable
  • delivered: Message successfully delivered to recipient
  • expired: Message could not be delivered before validity period expired
  • delivery_failed: Message delivery failed (see statusReason for details)

Common Status Reasons:

  • successfully delivered: Message delivered successfully
  • pending DLR: Awaiting delivery report from carrier
  • unknown subscriber: Invalid/non-existent phone number (permanent failure)
  • unavailable subscriber: Recipient temporarily unavailable (temporary failure)
  • carrier rejected: Carrier blocked message (check sender ID registration)
  • capacity limit reached: Carrier rate limit exceeded (e.g., USA 10DLC throttling)

Configure webhook URL in MessageBird dashboard:

https://yourdomain.com/api/webhooks/messagebird?id=$id&status=$status&statusReason=$statusReason

If signature verification fails, MessageBird will retry delivery up to 10 times with exponential backoff.

Section 7: Testing Your Application

1. Test MessageBird Integration

MessageBird provides a TEST API key that simulates message sending without delivering real SMS messages:

bash
# Add to .env.local for testing
MESSAGEBIRD_API_KEY=test_your_test_key_here

Test Mode Behavior:

  • Returns successful API responses (status code 200-201)
  • Generates realistic message IDs
  • Does not send actual SMS messages
  • Does not charge your account
  • Does not trigger webhooks
  • Status will always show as "sent" immediately

To validate your integration, verify the API response structure matches production and that your database records are created correctly.

2. Verify Database Operations

Check your appointments table using Prisma Studio:

bash
npx prisma studio

This opens a web interface at http://localhost:5555 where you inspect database records, verify scheduled messages, and check reminder status.

3. Integration Testing

Install Jest and testing dependencies:

bash
npm install -D jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom

Create __tests__/appointments.test.ts:

typescript
import { POST } from '@/app/api/appointments/route';

describe('Appointment API', () => {
  it('creates appointment with valid data', async () => {
    const mockRequest = {
      json: async () => ({
        customerName: 'John Doe',
        customerPhone: '+14155552671',
        appointmentDate: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
        serviceType: 'Haircut'
      })
    } as any;

    const response = await POST(mockRequest);
    expect(response.status).toBe(201);
  });

  it('rejects invalid phone format', async () => {
    const mockRequest = {
      json: async () => ({
        customerName: 'John Doe',
        customerPhone: '415-555-2671', // Invalid format
        appointmentDate: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
      })
    } as any;

    const response = await POST(mockRequest);
    expect(response.status).toBe(400);
  });

  it('rejects appointments less than 1 hour away', async () => {
    const mockRequest = {
      json: async () => ({
        customerName: 'John Doe',
        customerPhone: '+14155552671',
        appointmentDate: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30 minutes
      })
    } as any;

    const response = await POST(mockRequest);
    expect(response.status).toBe(400);
  });
});

Run tests:

bash
npm test

4. Test Webhook Delivery

Use ngrok to expose your local server for webhook testing:

bash
npx ngrok http 3000

Copy the ngrok URL and configure it in MessageBird dashboard under Webhooks.

Section 8: Deployment Considerations

1. Environment Variables

Set these variables in your production environment:

bash
MESSAGEBIRD_API_KEY=live_your_live_key
DATABASE_URL=postgresql://prod_connection_string
NEXT_PUBLIC_APP_URL=https://yourdomain.com
CRON_SECRET=random_secure_string
WEBHOOK_SECRET=random_secure_string

Generate secure secrets:

bash
openssl rand -base64 32

2. Database Connection Pooling

Update lib/db.ts for production:

typescript
import { PrismaClient } from '@prisma/client';

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma = globalForPrisma.prisma || new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

For serverless deployments (Vercel): Configure connection pooling in schema.prisma:

prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  directUrl = env("DIRECT_URL") // For migrations
}

Connection Pool Sizing:

  • Vercel Functions: Limit to 1-2 connections per function instance
  • Use Prisma Data Proxy or PgBouncer for connection pooling
  • Monitor active connections in your database dashboard
  • Set connection_limit=10 in your DATABASE_URL for Vercel

3. Hosting Platform Options

Vercel (Recommended):

  • Native Next.js support with zero configuration
  • Built-in cron job support via vercel.json
  • Automatic HTTPS and edge network
  • Database connection pooling with Vercel Postgres
  • Limitations: 10s function timeout on Hobby plan, requires connection pooling

Railway:

  • Full PostgreSQL database included
  • Environment variable management
  • Automatic deployments from GitHub
  • Support for long-running cron jobs
  • Advantages: Persistent processes supported, no serverless constraints

AWS Amplify:

  • Full-stack deployment with API Gateway
  • EventBridge for scheduled jobs
  • RDS for PostgreSQL database
  • CloudWatch for monitoring
  • Complexity: More configuration required but full control

4. Monitoring and Logging

Implement production logging with Winston:

bash
npm install winston

Create lib/logger.ts:

typescript
import winston from 'winston';

export const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' }),
  ],
});

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple(),
  }));
}

Cloud Logging Integration Examples:

For DataDog:

bash
npm install @datadog/datadog-api-client

For AWS CloudWatch:

bash
npm install winston-cloudwatch

Replace all console.log calls with logger.info, logger.error, etc.

Monitor key metrics:

  • SMS delivery rates by message status
  • API response times and error rates
  • Database query performance
  • Cron job execution success/failure
  • MessageBird account balance and usage

5. Security Best Practices

Webhook Signature Verification: MessageBird signs webhook payloads. Verify signatures to prevent spoofing:

typescript
import crypto from 'crypto';

function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const hmac = crypto.createHmac('sha256', secret);
  const digest = hmac.update(payload).digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(digest)
  );
}

export async function POST(req: NextRequest) {
  const signature = req.headers.get('messagebird-signature');
  const body = await req.text();

  if (!verifyWebhookSignature(body, signature, process.env.WEBHOOK_SECRET!)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  // Process webhook...
}

If signature verification fails:

  • Return HTTP 401 Unauthorized
  • MessageBird will retry delivery up to 10 times
  • Log failed verification attempts for security monitoring
  • Check that WEBHOOK_SECRET matches the value in MessageBird dashboard

Rate Limiting: Implement rate limiting on public API endpoints:

bash
npm install @upstash/ratelimit @upstash/redis
typescript
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute
});

export async function POST(req: NextRequest) {
  const ip = req.ip ?? '127.0.0.1';
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests' },
      { status: 429 }
    );
  }

  // Process request...
}

Input Sanitization: Validate and sanitize all user inputs:

typescript
import validator from 'validator';

function sanitizeInput(input: string): string {
  return validator.escape(validator.trim(input));
}

Section 9: Extending the Application

1. Message Cancellation

Implement cancellation for scheduled messages:

typescript
// lib/messagebird.ts
export async function cancelScheduledMessage(messageId: string): Promise<boolean> {
  try {
    // MessageBird doesn't support API-based cancellation
    // Mark as canceled in database and filter during sending
    await prisma.appointment.update({
      where: { messageBirdId: messageId },
      data: { reminderStatus: 'CANCELED' }
    });
    return true;
  } catch (error) {
    logger.error('Cancel error:', error);
    return false;
  }
}

2. Multiple Reminders

Send reminders at multiple intervals (7 days, 1 day, 1 hour):

typescript
enum ReminderType {
  WEEK_BEFORE = 'WEEK_BEFORE',
  DAY_BEFORE = 'DAY_BEFORE',
  HOUR_BEFORE = 'HOUR_BEFORE'
}

// Add to schema.prisma
model Reminder {
  id              String        @id @default(cuid())
  appointmentId   String
  appointment     Appointment   @relation(fields: [appointmentId], references: [id])
  reminderType    ReminderType
  sentAt          DateTime?
  status          ReminderStatus @default(PENDING)
  messageBirdId   String?
}

Update scheduler to handle multiple reminder types:

typescript
export async function sendScheduledReminders() {
  const intervals = [
    { type: 'WEEK_BEFORE', hours: 7 * 24 },
    { type: 'DAY_BEFORE', hours: 24 },
    { type: 'HOUR_BEFORE', hours: 1 }
  ];

  for (const interval of intervals) {
    const targetTime = new Date(Date.now() + interval.hours * 60 * 60 * 1000);
    const endWindow = new Date(targetTime.getTime() + 60 * 60 * 1000);

    const appointments = await prisma.appointment.findMany({
      where: {
        appointmentDate: {
          gte: targetTime,
          lte: endWindow,
        },
      },
      include: {
        reminders: true,
      },
    });

    for (const appointment of appointments) {
      // Check if this reminder type already sent
      const existingReminder = appointment.reminders.find(
        r => r.reminderType === interval.type && r.status === 'SENT'
      );

      if (!existingReminder) {
        // Send reminder and create record
        const messageBirdId = await sendAppointmentReminder(
          appointment.customerPhone,
          appointment.customerName,
          appointment.appointmentDate,
          appointment.id
        );

        await prisma.reminder.create({
          data: {
            appointmentId: appointment.id,
            reminderType: interval.type,
            status: 'SENT',
            messageBirdId,
            sentAt: new Date(),
          },
        });
      }
    }
  }
}

3. Customer Preferences

Add customer preferences for reminder timing and channels:

prisma
model Customer {
  id                String   @id @default(cuid())
  phone             String   @unique
  name              String
  email             String?
  preferredChannel  String   @default("SMS") // SMS, EMAIL, BOTH
  reminderHoursBefore Int   @default(24)
  timezone          String   @default("America/New_York")
  createdAt         DateTime @default(now())
}

Query customer preferences when scheduling reminders and adjust timing accordingly.

Best Practices and Production Tips

1. Phone Number Validation: Use MessageBird Lookup API to verify numbers before sending:

typescript
export async function validatePhoneNumber(phone: string): Promise<boolean> {
  return new Promise((resolve, reject) => {
    messagebird.lookup.read(phone, (err, response) => {
      if (err) {
        resolve(false); // Invalid number
      } else {
        resolve(response.type === 'mobile'); // Only mobile numbers
      }
    });
  });
}

MessageBird Lookup API Costs:

  • Approximately $0.01-0.02 per lookup depending on country
  • Use strategically: validate on signup, not on every message
  • Cache validation results for known numbers
  • Only validate numbers that failed delivery previously

2. Message Templates: Store message templates in database for easy updates:

typescript
const templates = {
  reminder: (name: string, date: string) =>
    `Hi ${name}, this is a reminder about your appointment on ${date}. Reply CANCEL to cancel.`,
  confirmation: (name: string) =>
    `Thanks ${name}! Your appointment is confirmed. You'll receive a reminder 24 hours before.`
};

SMS Length Limits:

  • GSM-7 encoding (plain): 160 characters per message
  • Unicode (special characters): 70 characters per message
  • Concatenated messages: Automatically split with 7-byte header (153/67 char segments)
  • MessageBird charges per message segment
  • Use datacoding: 'auto' to automatically select optimal encoding

3. Timezone Handling: Always store dates in UTC and convert to customer timezone for display:

typescript
import moment from 'moment-timezone';

function formatAppointmentTime(date: Date, timezone: string): string {
  return moment(date).tz(timezone).format('MMM DD, YYYY [at] h:mm A z');
}

Note: Consider migrating from Moment.js (maintenance mode) to Day.js or date-fns for new projects.

4. Cost Optimization:

  • Validate phone numbers before sending to reduce failed deliveries
  • Use MessageBird Lookup API to verify number validity
  • Implement message deduplication to prevent double-sending
  • Monitor delivery rates by country and adjust strategy
  • Consider alternative channels (Email, WhatsApp) for non-urgent reminders

Example Cost Calculation:

  • US SMS: $0.0075 per message
  • 1,000 appointments/month with 24h reminders: $7.50/month
  • Add validation ($0.01 each): $10 + $7.50 = $17.50/month
  • Failed delivery rate of 5%: Waste $0.38/month without validation
  • ROI: Lookup API prevents failed sends but costs more than failures for reliable numbers

5. Error Handling: Implement comprehensive error tracking:

typescript
class MessageBirdError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number
  ) {
    super(message);
    this.name = 'MessageBirdError';
  }
}

// Log errors to monitoring service
function handleError(error: MessageBirdError) {
  logger.error('MessageBird API Error', {
    code: error.code,
    status: error.statusCode,
    message: error.message,
    timestamp: new Date().toISOString()
  });

  // Send to error tracking service (Sentry, Rollbar, etc.)
  if (process.env.SENTRY_DSN) {
    Sentry.captureException(error);
  }
}

Frequently Asked Questions (FAQ)

How do I get a MessageBird API key for sending SMS?

Sign up for a free MessageBird account at messagebird.com, navigate to Developers → API Keys in your dashboard, and copy your Live API key (starts with live_). For development and testing, use your Test API key (starts with test_) to simulate message sending without charges or actual SMS delivery.

What phone number format does MessageBird require?

MessageBird requires E.164 international format: +[country code][number] with no spaces or special characters. Examples: +14155552671 (US), +447700900123 (UK). Remove parentheses, hyphens, and spaces before sending. Invalid formats like (415) 555-2671 or 415-555-2671 will cause API errors.

Can I schedule SMS messages more than 24 hours in advance with MessageBird?

Yes, MessageBird supports scheduling SMS messages up to 30 days in advance. Use the scheduledDatetime parameter with RFC3339 format (e.g., 2024-01-15T10:30:00Z) when creating messages. Schedule your appointment reminders days or weeks ahead to ensure timely delivery without manual intervention.

How do I handle failed SMS deliveries in my appointment system?

Implement webhook integration to receive real-time delivery status updates from MessageBird. When a message fails, your webhook endpoint updates the database with the failure status. Add retry logic with exponential backoff (1s, 2s, 4s delays) for network errors, and consider fallback channels like email for persistent failures.

What are MessageBird's rate limits for SMS sending?

MessageBird enforces 500 requests per second for POST operations (message creation) and 50 requests per second for GET operations (status checks). For bulk sending, batch your requests and implement exponential backoff retry logic. Monitor your API usage in the MessageBird dashboard to avoid rate limit errors.

How much does it cost to send SMS with MessageBird?

MessageBird pricing varies by destination country, typically ranging from $0.01 to $0.15 per SMS. US SMS costs approximately $0.0075 per message, while international rates vary. Check current pricing at messagebird.com/pricing. Use the Test API key during development to avoid charges while building your application.

Can I cancel a scheduled SMS message in MessageBird?

MessageBird doesn't support API-based cancellation of scheduled messages. Instead, mark appointments as canceled in your database and filter them when your cron job runs. Your scheduler should check the reminderStatus field and skip sending messages for canceled appointments before calling the MessageBird API.

How do I verify phone numbers before sending SMS?

Use MessageBird's Lookup API to verify phone numbers before sending messages. The Lookup API returns number validity, carrier information, and number type (mobile vs. landline). This reduces failed deliveries, lowers costs, and improves your sender reputation by avoiding invalid destinations.

What's the best way to handle timezones for international appointments?

Store all appointment dates in UTC format in your database. When sending reminders, convert UTC timestamps to the customer's local timezone using moment-timezone or date-fns-tz. Include timezone information in reminder messages (e.g., "Your appointment is Jan 15, 2024 at 2:00 PM EST") to prevent confusion.

How do I deploy a Next.js MessageBird application to production?

Deploy to Vercel (recommended for Next.js), Railway, or AWS Amplify. Configure production environment variables (MessageBird Live API key, database URL, webhook secrets), set up Vercel Cron for scheduled jobs, enable database connection pooling for serverless environments, and implement webhook signature verification for security.

What are the common MessageBird error codes and how do I handle them?

Common errors:

  • EC_UNKNOWN_SUBSCRIBER (code 1): Invalid phone number, remove from database
  • EC_SENDER_UNREGISTERED (code 104): Sender ID not registered in destination country, check country restrictions
  • EC_CAMPAIGN_THROUGHPUT_EXCEEDED (code 107): Rate limit hit, implement backoff retry
  • Insufficient balance: Top up MessageBird account
  • Handle errors by checking the statusErrorCode in delivery reports and taking appropriate action

MessageBird Integration Guides:

Next.js Development:

Appointment & Scheduling Systems:

Conclusion: Your Production-Ready Appointment System

You've built a complete SMS appointment reminder system with MessageBird API, Next.js 14, and Prisma ORM. Your application now handles scheduled SMS delivery, database management, webhook processing, and production-grade error handling with comprehensive monitoring and security features.

What You Accomplished: ✅ MessageBird SMS API integration with scheduled messaging and RFC3339 format ✅ Next.js 14 App Router with TypeScript API routes and server components ✅ Prisma ORM with PostgreSQL database and optimized connection pooling ✅ Automated cron jobs for reminder scheduling (Vercel Cron and node-cron) ✅ Webhook integration for real-time delivery status tracking ✅ Production-ready error handling with exponential backoff retry logic ✅ Timezone support for international customers using moment-timezone ✅ Security features including webhook signature verification and rate limiting

Next Steps for Production:

  1. Set up monitoring: Implement Winston logging and connect to monitoring service (DataDog, New Relic, Sentry)
  2. Add customer portal: Build a frontend where customers manage their appointments and preferences
  3. Implement analytics: Track delivery rates, engagement metrics, and cost per message by country
  4. Scale infrastructure: Set up Redis caching for database queries and rate limiting with Upstash
  5. Add alternative channels: Integrate Email (Resend, SendGrid) and WhatsApp as fallback options
  6. Internationalization: Support multiple languages based on customer preferences using i18next

Production Deployment Checklist:

  • Configure production environment variables with secure secrets (use openssl rand -base64 32)
  • Set up database connection pooling for serverless environments (Vercel, AWS Lambda)
  • Enable webhook signature verification for security against spoofing attacks
  • Configure rate limiting on public API endpoints using Upstash Rate Limit
  • Set up error tracking and monitoring (Sentry, LogRocket, or DataDog)
  • Implement database backup strategy (automated daily backups with point-in-time recovery)
  • Configure custom domain with HTTPS certificate (automatic on Vercel)
  • Test cron jobs in production environment with MessageBird Test API key
  • Document API endpoints with OpenAPI/Swagger specification
  • Set up uptime monitoring and alerting (UptimeRobot, Pingdom, or StatusCake)

Additional Resources:

Your appointment reminder system is now ready to scale and serve thousands of customers with reliable, automated SMS notifications. Start testing with MessageBird's test API key, then deploy to production when you're ready to send real messages to your customers.

Frequently Asked Questions

how to reduce appointment no shows

One effective way to reduce appointment no-shows is to implement automated SMS reminders. This guide details building an application with Next.js and MessageBird to send timely reminders, improving customer communication and minimizing missed appointments.

what is messagebird used for

MessageBird, a Communication Platform as a Service (CPaaS), is used for validating phone numbers via its Lookup API and sending scheduled SMS reminders through its Messages API. Its developer-friendly SDK and direct scheduling capabilities make it ideal for this purpose.

why use next.js for appointment booking

Next.js is chosen for this project due to its excellent developer experience, performance optimizations, and built-in API route capabilities, making it suitable for both frontend UI and backend logic.

when should sms reminders be sent

SMS appointment reminders should be sent a few hours before the appointment, giving enough notice but not too far in advance. This application is set up to send reminders 3 hours prior to the appointment time.

how to validate phone numbers with messagebird

The MessageBird Lookup API is used to validate phone numbers. The API route checks number validity and format, ensuring it's a mobile number capable of receiving SMS. It also uses a default country code for convenience.

what database is used for appointments

PostgreSQL is used as the database for storing appointment data. Its robustness and reliability make it suitable for storing and managing this information.

what is prisma and why is it used

Prisma is a next-generation ORM for Node.js and TypeScript. It simplifies database interactions, manages migrations, and enhances type safety in the application.

how to set up messagebird api key

Obtain your MessageBird API key from the MessageBird Dashboard under Developers > API access > Live API Key. Then, paste this key into the `.env` file as `MESSAGEBIRD_API_KEY`, ensuring it's kept confidential.

what is the messagebird originator

The MessageBird originator is the sender ID that recipients see on their phones when they receive the SMS. This can be an alphanumeric string (with restrictions) or an E.164 formatted phone number.

can I use an alphanumeric sender id with messagebird

Alphanumeric sender IDs are possible but not universally supported. Check MessageBird's documentation for country-specific restrictions. Numeric sender IDs (phone numbers) are generally recommended for broader compatibility.

how to handle different time zones for reminders

The application stores the user's time zone and uses `moment-timezone` to accurately calculate reminder times in UTC for MessageBird. This ensures reminders are delivered at the correct local time for each user.

what are the prerequisites for this project

You'll need Node.js v18 or later, npm/yarn, a MessageBird account and API key, access to a PostgreSQL database, and basic knowledge of React, Next.js, and asynchronous JavaScript.

how to schedule sms messages with messagebird

The MessageBird Messages API is used for scheduling SMS messages. The `messagebird.messages.create` function is called with the recipient, message body, and scheduled time in ISO 8601 format.

how does the application handle errors

The application uses extensive `try...catch` blocks and logs errors using `console.error`. It returns informative JSON error responses with appropriate HTTP status codes for client and server errors.