> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fanfare.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Appointment booking

# Appointment Booking Use Case

Learn how to use Fanfare to manage appointment scheduling with fair access to limited time slots.

## Overview

When appointment slots are scarce (medical specialists, consultations, exclusive services), Fanfare helps manage demand fairly. Use queues for high-demand scheduling or timed releases for coordinated slot openings.

**What you'll learn:**

* Setting up appointment-based experiences
* Managing time slot inventory
* Building the booking interface
* Handling confirmations and reminders
* Managing cancellations and waitlists

**Complexity:** Intermediate
**Time to complete:** 40 minutes

## Prerequisites

* Fanfare account with queue or timed release enabled
* Appointment scheduling system or calendar
* Understanding of your service capacity
* Customer communication system (email/SMS)

## When to Use Fanfare for Appointments

| Scenario                                | Fanfare Solution               |
| --------------------------------------- | ------------------------------ |
| High demand for limited slots           | Queue before slot selection    |
| Coordinated slot release (e.g., weekly) | Timed release                  |
| VIP priority booking                    | Audience-based early access    |
| Preventing bots from grabbing slots     | Rate limiting + authentication |

## Step 1: Define Appointment Structure

### Configuration Model

```typescript theme={null}
// types/appointments.ts
interface AppointmentConfig {
  // Service definition
  serviceId: string;
  serviceName: string;
  duration: number; // minutes
  price: string;
  location: "in_person" | "virtual";

  // Availability
  availableDays: number[]; // 0=Sunday, 1=Monday, etc.
  timeSlots: TimeSlot[];

  // Booking rules
  maxAdvanceBookingDays: number;
  minAdvanceBookingHours: number;
  maxBookingsPerConsumer: number;
  requireDeposit: boolean;
  depositAmount?: string;

  // Cancellation policy
  freeCancellationHours: number;
  cancellationFee?: string;
}

interface TimeSlot {
  startTime: string; // "09:00"
  endTime: string; // "09:30"
  capacity: number; // Concurrent appointments
}

// Example configuration
const consultationConfig: AppointmentConfig = {
  serviceId: "specialist-consultation",
  serviceName: "Specialist Consultation",
  duration: 30,
  price: "150.00",
  location: "virtual",

  availableDays: [1, 2, 3, 4, 5], // Monday-Friday
  timeSlots: [
    { startTime: "09:00", endTime: "09:30", capacity: 1 },
    { startTime: "09:30", endTime: "10:00", capacity: 1 },
    { startTime: "10:00", endTime: "10:30", capacity: 1 },
    { startTime: "10:30", endTime: "11:00", capacity: 1 },
    { startTime: "14:00", endTime: "14:30", capacity: 1 },
    { startTime: "14:30", endTime: "15:00", capacity: 1 },
    { startTime: "15:00", endTime: "15:30", capacity: 1 },
    { startTime: "15:30", endTime: "16:00", capacity: 1 },
  ],

  maxAdvanceBookingDays: 30,
  minAdvanceBookingHours: 24,
  maxBookingsPerConsumer: 2,
  requireDeposit: true,
  depositAmount: "50.00",

  freeCancellationHours: 48,
  cancellationFee: "25.00",
};
```

## Step 2: Create Booking Experience

### Queue-Based Booking (High Demand)

```typescript theme={null}
async function createAppointmentQueue(config: AppointmentConfig, releaseDate: Date) {
  const queue = await createFanfareExperience({
    name: `${config.serviceName} - Booking Queue`,
    slug: `booking-${config.serviceId}-${releaseDate.toISOString().split("T")[0]}`,

    // Queue opens at specific time
    scheduledStart: releaseDate,

    config: {
      // Booking window after admission
      admissionWindowSeconds: 300, // 5 minutes to select slot

      // Flow control
      maxConcurrentAdmissions: 10,
      admissionRatePerMinute: 20,

      // Authentication required
      requireAuthentication: true,

      // Fair access
      fairnessMode: "strict",
    },

    metadata: {
      serviceId: config.serviceId,
      bookingType: "appointment",
      availableSlots: await getAvailableSlotsCount(config, releaseDate),
    },
  });

  return queue;
}
```

### Timed Release for Weekly Slots

```typescript theme={null}
async function createWeeklySlotRelease(config: AppointmentConfig) {
  // Release next week's appointments every Monday at 9 AM
  const release = await createFanfareExperience({
    name: `${config.serviceName} - Weekly Slot Release`,
    slug: `slots-${config.serviceId}`,

    // Recurring schedule
    schedule: {
      type: "recurring",
      frequency: "weekly",
      dayOfWeek: 1, // Monday
      time: "09:00",
      timezone: "America/New_York",
    },

    config: {
      admissionWindowSeconds: 600, // 10 minutes
      maxConcurrentAdmissions: 50,
      showRemainingSlots: true,
    },

    metadata: {
      serviceId: config.serviceId,
      releaseType: "weekly_appointment_slots",
    },
  });

  return release;
}
```

## Step 3: Build the Booking Interface

### Appointment Booking Page

```tsx theme={null}
// pages/book/[serviceId].tsx
import { FanfareProvider } from "@fanfare-io/fanfare-sdk-react";
import { AppointmentBookingExperience } from "@/components/AppointmentBookingExperience";
import { ServiceInfo } from "@/components/ServiceInfo";

interface BookingPageProps {
  experienceId: string;
  service: ServiceConfig;
  availableSlots: TimeSlot[];
}

export default function BookingPage({ experienceId, service, availableSlots }: BookingPageProps) {
  return (
    <FanfareProvider
      organizationId={process.env.NEXT_PUBLIC_FANFARE_ORG_ID!}
      publishableKey={process.env.NEXT_PUBLIC_FANFARE_PUBLISHABLE_KEY!}
    >
      <div className="booking-page">
        <ServiceInfo service={service} />
        <AppointmentBookingExperience experienceId={experienceId} service={service} availableSlots={availableSlots} />
      </div>
    </FanfareProvider>
  );
}
```

### Booking Experience Component

```tsx theme={null}
// components/AppointmentBookingExperience.tsx
import { useExperienceJourney } from "@fanfare-io/fanfare-sdk-react";
import type { SequenceView } from "@fanfare-io/fanfare-sdk-core/experiences";
import { useState, useEffect } from "react";

interface AppointmentBookingExperienceProps {
  experienceId: string;
  service: ServiceConfig;
  availableSlots: TimeSlot[];
}

export function AppointmentBookingExperience({
  experienceId,
  service,
  availableSlots,
}: AppointmentBookingExperienceProps) {
  const { view, start } = useExperienceJourney(experienceId, { autoStart: true });

  if (!view) return <LoadingState message="Connecting to booking system..." />;

  if (view.journeyStage === "ready") {
    return <BookingNotOpenState service={service} onStart={start} />;
  }

  if (view.journeyStage === "routing") {
    return <LoadingState message="Connecting to booking system..." />;
  }

  if (view.journeyStage === "gated") {
    return <GateState view={view} />;
  }

  switch (view.sequence.phase) {
    case "scheduled":
      return <BookingNotOpenState service={service} />;

    case "enterable":
      return "book" in view.sequence ? (
        <SlotSelectionState sequence={view.sequence} service={service} availableSlots={availableSlots} />
      ) : null;

    case "participating":
      return <BookingConfirmedState sequence={view.sequence} service={service} />;

    case "ended":
      return <SessionExpiredState onRetry={start} />;

    default:
      return null;
  }
}

function BookingNotOpenState({ service, onStart }: { service: ServiceConfig; onStart?: () => Promise<unknown> }) {
  return (
    <div className="booking-not-open">
      <h2>Booking Opens Soon</h2>

      {onStart && <button onClick={() => void onStart()}>Check availability</button>}

      <div className="service-preview">
        <h3>{service.serviceName}</h3>
        <p>
          {service.duration} minutes - ${service.price}
        </p>
        <p>{service.location === "virtual" ? "Video consultation" : "In-person appointment"}</p>
      </div>

      <div className="preparation-tips">
        <h4>Prepare for Booking</h4>
        <ul>
          <li>Have your preferred dates ready</li>
          <li>Prepare payment information (deposit required)</li>
          <li>You'll have 5 minutes to select your slot</li>
        </ul>
      </div>
    </div>
  );
}

function SlotSelectionState({ sequence, service, availableSlots }: {
  sequence: Extract<SequenceView, { phase: "enterable"; mechanism: "appointment" }>;
  service: ServiceConfig;
  availableSlots: TimeSlot[];
}) {
  const [selectedDate, setSelectedDate] = useState<Date | null>(null);
  const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleBookSlot = async () => {
    if (!selectedDate || !selectedSlot) return;

    setIsSubmitting(true);
    try {
      await sequence.book(selectedSlot.slotId, selectedSlot.locationId);

      // Continue to your app-owned confirmation or deposit step.
      window.location.href = `/book/confirm`;
    } catch (error) {
      console.error("Booking failed:", error);
      alert("Failed to reserve slot. Please try another time.");
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <div className="slot-selection">
      <div className="selection-header">
        <h2>Select Your Appointment</h2>
      </div>

      <div className="date-selection">
        <h3>Select a Date</h3>
        <DatePicker
          minDate={addDays(new Date(), 1)}
          maxDate={addDays(new Date(), service.maxAdvanceBookingDays)}
          availableDays={service.availableDays}
          selectedDate={selectedDate}
          onSelect={setSelectedDate}
        />
      </div>

      {selectedDate && (
        <div className="time-selection">
          <h3>Select a Time</h3>
          <TimeSlotPicker
            date={selectedDate}
            slots={availableSlots}
            selectedSlot={selectedSlot}
            onSelect={setSelectedSlot}
          />
        </div>
      )}

      {selectedDate && selectedSlot && (
        <div className="booking-summary">
          <h3>Booking Summary</h3>
          <dl>
            <dt>Service</dt>
            <dd>{service.serviceName}</dd>

            <dt>Date</dt>
            <dd>{selectedDate.toLocaleDateString()}</dd>

            <dt>Time</dt>
            <dd>
              {selectedSlot.startTime} - {selectedSlot.endTime}
            </dd>

            <dt>Duration</dt>
            <dd>{service.duration} minutes</dd>

            <dt>Price</dt>
            <dd>{formatMoney(service.price, "USD")}</dd>

            {service.requireDeposit && (
              <>
                <dt>Deposit (due now)</dt>
                <dd>{formatMoney(service.depositAmount, "USD")}</dd>
              </>
            )}
          </dl>

          <button onClick={handleBookSlot} disabled={isSubmitting} className="book-btn">
            {isSubmitting ? "Reserving..." : `Book & Pay ${formatMoney(service.depositAmount || service.price, "USD")}`}
          </button>
        </div>
      )}
    </div>
  );
}

function BookingConfirmedState({
  sequence,
  service,
}: {
  sequence: Extract<SequenceView, { phase: "participating"; mechanism: "appointment" }>;
  service: ServiceConfig;
}) {
  const booking = sequence.state$.get().booking;

  return (
    <div className="booking-confirmed">
      <div className="success-badge">
        <span className="icon">✓</span>
        <h2>Booking Confirmed!</h2>
      </div>

      <div className="confirmation-details">
        <h3>Appointment Details</h3>
        <dl>
          <dt>Confirmation Number</dt>
          <dd>{booking?.confirmationNumber}</dd>

          <dt>Service</dt>
          <dd>{service.serviceName}</dd>

          <dt>Date & Time</dt>
          <dd>
            {new Date(booking?.dateTime).toLocaleDateString()} at {booking?.time}
          </dd>

          <dt>Location</dt>
          <dd>{service.location === "virtual" ? "Video Call (link will be emailed)" : booking?.address}</dd>
        </dl>
      </div>

      <div className="next-steps">
        <h3>What's Next?</h3>
        <ul>
          <li>Confirmation email sent to the customer</li>
          <li>Calendar invite attached</li>
          <li>Reminder sent 24 hours before appointment</li>
        </ul>
      </div>

      <div className="actions">
        <button onClick={() => addToCalendar(booking)}>Add to Calendar</button>
        <button onClick={() => (window.location.href = "/appointments")}>View My Appointments</button>
      </div>
    </div>
  );
}
```

### Time Slot Picker Component

```tsx theme={null}
// components/TimeSlotPicker.tsx
interface TimeSlotPickerProps {
  date: Date;
  slots: TimeSlot[];
  selectedSlot: TimeSlot | null;
  onSelect: (slot: TimeSlot) => void;
}

export function TimeSlotPicker({ date, slots, selectedSlot, onSelect }: TimeSlotPickerProps) {
  const [availability, setAvailability] = useState<Map<string, boolean>>(new Map());
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function fetchAvailability() {
      setLoading(true);
      try {
        const available = await getSlotAvailability(date);
        const map = new Map<string, boolean>();
        available.forEach((slot) => {
          map.set(slot.startTime, slot.available);
        });
        setAvailability(map);
      } finally {
        setLoading(false);
      }
    }

    fetchAvailability();
  }, [date]);

  if (loading) {
    return <div className="loading">Loading available times...</div>;
  }

  const morningSlots = slots.filter((s) => parseInt(s.startTime) < 12);
  const afternoonSlots = slots.filter((s) => parseInt(s.startTime) >= 12);

  return (
    <div className="time-slot-picker">
      <div className="slot-group">
        <h4>Morning</h4>
        <div className="slots">
          {morningSlots.map((slot) => {
            const isAvailable = availability.get(slot.startTime) ?? false;
            const isSelected = selectedSlot?.startTime === slot.startTime;

            return (
              <button
                key={slot.startTime}
                onClick={() => isAvailable && onSelect(slot)}
                disabled={!isAvailable}
                className={`slot ${isSelected ? "selected" : ""} ${!isAvailable ? "unavailable" : ""}`}
              >
                {slot.startTime}
              </button>
            );
          })}
        </div>
      </div>

      <div className="slot-group">
        <h4>Afternoon</h4>
        <div className="slots">
          {afternoonSlots.map((slot) => {
            const isAvailable = availability.get(slot.startTime) ?? false;
            const isSelected = selectedSlot?.startTime === slot.startTime;

            return (
              <button
                key={slot.startTime}
                onClick={() => isAvailable && onSelect(slot)}
                disabled={!isAvailable}
                className={`slot ${isSelected ? "selected" : ""} ${!isAvailable ? "unavailable" : ""}`}
              >
                {slot.startTime}
              </button>
            );
          })}
        </div>
      </div>
    </div>
  );
}
```

## Step 4: Booking Management

### Server-Side Slot Reservation

```typescript theme={null}
// services/appointment-booking.ts
import { db } from "../db";
import { appointments, appointmentSlots } from "../db/schema";
import { eq, and } from "drizzle-orm";

interface ReserveSlotParams {
  serviceId: string;
  date: Date;
  slot: TimeSlot;
}

export async function reserveAppointmentSlot(params: ReserveSlotParams) {
  const { serviceId, date, slot } = params;

  return await db.transaction(async (tx) => {
    // Check slot availability with lock
    const slotRecord = await tx
      .select()
      .from(appointmentSlots)
      .where(
        and(
          eq(appointmentSlots.serviceId, serviceId),
          eq(appointmentSlots.date, date),
          eq(appointmentSlots.startTime, slot.startTime)
        )
      )
      .for("update");

    if (!slotRecord[0] || slotRecord[0].bookedCount >= slotRecord[0].capacity) {
      throw new Error("Slot no longer available");
    }

    // Increment booked count
    await tx
      .update(appointmentSlots)
      .set({ bookedCount: slotRecord[0].bookedCount + 1 })
      .where(eq(appointmentSlots.id, slotRecord[0].id));

    // Create appointment
    const [appointment] = await tx
      .insert(appointments)
      .values({
        serviceId,
        consumerId,
        slotId: slotRecord[0].id,
        date,
        startTime: slot.startTime,
        endTime: slot.endTime,
        status: "pending_payment",
        confirmationNumber: generateConfirmationNumber(),
        createdAt: new Date(),
      })
      .returning();

    return appointment;
  });
}
```

### Cancellation and Waitlist

```typescript theme={null}
// services/appointment-cancellation.ts
export async function cancelAppointment(appointmentId: string, consumerId: string) {
  const appointment = await db.query.appointments.findFirst({
    where: and(eq(appointments.id, appointmentId), eq(appointments.consumerId, consumerId)),
  });

  if (!appointment) {
    throw new Error("Appointment not found");
  }

  const hoursUntilAppointment = (new Date(appointment.date).getTime() - Date.now()) / (1000 * 60 * 60);

  const service = await getServiceConfig(appointment.serviceId);
  const isFreeCancellation = hoursUntilAppointment >= service.freeCancellationHours;

  await db.transaction(async (tx) => {
    // Update appointment status
    await tx
      .update(appointments)
      .set({
        status: "cancelled",
        cancelledAt: new Date(),
        cancellationFee: isFreeCancellation ? "0.00" : service.cancellationFee,
      })
      .where(eq(appointments.id, appointmentId));

    // Release slot
    await tx
      .update(appointmentSlots)
      .set({ bookedCount: sql`booked_count - 1` })
      .where(eq(appointmentSlots.id, appointment.slotId));

    // Process waitlist
    const nextInWaitlist = await tx
      .select()
      .from(appointmentWaitlist)
      .where(and(eq(appointmentWaitlist.slotId, appointment.slotId), eq(appointmentWaitlist.status, "waiting")))
      .orderBy(appointmentWaitlist.createdAt)
      .limit(1);

    if (nextInWaitlist[0]) {
      // Notify waitlist customer
      await notifyWaitlistCustomer(nextInWaitlist[0], appointment);

      // Update waitlist entry
      await tx
        .update(appointmentWaitlist)
        .set({
          status: "notified",
          notifiedAt: new Date(),
          expiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000), // 2 hours to claim
        })
        .where(eq(appointmentWaitlist.id, nextInWaitlist[0].id));
    }
  });

  // Process refund if applicable
  if (isFreeCancellation && appointment.depositPaid) {
    await processRefund(appointment.paymentId, appointment.depositAmount);
  } else if (appointment.depositPaid && service.cancellationFee) {
    await processPartialRefund(appointment.paymentId, subtractMoney(appointment.depositAmount, service.cancellationFee));
  }
}
```

## Step 5: Notifications and Reminders

### Notification Service

```typescript theme={null}
// services/appointment-notifications.ts
interface AppointmentNotification {
  type: "confirmation" | "reminder_24h" | "reminder_1h" | "cancellation" | "waitlist_available";
  appointment: Appointment;
  consumer: Consumer;
}

export async function sendAppointmentNotification(notification: AppointmentNotification) {
  const { type, appointment, consumer } = notification;

  const templates: Record<string, EmailTemplate> = {
    confirmation: {
      subject: `Appointment Confirmed - ${appointment.serviceName}`,
      template: "appointment-confirmation",
    },
    reminder_24h: {
      subject: `Reminder: Your appointment is tomorrow`,
      template: "appointment-reminder-24h",
    },
    reminder_1h: {
      subject: `Your appointment starts in 1 hour`,
      template: "appointment-reminder-1h",
    },
    cancellation: {
      subject: `Appointment Cancelled`,
      template: "appointment-cancellation",
    },
    waitlist_available: {
      subject: `A slot is now available!`,
      template: "waitlist-slot-available",
    },
  };

  const template = templates[type];

  // Send email
  await sendEmail({
    to: consumer.email,
    subject: template.subject,
    template: template.template,
    data: {
      consumerName: consumer.name,
      serviceName: appointment.serviceName,
      date: new Date(appointment.date).toLocaleDateString(),
      time: appointment.startTime,
      confirmationNumber: appointment.confirmationNumber,
      location: appointment.location,
      calendarLink: generateCalendarLink(appointment),
    },
  });

  // Send SMS for reminders
  if (type.includes("reminder") || type === "waitlist_available") {
    await sendSMS({
      to: consumer.phone,
      message: getSMSMessage(type, appointment),
    });
  }
}

// Scheduled reminder job
export async function processAppointmentReminders() {
  const now = new Date();

  // 24 hour reminders
  const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
  const appointments24h = await getAppointmentsForDate(tomorrow);

  for (const appointment of appointments24h) {
    if (!appointment.reminder24hSent) {
      await sendAppointmentNotification({
        type: "reminder_24h",
        appointment,
        consumer: await getConsumer(appointment.consumerId),
      });

      await markReminderSent(appointment.id, "24h");
    }
  }

  // 1 hour reminders
  const inOneHour = new Date(now.getTime() + 60 * 60 * 1000);
  const appointments1h = await getAppointmentsStartingAt(inOneHour);

  for (const appointment of appointments1h) {
    if (!appointment.reminder1hSent) {
      await sendAppointmentNotification({
        type: "reminder_1h",
        appointment,
        consumer: await getConsumer(appointment.consumerId),
      });

      await markReminderSent(appointment.id, "1h");
    }
  }
}
```

## Best Practices

### 1. Clear Booking Rules

```tsx theme={null}
function BookingRules({ service }: { service: ServiceConfig }) {
  return (
    <section className="booking-rules">
      <h3>Booking Policy</h3>
      <ul>
        <li>Book up to {service.maxAdvanceBookingDays} days in advance</li>
        <li>Minimum {service.minAdvanceBookingHours} hours notice required</li>
        <li>Maximum {service.maxBookingsPerConsumer} active bookings per customer</li>

        {service.requireDeposit && <li>Deposit of {formatMoney(service.depositAmount, "USD")} required to confirm</li>}

        <li>Free cancellation up to {service.freeCancellationHours} hours before appointment</li>

        {service.cancellationFee && <li>Late cancellation fee: {formatMoney(service.cancellationFee, "USD")}</li>}
      </ul>
    </section>
  );
}
```

### 2. Handle No-Shows

```typescript theme={null}
async function processNoShows() {
  const gracePeriod = 15 * 60 * 1000; // 15 minutes

  const noShows = await db.query.appointments.findMany({
    where: and(
      eq(appointments.status, "confirmed"),
      lt(appointments.startTime, new Date(Date.now() - gracePeriod)),
      isNull(appointments.checkedInAt)
    ),
  });

  for (const appointment of noShows) {
    await db
      .update(appointments)
      .set({
        status: "no_show",
        noShowAt: new Date(),
      })
      .where(eq(appointments.id, appointment.id));

    // Apply no-show policy (e.g., forfeit deposit)
    if (appointment.depositPaid) {
      await recordNoShowFee(appointment.id, appointment.depositAmount);
    }

    // Update customer history
    await incrementNoShowCount(appointment.consumerId);
  }
}
```

### 3. Mobile-Optimized Interface

```css theme={null}
/* Mobile-first booking styles */
.time-slot-picker .slots {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
  gap: 8px;
}

.slot {
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  background: white;
  font-size: 14px;
  cursor: pointer;
}

.slot.selected {
  background: #007bff;
  color: white;
  border-color: #007bff;
}

.slot.unavailable {
  background: #f5f5f5;
  color: #999;
  cursor: not-allowed;
  text-decoration: line-through;
}

.booking-summary {
  position: sticky;
  bottom: 0;
  background: white;
  padding: 16px;
  box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
}

@media (max-width: 480px) {
  .date-picker {
    width: 100%;
  }

  .book-btn {
    width: 100%;
    padding: 16px;
    font-size: 16px;
  }
}
```

## Troubleshooting

### Double Bookings

1. Always use database transactions with row locks
2. Verify slot availability immediately before booking
3. Implement idempotency keys for payment processing

### Slot Not Showing Available

1. Check slot capacity vs. booked count
2. Verify date is within booking window
3. Ensure customer hasn't exceeded booking limit

### Reminders Not Sending

1. Verify scheduled job is running
2. Check email/SMS service status
3. Review reminder sent flags in database

## What's Next

* [Event Ticketing](/guides/use-cases/event-ticketing) - Manage event access
* [Webhooks Guide](/guides/advanced/webhooks-guide) - Real-time notifications
* [Error Handling](/guides/advanced/error-handling) - Handle booking failures gracefully
