How to Design an Appointment Scheduling Platform

How to Design an Appointment Scheduling Platform — A Senior+ Guide | Ayodhyya

A Complete System Design Deep-Dive — From Calendly-Style Core to Enterprise-Grade Healthcare Scheduling

Senior+ System Design Guide Ayodhyya

1. Introduction & Problem Statement

Appointment scheduling is one of the most deceptively complex problems in software engineering. On the surface, it seems trivial — pick a time, book it, done. But the moment you layer in real-world constraints like overlapping calendars across multiple providers, time zone DST transitions, buffer times between meetings, group availability consensus, payment processing, HIPAA compliance for healthcare, and no-show prevention, you are looking at a system that rivals the complexity of an e-commerce checkout pipeline.

Platforms like Calendly, Acuity Scheduling, Cal.com, and HubSpot Meetings have proven that there is massive demand for automated scheduling. As of 2026, Calendly alone processes over 20 million meetings per month across 20 million users. The enterprise scheduling market is projected to reach $1.5 billion by 2028.

In this article, we will design a production-grade appointment scheduling platform from scratch. We will cover every angle: calendar sync, availability computation, booking flows, payments, reminders, analytics, team routing, embeddable widgets, API design, security, HIPAA compliance, and cost estimation. This is a complete senior-level system design walkthrough.

What You Will Learn

By the end of this article, you will understand how to architect a scheduling platform that handles millions of bookings per day, integrates with every major calendar and video conferencing provider, processes payments, prevents no-shows, and complies with healthcare regulations.

2. Functional & Non-Functional Requirements

Functional Requirements

IDRequirementPriority
FR-1Users can create scheduling pages with configurable event typesP0
FR-2Bookers can view available time slots and book appointmentsP0
FR-3Sync availability with Google Calendar, Outlook, and iCalP0
FR-4Support working hours, date overrides, and buffer timesP0
FR-5Support one-on-one, group, round-robin, and collective bookingP1
FR-6Automatic time zone detection and conversionP0
FR-7Email, SMS, and push notification remindersP1
FR-8Payment collection at booking (Stripe integration)P1
FR-9Recurring appointment schedulingP2
FR-10Waiting lists for fully booked slotsP2
FR-11Rescheduling and cancellation with policiesP0
FR-12Team scheduling with round-robin and collective routingP1
FR-13Meeting polling to find mutually available timesP2
FR-14Embeddable JavaScript widget for external websitesP1
FR-15Zoom, Google Meet, and Teams auto-provisioningP1
FR-16Analytics dashboard (booking rates, no-show rates, revenue)P1
FR-17CRM integration (Salesforce, HubSpot)P2
FR-18Multi-location supportP2
FR-19HIPAA-compliant scheduling for healthcareP2
FR-20RESTful API and webhook systemP1

Non-Functional Requirements

RequirementTarget
Availability99.95% uptime (4.38 hours downtime/year)
LatencySlot availability query < 200ms p99
Throughput10,000 bookings/second peak
ConsistencyStrong consistency for double-booking prevention
Scalability100M+ calendar syncs, 50M+ bookings/month
SecuritySOC 2 Type II, HIPAA BAA available
Real-timeCalendar sync lag < 5 seconds

3. High-Level Architecture

graph TB subgraph Clients A[Web App - React SPA] B[Mobile App - React Native] C[Embeddable Widget - JS] D[Public Booking Page] end subgraph "API Gateway" E[API Gateway - Rate Limiting, Auth, Routing] end subgraph "Core Services" F[Booking Service] G[Availability Service] H[Calendar Sync Service] I[Notification Service] J[Payment Service] K[Analytics Service] L[User & Auth Service] M[Webhook Service] end subgraph "Data Layer" N[(PostgreSQL - Primary)] O[(Redis - Cache/Sessions)] P[(Elasticsearch - Search)] Q[Object Storage - S3] end subgraph "Message Queue" R[Kafka / RabbitMQ] end subgraph "External APIs" S[Google Calendar API] T[Microsoft Graph API] U[iCal Feed] V[Zoom API] W[Teams API] X[Stripe API] Y[Twilio - SMS] Z[SendGrid - Email] end A --> E B --> E C --> E D --> E E --> F E --> G E --> L F --> N F --> R G --> O G --> N H --> R R --> H H --> S H --> T H --> U F --> R R --> I R --> J R --> K R --> M I --> Y I --> Z J --> X F --> V F --> W M --> N K --> N K --> P

Architecture Principles

  • Event-Driven Core: Every booking, cancellation, and modification emits an event to Kafka. Downstream services (notifications, analytics, calendar sync, webhooks) subscribe independently.
  • Availability as a Materialized View: The availability service pre-computes and caches available time slots. Changes to calendars or working hours trigger a cache invalidation and recomputation.
  • Optimistic Concurrency for Double-Booking Prevention: We use database-level unique constraints and serializable transactions to guarantee no two bookings occupy the same slot.
  • Idempotent Booking API: Every booking request carries an idempotency key. Retries do not create duplicate bookings.

Service Responsibilities

ServiceResponsibilityScaling Strategy
Booking ServiceCreates, modifies, cancels bookingsHorizontal, partitioned by host_id
Availability ServiceComputes available time slotsRead-heavy, Redis-cached, replicated
Calendar Sync ServiceBidirectional sync with external calendarsWorker pool, rate-limited per provider
Notification ServiceEmail, SMS, push notificationsAsync via Kafka, retry with backoff
Payment ServicePayment intent creation, refundsStateless, idempotent Stripe calls
Analytics ServiceAggregates booking metricsOLAP (ClickHouse), batch + stream
Webhook ServiceOutbound event delivery to customersAt-least-once, exponential backoff

4. Database Schema & Data Model

The data model is the backbone of the scheduling system. We design it around a few core aggregates: User, Calendar, EventType, Booking, and Availability.

erDiagram USER ||--o{ CALENDAR : has USER ||--o{ EVENT_TYPE : creates USER ||--o{ BOOKING : hosts EVENT_TYPE ||--o{ BOOKING : generates CALENDAR ||--o{ CALENDAR_EVENT : contains BOOKING ||--o| PAYMENT : has BOOKING ||--o{ NOTIFICATION : triggers USER ||--o{ AVAILABILITY_RULE : defines TEAM ||--o{ USER : includes TEAM ||--o{ EVENT_TYPE : owns BOOKING }o--o| WAITLIST_ENTRY : fills

Core Tables

SQL
CREATE TABLE users (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email           VARCHAR(255) UNIQUE NOT NULL,
    name            VARCHAR(255) NOT NULL,
    timezone        VARCHAR(50) NOT NULL DEFAULT 'UTC',
    avatar_url      TEXT,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE organizations (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            VARCHAR(255) NOT NULL,
    slug            VARCHAR(255) UNIQUE NOT NULL,
    plan            VARCHAR(50) DEFAULT 'free',
    settings        JSONB DEFAULT '{}',
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE org_members (
    org_id          UUID REFERENCES organizations(id),
    user_id         UUID REFERENCES users(id),
    role            VARCHAR(50) DEFAULT 'member',
    PRIMARY KEY (org_id, user_id)
);

CREATE TABLE calendars (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID REFERENCES users(id),
    provider        VARCHAR(50) NOT NULL,
    external_id     VARCHAR(500) NOT NULL,
    access_token    TEXT,
    refresh_token   TEXT,
    token_expires   TIMESTAMPTZ,
    sync_token      VARCHAR(500),
    last_synced_at  TIMESTAMPTZ,
    is_primary      BOOLEAN DEFAULT false,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE calendar_events (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    calendar_id     UUID REFERENCES calendars(id),
    external_id     VARCHAR(500),
    summary         VARCHAR(500),
    start_time      TIMESTAMPTZ NOT NULL,
    end_time        TIMESTAMPTZ NOT NULL,
    is_busy         BOOLEAN DEFAULT true,
    is_tentative    BOOLEAN DEFAULT false,
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE event_types (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID REFERENCES users(id),
    org_id          UUID REFERENCES organizations(id),
    name            VARCHAR(255) NOT NULL,
    slug            VARCHAR(255) NOT NULL,
    description     TEXT,
    duration_min    INT NOT NULL,
    location_type   VARCHAR(50),
    location_value  TEXT,
    color           VARCHAR(7),
    is_active       BOOLEAN DEFAULT true,
    requires_payment BOOLEAN DEFAULT false,
    price_cents     INT,
    currency        VARCHAR(3) DEFAULT 'USD',
    buffer_before   INT DEFAULT 0,
    buffer_after    INT DEFAULT 0,
    min_booking_advance INT DEFAULT 0,
    max_booking_advance INT DEFAULT 43200,
    slot_interval   INT DEFAULT 30,
    scheduling_type VARCHAR(50) DEFAULT 'one_on_one',
    max_attendees   INT,
    secret          BOOLEAN DEFAULT false,
    custom_fields   JSONB DEFAULT '[]',
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(user_id, slug)
);

CREATE TABLE availability_rules (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_type_id   UUID REFERENCES event_types(id) ON DELETE CASCADE,
    day_of_week     INT NOT NULL,
    start_time      TIME NOT NULL,
    end_time        TIME NOT NULL,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE date_overrides (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_type_id   UUID REFERENCES event_types(id) ON DELETE CASCADE,
    override_date   DATE NOT NULL,
    is_available    BOOLEAN DEFAULT false,
    start_time      TIME,
    end_time        TIME,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE bookings (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_type_id   UUID REFERENCES event_types(id),
    booker_email    VARCHAR(255) NOT NULL,
    booker_name     VARCHAR(255),
    booker_timezone VARCHAR(50) NOT NULL,
    booker_phone    VARCHAR(50),
    host_user_id    UUID REFERENCES users(id),
    start_time      TIMESTAMPTZ NOT NULL,
    end_time        TIMESTAMPTZ NOT NULL,
    status          VARCHAR(50) DEFAULT 'confirmed',
    cancellation_reason TEXT,
    cancelled_at    TIMESTAMPTZ,
    rescheduled_from UUID REFERENCES bookings(id),
    meeting_url     TEXT,
    meeting_provider VARCHAR(50),
    notes           TEXT,
    custom_answers  JSONB DEFAULT '{}',
    idempotency_key VARCHAR(255) UNIQUE,
    version         INT DEFAULT 1,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_no_double_booking
    ON bookings(host_user_id, start_time)
    WHERE status IN ('confirmed', 'pending');

CREATE TABLE payments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    booking_id      UUID REFERENCES bookings(id),
    stripe_payment_intent VARCHAR(255),
    amount_cents    INT NOT NULL,
    currency        VARCHAR(3) NOT NULL,
    status          VARCHAR(50) DEFAULT 'pending',
    refund_amount_cents INT DEFAULT 0,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE recurring_schedules (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    booking_id      UUID REFERENCES bookings(id),
    frequency       VARCHAR(20) NOT NULL,
    end_date        DATE,
    next_instance   UUID REFERENCES bookings(id),
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE waitlist_entries (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_type_id   UUID REFERENCES event_types(id),
    requested_date  DATE,
    email           VARCHAR(255) NOT NULL,
    name            VARCHAR(255),
    position        INT NOT NULL,
    status          VARCHAR(50) DEFAULT 'waiting',
    notified_at     TIMESTAMPTZ,
    expires_at      TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE round_robin_state (
    event_type_id   UUID REFERENCES event_types(id),
    last_assigned   UUID REFERENCES users(id),
    updated_at      TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (event_type_id)
);
Why a Unique Index on (host_user_id, start_time)?

This is the fundamental mechanism that prevents double-booking. When two concurrent booking requests try to insert the same slot, the database rejects the second one at the index level. We handle the resulting unique_violation error in the application layer by retrying with the next available slot.

Why PostgreSQL?

  • JSONB for flexible custom fields and event settings
  • Time zone awareness with TIMESTAMPTZ
  • Serializable isolation for critical booking transactions
  • Partial indexes for efficient double-booking checks
  • Row-level security for multi-tenant data isolation

5. Calendar Integration (Google, Outlook, iCal)

Calendar sync is the hardest operational challenge in a scheduling platform. We need bidirectional, real-time sync with three major providers, each with their own API quirks, rate limits, and authentication flows.

sequenceDiagram participant User as Booker participant Platform as Scheduling Platform participant GS as Google Calendar participant MS as Microsoft Graph Note over Platform: Initial Setup User->>Platform: Connect Google Calendar Platform->>GS: OAuth2 Consent Flow GS-->>Platform: access_token + refresh_token Platform->>GS: GET /calendars/primary/events (sync token) GS-->>Platform: Calendar events (past 30 days + future) Platform->>Platform: Store in calendar_events table Note over Platform: Sync Loop loop Every 30 seconds (push) or 5 min (poll) Platform->>GS: GET /calendars/primary/events?syncToken=... GS-->>Platform: Delta (new/updated/deleted events) Platform->>Platform: Update calendar_events table Platform->>Platform: Invalidate availability cache end Note over Platform: On New Booking User->>Platform: Book appointment Platform->>Platform: Create calendar event locally Platform->>GS: POST /calendars/primary/events GS-->>Platform: Event created Platform->>Platform: Store external_id

Google Calendar Integration

Google Calendar uses a sync token pattern. After the initial full fetch, we only request deltas using the sync token. This is efficient — we don't re-download the entire calendar every time.

C#
public class GoogleCalendarSyncService : ICalendarSyncService
{
    private readonly CalendarService _googleCalendar;
    private readonly AppDbContext _db;
    private readonly ILogger<GoogleCalendarSyncService> _logger;

    public async Task<SyncResult> SyncCalendarAsync(Calendar calendar)
    {
        var syncToken = calendar.SyncToken;
        var pageToken = string.Empty;
        var events = new List<CalendarEventDto>();
        var deletedIds = new List<string>();

        try
        {
            var request = _googleCalendar.Events.List(calendar.ExternalId);
            request.SyncToken = syncToken;
            request.PageToken = pageToken;
            request.TimeMin = DateTime.UtcNow.AddDays(-30);
            request.TimeMax = DateTime.UtcNow.AddDays(90);
            request.SingleEvents = true;

            Events eventsPage;
            do
            {
                request.PageToken = pageToken;
                eventsPage = await request.ExecuteAsync();

                if (eventsPage.Items != null)
                {
                    foreach (var evt in eventsPage.Items)
                    {
                        if (evt.Status == "cancelled")
                        {
                            deletedIds.Add(evt.Id);
                        }
                        else
                        {
                            events.Add(new CalendarEventDto
                            {
                                ExternalId = evt.Id,
                                Summary = evt.Summary,
                                StartTime = evt.Start.DateTime ?? DateTime.Parse(evt.Start.Date),
                                EndTime = evt.End.DateTime ?? DateTime.Parse(evt.End.Date),
                                IsBusy = evt.Transparency != "transparent",
                                IsTentative = evt.Status == "tentative"
                            });
                        }
                    }
                }

                pageToken = eventsPage.NextPageToken;
            } while (!string.IsNullOrEmpty(pageToken));

            await ApplySyncToDatabaseAsync(calendar.Id, events, deletedIds);

            calendar.SyncToken = eventsPage.NextSyncToken;
            calendar.LastSyncedAt = DateTime.UtcNow;
            await _db.SaveChangesAsync();

            return new SyncResult
            {
                EventsAdded = events.Count,
                EventsDeleted = deletedIds.Count,
                NewSyncToken = eventsPage.NextSyncToken
            };
        }
        catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.Gone)
        {
            _logger.LogWarning("Sync token expired for calendar {Id}, performing full sync", calendar.Id);
            return await FullSyncAsync(calendar);
        }
    }

    public async Task<string> CreateCalendarEventAsync(
        Calendar calendar, Booking booking, EventType eventType)
    {
        var evt = new Google.Apis.Calendar.v3.Data.Event
        {
            Summary = $"{eventType.Name} - {booking.BookerName}",
            Description = GenerateEventDescription(booking),
            Start = new Google.Apis.Calendar.v3.Data.EventDateTime
            {
                DateTime = booking.StartTime,
                TimeZone = booking.BookerTimezone
            },
            End = new Google.Apis.Calendar.v3.Data.EventDateTime
            {
                DateTime = booking.EndTime,
                TimeZone = booking.BookerTimezone
            },
            Attendees = new List<Google.Apis.Calendar.v3.Data.EventAttendee>
            {
                new() { Email = booking.BookerEmail }
            },
            Reminders = new Google.Apis.Calendar.v3.Data.Event.RemindersData
            {
                UseDefault = false,
                Overrides = new List<Google.Apis.Calendar.v3.Data.EventReminder>
                {
                    new() { Method = "email", Minutes = 30 },
                    new() { Method = "popup", Minutes = 10 }
                }
            }
        };

        var created = await _googleCalendar.Events.InsertAsync(
            calendar.ExternalId, evt).ExecuteAsync();
        return created.Id;
    }
}

Microsoft Outlook / Exchange Integration

Microsoft Graph API uses delta queries similar to Google's sync tokens. However, tokens expire after 30 days, and we must use the prefer: outlook.timezone header for consistent time zone handling.

C#
public class OutlookCalendarSyncService : ICalendarSyncService
{
    private readonly GraphServiceClient _graphClient;
    private readonly AppDbContext _db;

    public async Task<SyncResult> SyncCalendarAsync(Calendar calendar)
    {
        var deltaLink = calendar.SyncToken;
        var events = new List<CalendarEventDto>();
        var deletedIds = new List<string>();

        ICalendarDeltaRequestBuilder requestBuilder;
        
        if (string.IsNullOrEmpty(deltaLink))
        {
            requestBuilder = _graphClient
                .Users[calendar.ExternalId]
                .Calendar
                .CalendarView
                .Delta();
            
            requestBuilder.QueryParameters.StartDateTime =
                DateTime.UtcNow.AddDays(-30).ToString("o");
            requestBuilder.QueryParameters.EndDateTime =
                DateTime.UtcNow.AddDays(90).ToString("o");
        }
        else
        {
            requestBuilder = new CalendarDeltaRequestBuilder(
                deltaLink, _graphClient);
        }

        var deltaPage = await requestBuilder
            .Header("Prefer", "outlook.timezone=\"UTC\"")
            .GetAsync();

        while (deltaPage != null)
        {
            foreach (var evt in deltaPage.Value)
            {
                if (evt.AdditionalData.ContainsKey("@removed"))
                {
                    deletedIds.Add(evt.Id);
                }
                else
                {
                    events.Add(MapGraphEventToDto(evt));
                }
            }

            if (deltaPage.OdataNextLink != null)
            {
                deltaPage = await new CalendarDeltaRequestBuilder(
                    deltaPage.OdataNextLink, _graphClient).GetAsync();
            }
            else
            {
                calendar.SyncToken = deltaPage.OdataDeltaLink;
                break;
            }
        }

        await ApplySyncToDatabaseAsync(calendar.Id, events, deletedIds);
        return new SyncResult { EventsAdded = events.Count, EventsDeleted = deletedIds.Count };
    }
}

iCal Integration

iCal (RFC 5545) is read-only — we can consume .ics feeds but cannot write back. This is used for Apple Calendar and any CalDAV-compatible source.

C#
public class ICalSyncService : ICalendarSyncService
{
    private readonly HttpClient _httpClient;
    private readonly AppDbContext _db;

    public async Task<SyncResult> SyncCalendarAsync(Calendar calendar)
    {
        var icsContent = await _httpClient.GetStringAsync(calendar.ExternalId);
        var calendarObj = Calendar.Load(icsContent);
        
        var events = calendarObj.Events
            .Where(e => e.End.Value > DateTime.UtcNow.AddDays(-30)
                     && e.Start.Value < DateTime.UtcNow.AddDays(90))
            .Select(e => new CalendarEventDto
            {
                ExternalId = e.Uid,
                Summary = e.Summary,
                StartTime = e.Start.AsUtc,
                EndTime = e.End.AsUtc,
                IsBusy = e.Transparency != TransparencyType.Transparent,
                IsTentative = e.Status == StatusType.Tentative
            })
            .ToList();

        await ApplySyncToDatabaseAsync(calendar.Id, events, new List<string>());
        return new SyncResult { EventsAdded = events.Count };
    }
}

Sync Strategy Comparison

ProviderAuthDelta MechanismRate LimitsPush Notifications
Google CalendarOAuth2Sync tokens1M req/day, 500/100sYes (Watch API)
Outlook (MS Graph)OAuth2Delta links10K req/min per appYes (Change Notifications)
iCal / CalDAVURL or Basic AuthFull re-fetchNo formal limitsNo
Watch Out: Sync Token Expiration

Google sync tokens expire after 30 days of inactivity. Microsoft delta links expire after 30 days. If your sync falls behind, you must detect the Gone error and perform a full re-sync. Always implement exponential backoff and circuit breakers around calendar API calls.

6. Availability Management

Availability computation is the most CPU-intensive and latency-sensitive part of the system. When a booker opens a scheduling page, we need to show them available time slots within 200 milliseconds. This means we cannot compute availability on-the-fly from raw calendar events — we need a pre-computed, cached approach.

Availability Components

graph LR A[Working Hours Rules] --> D[Availability Engine] B[Calendar Events - External] --> D C[Date Overrides] --> D E[Buffer Times] --> D F[Booking History] --> D G[Date Range Requested] --> D D --> H[Available Time Slots] H --> I[Redis Cache] I --> J[Booking Page Response]

Computing Available Slots

C#
public class AvailabilityService : IAvailabilityService
{
    private readonly AppDbContext _db;
    private readonly IDistributedCache _cache;

    public async Task<List<TimeSlot>> GetAvailableSlotsAsync(
        Guid eventTypeId, DateTime dateFrom, DateTime dateTo)
    {
        var cacheKey = $"availability:{eventTypeId}:{dateFrom:yyyyMMdd}:{dateTo:yyyyMMdd}";
        var cached = await _cache.GetStringAsync(cacheKey);
        if (cached != null)
            return JsonSerializer.Deserialize<List<TimeSlot>>(cached);

        var eventType = await _db.EventTypes
            .Include(e => e.AvailabilityRules)
            .Include(e => e.DateOverrides)
            .FirstOrDefaultAsync(e => e.Id == eventTypeId);

        if (eventType == null) throw new NotFoundException("Event type not found");

        var potentialSlots = GenerateSlotsFromRules(eventType, dateFrom, dateTo);
        potentialSlots = ApplyDateOverrides(potentialSlots, eventType.DateOverrides, dateFrom, dateTo);

        var busyTimes = await GetBusyTimesAsync(eventType.UserId, dateFrom, dateTo);
        potentialSlots = RemoveConflicts(potentialSlots, busyTimes);

        var existingBookings = await _db.Bookings
            .Where(b => b.HostUserId == eventType.UserId
                     && b.Status.In("confirmed", "pending")
                     && b.EndTime > dateFrom && b.StartTime < dateTo)
            .ToListAsync();
        potentialSlots = RemoveBookingConflicts(potentialSlots, existingBookings);
        potentialSlots = ApplyBuffers(potentialSlots, eventType, existingBookings);
        potentialSlots = FilterByAdvanceWindow(potentialSlots, eventType);
        potentialSlots = ApplySlotInterval(potentialSlots, eventType.SlotInterval);

        await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(potentialSlots),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60) });

        return potentialSlots;
    }

    private List<TimeSlot> GenerateSlotsFromRules(
        EventType eventType, DateTime dateFrom, DateTime dateTo)
    {
        var slots = new List<TimeSlot>();
        for (var date = dateFrom.Date; date <= dateTo.Date; date = date.AddDays(1))
        {
            var dayOfWeek = (int)date.DayOfWeek;
            foreach (var rule in eventType.AvailabilityRules.Where(r => r.DayOfWeek == dayOfWeek))
            {
                var slotStart = DateTime.SpecifyKind(date.Add(rule.StartTime.ToTimeSpan()), DateTimeKind.Utc);
                var slotEnd = DateTime.SpecifyKind(date.Add(rule.EndTime.ToTimeSpan()), DateTimeKind.Utc);

                for (var start = slotStart; start.AddMinutes(eventType.DurationMin) <= slotEnd;
                     start = start.AddMinutes(eventType.SlotInterval))
                {
                    slots.Add(new TimeSlot
                    {
                        Start = start,
                        End = start.AddMinutes(eventType.DurationMin),
                        EventType = TimeSlotType.Available
                    });
                }
            }
        }
        return slots;
    }

    private List<TimeSlot> ApplyBuffers(
        List<TimeSlot> slots, EventType eventType, List<Booking> existingBookings)
    {
        return slots.Where(slot =>
        {
            var bufferedStart = slot.Start.AddMinutes(-eventType.BufferBefore);
            var bufferedEnd = slot.End.AddMinutes(eventType.BufferAfter);
            return !existingBookings.Any(b =>
                bufferedStart < b.EndTime && bufferedEnd > b.StartTime);
        }).ToList();
    }
}

Cache Invalidation Triggers

TriggerScopeMethod
Calendar syncAffected user, date rangeRedis key pattern delete
Working hours changedAffected user, future datesRedis key pattern delete
Date override addedAffected user, specific dateRedis key pattern delete
New booking / cancellationAffected host, date rangeRedis key pattern delete
Pro Tip: Two-Layer Caching

Use an L1 in-memory cache in front of Redis for hot event types. This reduces Redis round-trips by 80%+ for popular hosts. Invalidation publishes to a Redis Pub/Sub channel, and all instances subscribe to evict their L1 caches.

7. Time Zone Handling

Time zones are the single most common source of scheduling bugs. The fundamental rule: store everything in UTC, convert only for display.

graph TB A[Host in New York] -->|Stores availability in UTC| B[(Database - UTC)] C[Booker in London] -->|Views slots| D[Availability Service] D -->|Converts UTC to GMT| E[Display: 2pm GMT] F[Booker in Tokyo] -->|Views same slots| G[Availability Service] G -->|Converts UTC to JST| H[Display: 11pm JST] I[DST Transition] -->|Spring Forward| J[Skip gap slots] I -->|Fall Back| K[Deduplicate ambiguous slots]
C#
public class TimeZoneService : ITimeZoneService
{
    public DateTime HostLocalToUtc(DateTime hostLocalTime, string hostTimezone)
    {
        var tz = TimeZoneInfo.FindSystemTimeZoneById(
            TimeZoneConverter.TZConvert.IanaToWindows(hostTimezone));
        return TimeZoneInfo.ConvertTimeToUtc(hostLocalTime, tz);
    }

    public DateTime UtcToBookerLocal(DateTime utcTime, string bookerTimezone)
    {
        var tz = TimeZoneInfo.FindSystemTimeZoneById(
            TimeZoneConverter.TZConvert.IanaToWindows(bookerTimezone));
        return TimeZoneInfo.ConvertTimeFromUtc(utcTime, tz);
    }

    public string DetectBookerTimezone(string? browserTimezone, string? ipAddress)
    {
        if (!string.IsNullOrEmpty(browserTimezone))
        {
            try
            {
                TimeZoneInfo.FindSystemTimeZoneById(
                    TimeZoneConverter.TZConvert.IanaToWindows(browserTimezone));
                return browserTimezone;
            }
            catch (TimeZoneNotFoundException) { }
        }
        if (!string.IsNullOrEmpty(ipAddress))
        {
            var geoTimezone = LookupTimezoneFromIp(ipAddress);
            if (geoTimezone != null) return geoTimezone;
        }
        return "UTC";
    }

    public List<TimeSlot> HandleDstTransitions(
        List<TimeSlot> slots, string hostTimezone)
    {
        var tz = TimeZoneInfo.FindSystemTimeZoneById(
            TimeZoneConverter.TZConvert.IanaToWindows(hostTimezone));
        var result = new List<TimeSlot>();

        foreach (var slot in slots)
        {
            var slotStartLocal = TimeZoneInfo.ConvertTimeFromUtc(slot.Start, tz);
            var slotEndLocal = TimeZoneInfo.ConvertTimeFromUtc(slot.End, tz);

            if (Math.Abs((slotEndLocal - slotStartLocal).TotalMinutes) < slot.DurationMinutes)
                continue; // Slot falls in DST gap - skip

            result.Add(slot);
        }
        return result;
    }
}

Common Time Zone Pitfalls

PitfallExampleSolution
DST Spring Forward2:00 AM becomes 3:00 AMDetect gap and skip slot
DST Fall Back2:00 AM occurs twiceAlways resolve to first occurrence in UTC
UTC Offset DriftNepal is UTC+5:45, India is UTC+5:30Use IANA timezone IDs, not manual offsets
Half-Hour TimezonesIndia, Iran, MyanmarSupport non-whole-hour offsets in slot generation
No DST ObservationArizona, HawaiiUse IANA IDs which encode DST rules correctly
Historical ChangesRussia changed zones in 2014Use NodaTime or TZConvert library

8. Booking Flow & Scheduling Types

The booking flow is the critical path of the entire system. It must be fast, idempotent, and race-condition-free.

sequenceDiagram participant B as Booker participant FE as Frontend participant API as API Gateway participant BS as Booking Service participant AS as Availability Service participant CS as Calendar Sync participant NS as Notification Service participant PS as Payment Service participant DB as PostgreSQL participant Redis as Redis Cache B->>FE: Select date and time slot FE->>API: POST /bookings API->>BS: Create booking request BS->>DB: BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE BS->>DB: SELECT ... FOR UPDATE (check slot) BS->>DB: INSERT INTO bookings (status=pending) BS->>DB: COMMIT TRANSACTION alt Payment Required BS->>PS: Create payment intent PS-->>BS: Payment intent ID BS-->>FE: Return payment URL B->>PS: Complete payment PS->>BS: Payment confirmed BS->>DB: UPDATE status=confirmed end BS->>Redis: Invalidate availability cache BS->>CS: Create event in host's calendar CS-->>BS: External event ID BS->>NS: Send confirmation emails BS-->>FE: Booking confirmed FE-->>B: Show confirmation page

Double-Booking Prevention

C#
public class BookingService : IBookingService
{
    private readonly AppDbContext _db;
    private readonly IAvailabilityService _availability;
    private readonly IPublishEndpoint _publisher;

    public async Task<BookingResult> CreateBookingAsync(
        CreateBookingRequest request, string idempotencyKey)
    {
        var existing = await _db.Bookings
            .FirstOrDefaultAsync(b => b.IdempotencyKey == idempotencyKey);
        if (existing != null)
            return BookingResult.AlreadyExists(existing);

        await using var transaction = await _db.Database
            .BeginTransactionAsync(IsolationLevel.Serializable);

        try
        {
            var isAvailable = await _availability.CheckSlotAvailableAsync(
                request.HostUserId, request.StartTime, request.EndTime);

            if (!isAvailable)
            {
                await transaction.RollbackAsync();
                return BookingResult.SlotUnavailable(
                    await _availability.GetNextAvailableAsync(
                        request.HostUserId, request.StartTime, request.EndTime));
            }

            var booking = new Booking
            {
                Id = Guid.NewGuid(),
                EventTypeId = request.EventTypeId,
                BookerEmail = request.BookerEmail,
                BookerName = request.BookerName,
                BookerTimezone = request.BookerTimezone,
                BookerPhone = request.BookerPhone,
                HostUserId = request.HostUserId,
                StartTime = request.StartTime,
                EndTime = request.EndTime,
                Status = request.RequiresPayment ? "pending" : "confirmed",
                MeetingProvider = request.MeetingProvider,
                CustomAnswers = request.CustomAnswers,
                IdempotencyKey = idempotencyKey,
                Version = 1
            };

            _db.Bookings.Add(booking);
            await _db.SaveChangesAsync();
            await transaction.CommitAsync();

            await _publisher.Publish(new BookingCreatedEvent
            {
                BookingId = booking.Id,
                HostUserId = request.HostUserId,
                StartTime = request.StartTime,
                EndTime = request.EndTime,
                BookerEmail = request.BookerEmail,
                BookerName = request.BookerName,
                RequiresPayment = request.RequiresPayment
            });

            return BookingResult.Success(booking);
        }
        catch (DbUpdateException ex) when (IsUniqueViolation(ex))
        {
            await transaction.RollbackAsync();
            var nextSlot = await _availability.GetNextAvailableAsync(
                request.HostUserId, request.StartTime, request.EndTime);
            return BookingResult.SlotUnavailable(nextSlot);
        }
        catch (Exception)
        {
            await transaction.RollbackAsync();
            throw;
        }
    }
}

Scheduling Types

TypeDescriptionAlgorithmUse Case
One-on-OneSingle host, single bookerCheck host's availability onlyConsultations, interviews
GroupSingle host, multiple bookersCheck host availability, cap at maxWebinars, group sessions
Round-RobinAssign to next available hostLeast-recently-assigned from poolSales calls, support
CollectiveAll hosts must be freeIntersect all host availabilitiesPanel interviews, joint meetings

Round-Robin Implementation

C#
public class RoundRobinService : IRoutingService
{
    private readonly AppDbContext _db;

    public async Task<Guid?> SelectHostAsync(
        Guid eventTypeId, DateTime startTime, DateTime endTime)
    {
        var teamMembers = await _db.EventTypeMembers
            .Where(m => m.EventTypeId == eventTypeId && m.IsActive)
            .OrderBy(m => m.Weight)
            .ToListAsync();

        if (!teamMembers.Any()) return null;

        var state = await _db.RoundRobinStates
            .FirstOrDefaultAsync(s => s.EventTypeId == eventTypeId);

        var lastAssignedIndex = state != null
            ? teamMembers.FindIndex(m => m.UserId == state.LastAssigned)
            : -1;

        for (int i = 1; i <= teamMembers.Count; i++)
        {
            var index = (lastAssignedIndex + i) % teamMembers.Count;
            var candidate = teamMembers[index];

            var isAvailable = await CheckMemberAvailableAsync(
                candidate.UserId, startTime, endTime);

            if (isAvailable)
            {
                if (state == null)
                {
                    state = new RoundRobinState
                    {
                        EventTypeId = eventTypeId,
                        LastAssigned = candidate.UserId
                    };
                    _db.RoundRobinStates.Add(state);
                }
                else
                {
                    state.LastAssigned = candidate.UserId;
                }

                await _db.SaveChangesAsync();
                return candidate.UserId;
            }
        }
        return null;
    }
}

Collective Scheduling

C#
public class CollectiveSchedulingService : IRoutingService
{
    public async Task<List<TimeSlot>> FindCollectiveAvailabilityAsync(
        List<Guid> hostUserIds, DateTime dateFrom, DateTime dateTo, int durationMinutes)
    {
        var hostAvailabilities = new Dictionary<Guid, List<TimeSlot>>();
        foreach (var hostId in hostUserIds)
        {
            hostAvailabilities[hostId] = await _availabilityService
                .GetRawAvailableSlotsAsync(hostId, dateFrom, dateTo);
        }

        var collectiveSlots = hostAvailabilities.Values.First().ToList();
        foreach (var hostSlots in hostAvailabilities.Values.Skip(1))
        {
            collectiveSlots = collectiveSlots
                .Where(cs => hostSlots.Any(hs =>
                    hs.Start == cs.Start && hs.End >= cs.End))
                .ToList();
        }

        return collectiveSlots
            .Where(s => (s.End - s.Start).TotalMinutes >= durationMinutes)
            .ToList();
    }
}

9. Booking Pages & Branding

Each user gets a public booking page at scheduling.example.com/username/event-slug. These pages must be fast, SEO-friendly, and highly customizable.

graph TB A[Booker visits /username/event-slug] --> B[Cloudflare Edge] B --> C[Nginx / Load Balancer] C --> D[Next.js SSR / SSG] D -->|API calls| E[Availability API] D -->|JavaScript| F[Booking Widget - React] F -->|POST| G[Booking API] subgraph "Branding" H[Custom Logo] I[Color Scheme] J[Custom Domain] K[Custom CSS] end H --> D I --> D J --> D K --> D
C#
public class BrandingConfig
{
    public string? CustomLogoUrl { get; set; }
    public string PrimaryColor { get; set; } = "#4F46E5";
    public string SecondaryColor { get; set; } = "#06B6D4";
    public string BackgroundColor { get; set; } = "#FFFFFF";
    public string TextColor { get; set; } = "#1F2937";
    public string? CustomFontFamily { get; set; }
    public string? CustomCss { get; set; }
    public string? CustomDomain { get; set; }
    public string? FaviconUrl { get; set; }
    public bool HideBranding { get; set; } = false;
    public string? WelcomeMessage { get; set; }
    public string? ConfirmationMessage { get; set; }
    public string? RedirectUrlAfterBooking { get; set; }
}

Custom Domain Support

Enterprise customers can use their own domains (e.g., book.theircompany.com). This requires:

  • DNS CNAME record pointing to our infrastructure
  • Automated SSL certificate provisioning via Let's Encrypt / ACM
  • TLS termination at the load balancer level
  • Host-based routing to resolve the correct tenant

10. Waiting Lists & Cancellation Policies

Waiting List System

When a date is fully booked, users can join a waiting list. If a cancellation occurs, we notify the next person in line.

C#
public class WaitlistService : IWaitlistService
{
    private readonly AppDbContext _db;
    private readonly INotificationService _notifications;

    public async Task<WaitlistResult> JoinWaitlistAsync(
        Guid eventTypeId, DateTime requestedDate, string email, string name)
    {
        var existing = await _db.WaitlistEntries
            .FirstOrDefaultAsync(w =>
                w.EventTypeId == eventTypeId
                && w.RequestedDate == requestedDate.Date
                && w.Email == email && w.Status == "waiting");

        if (existing != null)
            return WaitlistResult.AlreadyOnWaitlist(existing.Position);

        var maxPosition = await _db.WaitlistEntries
            .Where(w =>
                w.EventTypeId == eventTypeId
                && w.RequestedDate == requestedDate.Date
                && w.Status == "waiting")
            .MaxAsync(w => (int?)w.Position) ?? 0;

        var entry = new WaitlistEntry
        {
            Id = Guid.NewGuid(),
            EventTypeId = eventTypeId,
            RequestedDate = requestedDate.Date,
            Email = email,
            Name = name,
            Position = maxPosition + 1,
            Status = "waiting",
            ExpiresAt = DateTime.UtcNow.AddHours(24)
        };

        _db.WaitlistEntries.Add(entry);
        await _db.SaveChangesAsync();
        return WaitlistResult.Success(entry.Position);
    }

    public async Task<void> OnBookingCancelledAsync(Booking booking)
    {
        var nextInLine = await _db.WaitlistEntries
            .Where(w =>
                w.EventTypeId == booking.EventTypeId
                && w.RequestedDate == booking.StartTime.Date
                && w.Status == "waiting")
            .OrderBy(w => w.Position)
            .FirstOrDefaultAsync();

        if (nextInLine != null)
        {
            nextInLine.Status = "offered";
            nextInLine.NotifiedAt = DateTime.UtcNow;
            nextInLine.ExpiresAt = DateTime.UtcNow.AddHours(24);
            await _db.SaveChangesAsync();
            await _notifications.SendWaitlistOfferAsync(nextInLine, booking);
        }
    }
}

Cancellation Policies

PolicyDescriptionImplementation
Free CancellationCancel anytime before the meetingAllow cancel, no restrictions
24-Hour PolicyCancel up to 24 hours beforeCheck startTime - now > 24h
48-Hour PolicyCancel up to 48 hours beforeCheck startTime - now > 48h
No CancellationCannot cancel once bookedDisable cancel button entirely
Late FeeCharge a fee for late cancellationPartial refund via Stripe
C#
public class CancellationPolicyEvaluator
{
    public CancellationResult EvaluateCancellation(
        Booking booking, EventType eventType, DateTime currentTime)
    {
        var timeUntilMeeting = booking.StartTime - currentTime;
        
        return eventType.CancellationPolicy switch
        {
            "free" => CancellationResult.Allowed("full"),
            "24_hours" => timeUntilMeeting.TotalHours >= 24
                ? CancellationResult.Allowed("full")
                : CancellationResult.Denied("Must cancel at least 24 hours in advance"),
            "48_hours" => timeUntilMeeting.TotalHours >= 48
                ? CancellationResult.Allowed("full")
                : CancellationResult.Denied("Must cancel at least 48 hours in advance"),
            "late_fee" => timeUntilMeeting.TotalHours >= 24
                ? CancellationResult.Allowed("full")
                : timeUntilMeeting.TotalHours >= 2
                    ? CancellationResult.Allowed("partial", 50)
                    : CancellationResult.Denied("Too late to cancel"),
            _ => CancellationResult.Allowed("full")
        };
    }
}

11. Recurring Appointments

Recurring appointments allow bookers to schedule repeating meetings (weekly coaching, monthly check-ins). The complexity lies in handling cancellations of individual instances vs. the entire series.

C#
public class RecurringBookingService : IRecurringBookingService
{
    private readonly AppDbContext _db;

    public async Task<List<Booking>> CreateRecurringBookingsAsync(
        CreateRecurringBookingRequest request)
    {
        var bookings = new List<Booking>();
        Guid? previousBookingId = null;
        var currentStartTime = request.StartTime;

        for (int i = 0; i < request.TotalOccurrences; i++)
        {
            var endTime = currentStartTime.AddMinutes(request.DurationMinutes);

            var booking = new Booking
            {
                Id = Guid.NewGuid(),
                EventTypeId = request.EventTypeId,
                BookerEmail = request.BookerEmail,
                BookerName = request.BookerName,
                BookerTimezone = request.BookerTimezone,
                HostUserId = request.HostUserId,
                StartTime = currentStartTime,
                EndTime = endTime,
                Status = "confirmed"
            };

            _db.Bookings.Add(booking);
            bookings.Add(booking);

            if (i == 0)
            {
                _db.RecurringSchedules.Add(new RecurringSchedule
                {
                    Id = Guid.NewGuid(),
                    BookingId = booking.Id,
                    Frequency = request.Frequency,
                    EndDate = request.EndDate
                });
            }

            if (previousBookingId.HasValue)
            {
                var prevSchedule = await _db.RecurringSchedules
                    .FirstOrDefaultAsync(r => r.BookingId == previousBookingId.Value);
                if (prevSchedule != null)
                    prevSchedule.NextInstanceId = booking.Id;
            }

            previousBookingId = booking.Id;

            currentStartTime = request.Frequency switch
            {
                "weekly" => currentStartTime.AddDays(7),
                "biweekly" => currentStartTime.AddDays(14),
                "monthly" => currentStartTime.AddMonths(1),
                _ => throw new ArgumentException($"Unknown frequency: {request.Frequency}")
            };

            if (request.EndDate.HasValue && currentStartTime > request.EndDate.Value)
                break;
        }

        await _db.SaveChangesAsync();
        return bookings;
    }

    public async Task<CancelRecurringResult> CancelRecurringAsync(
        Guid bookingId, string cancelScope, string reason)
    {
        var booking = await _db.Bookings.FindAsync(bookingId);
        if (booking == null) throw new NotFoundException("Booking not found");

        switch (cancelScope)
        {
            case "this_only":
                await CancelSingleBookingAsync(booking, reason);
                break;

            case "this_and_future":
                var futureIds = await GetFutureInstanceIdsAsync(bookingId);
                foreach (var id in futureIds)
                {
                    var instance = await _db.Bookings.FindAsync(id);
                    if (instance != null)
                        await CancelSingleBookingAsync(instance, reason);
                }
                break;

            case "all":
                var allIds = await GetAllInstanceIdsAsync(bookingId);
                foreach (var id in allIds)
                {
                    var instance = await _db.Bookings.FindAsync(id);
                    if (instance != null)
                        await CancelSingleBookingAsync(instance, reason);
                }
                break;
        }

        return new CancelRecurringResult { CancelledCount = await GetAllInstanceIdsAsync(bookingId).ContinueWith(t => t.Result.Count) };
    }
}

12. Reminders & Notifications

Reminders are critical for reducing no-shows. We need a reliable, multi-channel notification system that can schedule messages at precise times.

graph TB A[Booking Created/Modified] --> B[Notification Scheduler] B -->|Immediate| C[Confirmation Email] B -->|24h before| D[Reminder Email] B -->|1h before| E[SMS Reminder] B -->|15min before| F[Push Notification] B -->|On cancellation| G[Cancellation Notice] C --> H[SendGrid - Email] D --> H E --> I[Twilio - SMS] F --> J[FCM / APNs - Push] G --> H G --> I K[Failed Delivery] --> L[Retry Queue] L -->|3 retries| C L -->|3 retries| E
C#
public class NotificationScheduler : INotificationScheduler
{
    private readonly AppDbContext _db;
    private readonly IKafkaProducer _kafka;

    public async Task ScheduleBookingNotificationsAsync(Booking booking, EventType eventType)
    {
        var notifications = new List<ScheduledNotification>
        {
            new() // Immediate confirmation
            {
                BookingId = booking.Id, Type = "confirmation", Channel = "email",
                ScheduledFor = DateTime.UtcNow,
                RecipientEmail = booking.BookerEmail,
                TemplateId = "booking_confirmation"
            },
            new() // Host notification
            {
                BookingId = booking.Id, Type = "confirmation", Channel = "email",
                ScheduledFor = DateTime.UtcNow,
                RecipientEmail = eventType.HostEmail,
                TemplateId = "new_booking_host"
            },
            new() // 24-hour reminder
            {
                BookingId = booking.Id, Type = "reminder_24h", Channel = "email",
                ScheduledFor = booking.StartTime.AddHours(-24),
                RecipientEmail = booking.BookerEmail,
                TemplateId = "reminder_24h"
            },
            new() // 1-hour reminder (SMS)
            {
                BookingId = booking.Id, Type = "reminder_1h", Channel = "sms",
                ScheduledFor = booking.StartTime.AddHours(-1),
                RecipientPhone = booking.BookerPhone,
                TemplateId = "reminder_1h_sms"
            },
            new() // 15-minute reminder (push)
            {
                BookingId = booking.Id, Type = "reminder_15m", Channel = "push",
                ScheduledFor = booking.StartTime.AddMinutes(-15),
                RecipientUserId = booking.HostUserId,
                TemplateId = "reminder_15m_push"
            }
        };

        _db.ScheduledNotifications.AddRange(notifications);
        await _db.SaveChangesAsync();
    }
}

public class NotificationProcessor : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var due = await _db.ScheduledNotifications
                .Where(n => n.Status == "scheduled"
                    && n.ScheduledFor <= DateTime.UtcNow
                    && n.AttemptCount < 3)
                .Take(100)
                .ToListAsync();

            foreach (var notification in due)
            {
                try
                {
                    await SendNotificationAsync(notification);
                    notification.Status = "sent";
                    notification.SentAt = DateTime.UtcNow;
                }
                catch (Exception ex)
                {
                    notification.AttemptCount++;
                    notification.LastError = ex.Message;
                    if (notification.AttemptCount >= 3)
                        notification.Status = "failed";
                }
            }
            await _db.SaveChangesAsync();
            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

Notification Channels Comparison

ChannelProviderLatencyCostOpen Rate
EmailSendGrid / SES1-5 seconds$0.0001/email~50%
SMSTwilio1-3 seconds$0.0075/SMS (US)~98%
Push (iOS)APNs1-10 secondsFree~40%
Push (Android)FCM1-10 secondsFree~40%
WhatsAppTwilio API1-5 seconds$0.005/conversation~90%

13. Payment Collection at Booking

Many scheduling platforms (especially for coaching, consulting, and healthcare) require payment at the time of booking. We integrate with Stripe for payment processing.

sequenceDiagram participant B as Booker participant API as Booking API participant PS as Payment Service participant Stripe as Stripe API participant BS as Booking Service B->>API: POST /bookings (with payment required) API->>PS: Create payment intent PS->>Stripe: POST /payment_intents Stripe-->>PS: client_secret PS->>API: Return client_secret + booking_id API-->>B: Redirect to Stripe Checkout B->>Stripe: Complete payment Stripe->>PS: Webhook: payment_intent.succeeded PS->>BS: Confirm booking BS->>BS: Emit BookingConfirmedEvent
C#
public class PaymentService : IPaymentService
{
    private readonly StripeClient _stripe;
    private readonly AppDbContext _db;

    public async Task<PaymentResult> CreatePaymentIntentAsync(
        Booking booking, EventType eventType)
    {
        var paymentIntent = await _stripe.PaymentIntents.CreateAsync(
            new PaymentIntentCreateOptions
            {
                Amount = eventType.PriceCents,
                Currency = eventType.Currency.ToLower(),
                AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions
                {
                    Enabled = true,
                },
                Metadata = new Dictionary<string, string>
                {
                    { "booking_id", booking.Id.ToString() },
                    { "event_type_id", eventType.Id.ToString() },
                    { "booker_email", booking.BookerEmail }
                },
                ReceiptEmail = booking.BookerEmail,
                Description = $"{eventType.Name} - {booking.StartTime:yyyy-MM-dd HH:mm} UTC"
            });

        var payment = new Payment
        {
            Id = Guid.NewGuid(),
            BookingId = booking.Id,
            StripePaymentIntent = paymentIntent.Id,
            AmountCents = eventType.PriceCents,
            Currency = eventType.Currency,
            Status = "pending"
        };

        _db.Payments.Add(payment);
        await _db.SaveChangesAsync();

        return new PaymentResult { ClientSecret = paymentIntent.ClientSecret, PaymentId = payment.Id };
    }

    public async Task<RefundResult> ProcessRefundAsync(Guid bookingId, RefundType refundType)
    {
        var payment = await _db.Payments
            .FirstOrDefaultAsync(p => p.BookingId == bookingId && p.Status == "succeeded");

        if (payment == null)
            return RefundResult.NoPaymentFound();

        int refundAmount = refundType switch
        {
            RefundType.Full => payment.AmountCents,
            RefundType.Partial => payment.AmountCents / 2,
            RefundType.LateFee => payment.AmountCents * 80 / 100,
            _ => payment.AmountCents
        };

        await _stripe.Refunds.CreateAsync(new RefundCreateOptions
        {
            PaymentIntent = payment.StripePaymentIntent,
            Amount = refundAmount,
            Reason = RefundReasons.RequestedByCustomer
        });

        payment.RefundAmountCents += refundAmount;
        payment.Status = payment.RefundAmountCents >= payment.AmountCents
            ? "refunded" : "partially_refunded";
        await _db.SaveChangesAsync();

        return RefundResult.Success(refundAmount);
    }

    public async Task HandleWebhookAsync(string payload, string signature)
    {
        var stripeEvent = Event.ConstructEvent(payload, signature, _webhookSecret);

        if (stripeEvent.Type == Events.PaymentIntentSucceeded)
        {
            var pi = stripeEvent.Data.Object as PaymentIntent;
            var bookingId = pi.Metadata["booking_id"];

            var payment = await _db.Payments
                .FirstOrDefaultAsync(p => p.StripePaymentIntent == pi.Id);

            if (payment != null)
            {
                payment.Status = "succeeded";
                await _db.SaveChangesAsync();
                await _bookingService.ConfirmBookingAsync(Guid.Parse(bookingId));
            }
        }
    }
}

14. No-Show Protection

No-shows cost businesses billions annually. A scheduling platform must have robust mechanisms to detect, prevent, and charge for no-shows.

LayerMechanismTiming
PreventionMulti-channel reminders24h, 1h, 15min before
PreventionCalendar invite auto-addOn booking
DetectionMeeting provider attendance checkMeeting start + 15min
DetectionManual mark as no-show by hostAfter meeting
EnforcementCharge no-show feeAfter marking
EnforcementRequire card on file for futureOn first no-show
RecoveryAuto-offer slot to waitlistImmediate
C#
public class NoShowProtectionService : INoShowProtectionService
{
    private readonly AppDbContext _db;

    public async Task MarkAsNoShowAsync(Guid bookingId, string detectedBy)
    {
        var booking = await _db.Bookings.FindAsync(bookingId);
        if (booking == null || booking.Status != "confirmed") return;

        booking.Status = "noshow";
        booking.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync();

        var eventType = await _db.EventTypes.FindAsync(booking.EventTypeId);
        if (eventType?.NoShowFeeCents > 0 && booking.Payment != null)
        {
            await _paymentService.ChargeNoShowFeeAsync(booking.Id, eventType.NoShowFeeCents);
        }

        var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == booking.BookerEmail);
        if (user != null)
        {
            user.NoShowCount++;
            user.TotalBookings++;
            if ((double)user.NoShowCount / user.TotalBookings > 0.2)
                user.RequiresCardOnFile = true;
            await _db.SaveChangesAsync();
        }

        await _publisher.Publish(new NoShowDetectedEvent
        {
            BookingId = bookingId, DetectedBy = detectedBy,
            BookerEmail = booking.BookerEmail
        });
    }

    public async Task CheckMeetingAttendanceAsync(Guid bookingId)
    {
        var booking = await _db.Bookings
            .Include(b => b.EventType)
            .FirstOrDefaultAsync(b => b.Id == bookingId);

        if (booking?.MeetingProvider == null) return;

        bool attended = booking.MeetingProvider switch
        {
            "zoom" => await _zoomService.CheckAttendanceAsync(booking.MeetingUrl),
            "teams" => await _teamsService.CheckAttendanceAsync(booking.MeetingUrl),
            _ => false
        };

        if (!attended && booking.StartTime.AddMinutes(30) < DateTime.UtcNow)
            await MarkAsNoShowAsync(bookingId, "auto_detected");
    }
}

15. Rescheduling & Cancellation Flows

graph TB A[Reschedule Request] --> B{Who initiated?} B -->|Host| C[Send reschedule link to booker] B -->|Booker| D[Show available slots] C --> E[Booker selects new time] D --> E E --> F{Original slot} F -->|Before cutoff| G[Cancel original, book new] F -->|After cutoff| H[Deny with message] G --> I[Cancel original booking] G --> J[Create new booking] I --> K[Release slot, notify waitlist] J --> L[Sync to calendars] J --> M[Send updated confirmation]
C#
public class ReschedulingService : IReschedulingService
{
    private readonly AppDbContext _db;
    private readonly IBookingService _bookingService;
    private readonly IAvailabilityService _availability;

    public async Task<RescheduleResult> RescheduleBookingAsync(
        Guid bookingId, DateTime newStartTime, string initiatedBy)
    {
        var booking = await _db.Bookings
            .Include(b => b.EventType)
            .FirstOrDefaultAsync(b => b.Id == bookingId);

        if (booking == null) return RescheduleResult.NotFound();

        var policy = EvaluateReschedulePolicy(booking, initiatedBy);
        if (!policy.Allowed) return RescheduleResult.Denied(policy.Reason);

        var isAvailable = await _availability.CheckSlotAvailableAsync(
            booking.HostUserId, newStartTime,
            newStartTime.AddMinutes(booking.EventType.DurationMin));

        if (!isAvailable) return RescheduleResult.SlotUnavailable();

        var oldBooking = booking.Clone();
        booking.Status = "rescheduled";
        booking.CancelledAt = DateTime.UtcNow;
        booking.CancellationReason = $"Rescheduled by {initiatedBy}";

        var newBooking = await _bookingService.CreateBookingAsync(
            new CreateBookingRequest
            {
                EventTypeId = booking.EventTypeId,
                BookerEmail = booking.BookerEmail,
                BookerName = booking.BookerName,
                BookerTimezone = booking.BookerTimezone,
                HostUserId = booking.HostUserId,
                StartTime = newStartTime,
                EndTime = newStartTime.AddMinutes(booking.EventType.DurationMin)
            },
            idempotencyKey: Guid.NewGuid().ToString());

        newBooking.RescheduledFrom = booking.Id;
        await _db.SaveChangesAsync();

        await _notifications.SendRescheduleNotificationAsync(booking, newBooking, initiatedBy);
        return RescheduleResult.Success(newBooking);
    }

    private ReschedulePolicy EvaluateReschedulePolicy(Booking booking, string initiatedBy)
    {
        if (initiatedBy == "host") return ReschedulePolicy.Allowed();

        var hoursUntilMeeting = (booking.StartTime - DateTime.UtcNow).TotalHours;
        return booking.EventType.ReschedulingPolicy switch
        {
            "unlimited" => ReschedulePolicy.Allowed(),
            "24_hours" => hoursUntilMeeting >= 24
                ? ReschedulePolicy.Allowed()
                : ReschedulePolicy.Denied("Too close to meeting time"),
            "48_hours" => hoursUntilMeeting >= 48
                ? ReschedulePolicy.Allowed()
                : ReschedulePolicy.Denied("Too close to meeting time"),
            _ => ReschedulePolicy.Allowed()
        };
    }
}

16. Team Scheduling & Routing

graph TB subgraph "Team Configuration" A[Event Type] B[Team Members] C[Routing Strategy] end subgraph "Routing Logic" D{Strategy?} D -->|Round Robin| E[Least Recently Assigned] D -->|Collective| F[Intersect All Availabilities] D -->|Load Balanced| G[Least Busy Member] D -->|Specific| H[Assigned Member] end subgraph "Constraints" I[Member Availability] J[Capacity Limits] K[Skills / Tags] end A --> D B --> D I --> D J --> D K --> D E --> M[Selected Host] F --> M G --> M H --> M
C#
public class LoadBalancedRoundRobinService : IRoutingService
{
    private readonly AppDbContext _db;

    public async Task<Guid?> SelectHostAsync(
        Guid eventTypeId, DateTime startTime, DateTime endTime)
    {
        var members = await _db.EventTypeMembers
            .Where(m => m.EventTypeId == eventTypeId && m.IsActive)
            .ToListAsync();

        var scoredMembers = new List<(Guid UserId, double Score)>();

        foreach (var member in members)
        {
            var isAvailable = await CheckMemberAvailableAsync(
                member.UserId, startTime, endTime);
            if (!isAvailable) continue;

            var upcomingBookings = await _db.Bookings
                .CountAsync(b =>
                    b.HostUserId == member.UserId
                    && b.Status == "confirmed"
                    && b.StartTime > DateTime.UtcNow
                    && b.StartTime < DateTime.UtcNow.AddDays(7));

            var roundRobinWeight = member.LastAssignedAt == null
                ? 0
                : (DateTime.UtcNow - member.LastAssignedAt.Value).TotalHours;

            var score = (roundRobinWeight * 2) - upcomingBookings;
            scoredMembers.Add((member.UserId, score));
        }

        var selected = scoredMembers.OrderByDescending(s => s.Score).FirstOrDefault();
        if (selected.UserId == Guid.Empty) return null;

        var state = await _db.RoundRobinStates
            .FirstOrDefaultAsync(s => s.EventTypeId == eventTypeId);
        if (state != null)
            state.LastAssigned = selected.UserId;
        else
            _db.RoundRobinStates.Add(new RoundRobinState
            {
                EventTypeId = eventTypeId,
                LastAssigned = selected.UserId
            });

        var memberRecord = members.First(m => m.UserId == selected.UserId);
        memberRecord.LastAssignedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync();
        return selected.UserId;
    }
}

17. Meeting Polling (Find Best Time)

sequenceDiagram participant Host participant Platform participant P1 as Participant 1 participant P2 as Participant 2 participant P3 as Participant 3 Host->>Platform: Create poll with 5 time options Platform-->>Host: Share link (poll/abc123) Host->>P1: Send poll link Host->>P2: Send poll link Host->>P3: Send poll link P1->>Platform: Vote: available for times 1, 2, 4 P2->>Platform: Vote: available for times 1, 3, 5 P3->>Platform: Vote: available for times 1, 2, 3 Platform->>Platform: Calculate consensus alt Consensus Found Platform->>Host: Time slot 1 has 3/3 votes! Host->>Platform: Confirm and book slot 1 Platform->>P1: Booking confirmed Platform->>P2: Booking confirmed Platform->>P3: Booking confirmed else No Consensus Platform->>Host: Top: slots 1 and 3 (2/3 votes each) end
C#
public class MeetingPollService : IMeetingPollService
{
    private readonly AppDbContext _db;

    public async Task<PollResult> CreatePollAsync(CreatePollRequest request)
    {
        var poll = new MeetingPoll
        {
            Id = Guid.NewGuid(),
            CreatedByUserId = request.HostUserId,
            Title = request.Title,
            DurationMinutes = request.DurationMinutes,
            Options = request.TimeOptions.Select(o => new PollOption
            {
                Id = Guid.NewGuid(),
                StartTime = o.StartTime,
                EndTime = o.EndTime,
                Votes = new List<PollVote>()
            }).ToList(),
            ShareCode = GenerateShareCode(),
            ExpiresAt = DateTime.UtcNow.AddDays(7),
            Status = "open"
        };

        _db.MeetingPolls.Add(poll);
        await _db.SaveChangesAsync();

        return new PollResult
        {
            PollId = poll.Id,
            ShareUrl = $"/polls/{poll.ShareCode}"
        };
    }

    public async Task<PollStatusResult> VoteAsync(
        Guid pollId, Guid optionId, string voterEmail, string voterName, bool isAvailable)
    {
        var poll = await _db.MeetingPolls
            .Include(p => p.Options).ThenInclude(o => o.Votes)
            .FirstOrDefaultAsync(p => p.Id == pollId);

        if (poll == null || poll.Status != "open" || poll.ExpiresAt < DateTime.UtcNow)
            return PollStatusResult.PollClosed();

        var existingVotes = poll.Options
            .SelectMany(o => o.Votes)
            .Where(v => v.VoterEmail == voterEmail).ToList();

        foreach (var v in existingVotes)
        {
            var opt = poll.Options.First(o => o.Votes.Contains(v));
            opt.Votes.Remove(v);
        }

        if (isAvailable)
        {
            var option = poll.Options.FirstOrDefault(o => o.Id == optionId);
            option?.Votes.Add(new PollVote
            {
                Id = Guid.NewGuid(),
                VoterEmail = voterEmail,
                VoterName = voterName,
                VotedAt = DateTime.UtcNow
            });
        }

        await _db.SaveChangesAsync();
        return await GetPollStatusAsync(pollId);
    }

    public async Task<PollStatusResult> GetPollStatusAsync(Guid pollId)
    {
        var poll = await _db.MeetingPolls
            .Include(p => p.Options).ThenInclude(o => o.Votes)
            .FirstOrDefaultAsync(p => p.Id == pollId);

        var totalVoters = poll.Options
            .SelectMany(o => o.Votes)
            .Select(v => v.VoterEmail)
            .Distinct().Count();

        var optionResults = poll.Options
            .Select(o => new PollOptionResult
            {
                OptionId = o.Id,
                StartTime = o.StartTime,
                EndTime = o.EndTime,
                VoteCount = o.Votes.Count,
                VoterNames = o.Votes.Select(v => v.VoterName).ToList()
            })
            .OrderByDescending(o => o.VoteCount).ToList();

        var bestOption = optionResults.FirstOrDefault();
        return new PollStatusResult
        {
            PollId = pollId,
            TotalVoters = totalVoters,
            Options = optionResults,
            HasUnanimousChoice = bestOption?.VoteCount == totalVoters && totalVoters > 0,
            BestOption = bestOption,
            Status = poll.Status
        };
    }
}

18. Embeddable Widgets

The embeddable widget is how most users interact with scheduling platforms. It must be lightweight (~25KB gzipped), secure, and customizable.

graph TB A[Customer Website] -->|script src=widget.js| B[Widget JS - 25KB] B -->|Creates iframe| C[Booking Page iframe] C -->|API calls| D[Scheduling Platform API] subgraph "Widget Loading" E[Script loads async] E --> F[Create shadow DOM or iframe] F --> G[Render calendar picker] G --> H[Show available slots] H --> I[Collect booker info] I --> J[Submit booking] end

Widget Embed Code

HTML
<!-- Inline embed -->
<div id="scheduling-widget"></div>
<script src="https://scheduling.example.com/widget.js"
    data-url="https://scheduling.example.com/john/meeting"
    data-color="#4F46E5"
    data-embed-type="inline"
    data-target="#scheduling-widget">
</script>

<!-- Popup button -->
<script src="https://scheduling.example.com/widget.js"
    data-url="https://scheduling.example.com/john/meeting"
    data-color="#4F46E5"
    data-embed-type="popup"
    data-text="Schedule a Meeting">
</script>

<!-- Text-only link -->
<a href="https://scheduling.example.com/john/meeting"
    class="scheduling-link">Click here to schedule</a>

Widget JavaScript Implementation

JavaScript
(function() {
    'use strict';

    const script = document.currentScript;
    const config = {
        url: script.getAttribute('data-url'),
        color: script.getAttribute('data-color') || '#4F46E5',
        embedType: script.getAttribute('data-embed-type') || 'popup',
        text: script.getAttribute('data-text') || 'Schedule',
        target: script.getAttribute('data-target'),
        utmSource: 'embed'
    };

    function createPopup() {
        const overlay = document.createElement('div');
        overlay.style.cssText = `
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.5); z-index: 999999;
            display: flex; align-items: center; justify-content: center;
        `;

        const iframe = document.createElement('iframe');
        iframe.src = `${config.url}?embed=true&utm_source=${config.utmSource}`;
        iframe.style.cssText = `
            width: 90%; max-width: 680px; height: 90vh;
            border: none; border-radius: 12px;
            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
        `;
        iframe.setAttribute('allow', 'payment');

        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) overlay.remove();
        });

        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') overlay.remove();
        });

        overlay.appendChild(iframe);
        document.body.appendChild(overlay);

        window.addEventListener('message', (event) => {
            if (event.data === 'scheduling.close') overlay.remove();
        });
    }

    function createInline() {
        const target = document.querySelector(config.target);
        if (!target) return;
        const iframe = document.createElement('iframe');
        iframe.src = `${config.url}?embed=true&utm_source=${config.utmSource}`;
        iframe.style.cssText = 'width: 100%; height: 700px; border: none;';
        iframe.setAttribute('allow', 'payment');
        target.appendChild(iframe);
    }

    function createButton() {
        const btn = document.createElement('button');
        btn.textContent = config.text;
        btn.style.cssText = `
            background: ${config.color}; color: white; border: none;
            padding: 12px 24px; border-radius: 8px; font-size: 16px;
            cursor: pointer; font-weight: 600;
        `;
        btn.addEventListener('click', createPopup);
        if (config.target)
            document.querySelector(config.target)?.appendChild(btn);
        else
            script.parentNode.insertBefore(btn, script.nextSibling);
    }

    if (config.embedType === 'inline') createInline();
    else if (config.embedType === 'popup') createButton();
})();

Security Considerations

  • X-Frame-Options: Control which domains can embed via frame-ancestors CSP
  • CORS: API endpoints must allow the parent domain
  • CSP: Strict Content Security Policy prevents XSS in the iframe
  • PostMessage: Strict origin checking for parent-iframe communication

19. CRM & Third-Party Integrations

graph TB subgraph "Scheduling Platform" A[Booking Service] B[Integration Service] C[Webhook Service] end subgraph "CRM Integrations" E[Salesforce] F[HubSpot] G[Pipedrive] end subgraph "Communication" H[Slack] I[Microsoft Teams] end subgraph "Automation" J[Zapier] K[Make.com] L[n8n] end A --> B B --> C C --> E C --> F C --> G C --> H C --> I C --> J C --> K C --> L

Webhook System

C#
public class WebhookService : IWebhookService
{
    private readonly AppDbContext _db;
    private readonly HttpClient _httpClient;

    public async Task<void> DispatchWebhookAsync(string eventType, object payload)
    {
        var subscriptions = await _db.WebhookSubscriptions
            .Where(w => w.Events.Contains(eventType) && w.IsActive)
            .ToListAsync();

        foreach (var subscription in subscriptions)
        {
            var webhook = new OutgoingWebhook
            {
                Id = Guid.NewGuid(),
                SubscriptionId = subscription.Id,
                EventType = eventType,
                Payload = JsonSerializer.Serialize(payload),
                Status = "pending",
                AttemptCount = 0,
                NextAttemptAt = DateTime.UtcNow
            };
            _db.OutgoingWebhooks.Add(webhook);
        }
        await _db.SaveChangesAsync();
    }

    public async Task ProcessPendingWebhooksAsync()
    {
        var pending = await _db.OutgoingWebhooks
            .Where(w => w.Status == "pending"
                && w.AttemptCount < 5
                && w.NextAttemptAt <= DateTime.UtcNow)
            .Take(50)
            .ToListAsync();

        foreach (var webhook in pending)
        {
            try
            {
                var signature = ComputeHmacSignature(webhook.Payload, webhook.Subscription.Secret);
                var response = await _httpClient.SendAsync(new HttpRequestMessage
                {
                    Method = HttpMethod.Post,
                    RequestUri = new Uri(webhook.Subscription.Url),
                    Content = new StringContent(webhook.Payload, Encoding.UTF8, "application/json"),
                    Headers =
                    {
                        { "X-Webhook-Signature", signature },
                        { "X-Webhook-Event", webhook.EventType },
                        { "X-Webhook-Id", webhook.Id.ToString() }
                    }
                });

                if (response.IsSuccessStatusCode)
                {
                    webhook.Status = "delivered";
                    webhook.DeliveredAt = DateTime.UtcNow;
                }
                else
                {
                    webhook.AttemptCount++;
                    var delays = new[] { 1, 5, 30, 120, 720 }; // minutes
                    var idx = Math.Min(webhook.AttemptCount - 1, delays.Length - 1);
                    webhook.NextAttemptAt = DateTime.UtcNow.AddMinutes(delays[idx]);
                    webhook.LastError = $"HTTP {(int)response.StatusCode}";
                }
            }
            catch (Exception ex)
            {
                webhook.AttemptCount++;
                webhook.LastError = ex.Message;
                webhook.NextAttemptAt = DateTime.UtcNow.AddMinutes(Math.Pow(2, webhook.AttemptCount));
            }
        }
        await _db.SaveChangesAsync();
    }
}

20. Zoom / Teams / Meet Integration

Auto-provisioning video meetings is a key feature. When a booking is confirmed, the platform automatically creates a meeting link in the configured provider.

C#
public interface IVideoMeetingProvider
{
    Task<MeetingInfo> CreateMeetingAsync(
        string topic, DateTime startTime, int durationMinutes,
        string hostEmail, bool requirePassword = true);
    Task<bool> CheckAttendanceAsync(string meetingId);
}

public class ZoomMeetingProvider : IVideoMeetingProvider
{
    private readonly ZoomApiClient _zoomClient;

    public async Task<MeetingInfo> CreateMeetingAsync(
        string topic, DateTime startTime, int durationMinutes,
        string hostEmail, bool requirePassword = true)
    {
        var accessToken = await GetAccessTokenForUser(hostEmail);

        var meeting = await _zoomClient.CreateMeetingAsync(accessToken, new
        {
            topic = topic,
            type = 2,
            start_time = startTime.ToString("yyyy-MM-ddTHH:mm:ssZ"),
            duration = durationMinutes,
            timezone = "UTC",
            settings = new
            {
                host_video = true,
                participant_video = true,
                join_before_host = false,
                waiting_room = true
            }
        });

        return new MeetingInfo
        {
            Provider = "zoom",
            MeetingId = meeting.Id.ToString(),
            JoinUrl = meeting.JoinUrl,
            Password = meeting.Password,
            DialInNumber = meeting.DialNumbers?.FirstOrDefault()?.Number
        };
    }

    public async Task<bool> CheckAttendanceAsync(string meetingId)
    {
        var participants = await _zoomClient.GetParticipantsAsync(meetingId);
        return participants.Any();
    }
}

public class TeamsMeetingProvider : IVideoMeetingProvider
{
    private readonly GraphServiceClient _graphClient;

    public async Task<MeetingInfo> CreateMeetingAsync(
        string topic, DateTime startTime, int durationMinutes,
        string hostEmail, bool requirePassword = true)
    {
        var onlineMeeting = await _graphClient.Users[hostEmail]
            .OnlineMeetings.PostAsync(new OnlineMeeting
            {
                Subject = topic,
                StartDateTime = startTime,
                EndDateTime = startTime.AddMinutes(durationMinutes),
                AllowMeetingChat = true
            });

        return new MeetingInfo
        {
            Provider = "teams",
            MeetingId = onlineMeeting.Id,
            JoinUrl = onlineMeeting.JoinUrl,
            DialInNumber = onlineMeeting.AudioConferencing?.TollNumber
        };
    }
}

public class GoogleMeetProvider : IVideoMeetingProvider
{
    public async Task<MeetingInfo> CreateMeetingAsync(
        string topic, DateTime startTime, int durationMinutes,
        string hostEmail, bool requirePassword = true)
    {
        var calendarEvent = new Google.Apis.Calendar.v3.Data.Event
        {
            Summary = topic,
            Start = new Google.Apis.Calendar.v3.Data.EventDateTime
            { DateTime = startTime, TimeZone = "UTC" },
            End = new Google.Apis.Calendar.v3.Data.EventDateTime
            { DateTime = startTime.AddMinutes(durationMinutes), TimeZone = "UTC" },
            ConferenceData = new Google.Apis.Calendar.v3.Data.ConferenceData
            {
                CreateRequest = new Google.Apis.Calendar.v3.Data.CreateConferenceRequest
                {
                    RequestId = Guid.NewGuid().ToString(),
                    ConferenceSolutionKey = new Google.Apis.Calendar.v3.Data.ConferenceSolutionKeyType
                    { Type = "hangoutsMeet" }
                }
            }
        };

        var created = await _calendarService.Events.InsertAsync(
            calendarEvent, "primary").ExecuteAsync();

        return new MeetingInfo
        {
            Provider = "google_meet",
            MeetingId = created.HangoutLink,
            JoinUrl = created.HangoutLink
        };
    }
}

Video Provider Comparison

ProviderAuthAuto-ProvisionAttendance CheckRecording
ZoomOAuth2YesYes (participant list)Cloud + local
Microsoft TeamsMS Graph OAuth2YesYes (meeting analytics)Cloud (requires policy)
Google MeetGoogle OAuth2Yes (via Calendar)LimitedRequires Workspace

21. Analytics & Reporting

graph TB subgraph "Event Sources" A[Booking Events - Kafka] B[Calendar Sync Events] C[Payment Events] D[Notification Events] E[Page View Events] end subgraph "Analytics Pipeline" F[Apache Kafka] G[Apache Flink] H[ClickHouse - OLAP] I[Pre-Aggregated Views] end subgraph "Dashboard" J[Analytics Dashboard - React] K[Report API] L[Scheduled Reports] end A --> F B --> F C --> F D --> F E --> F F --> G G --> H H --> I I --> K K --> J K --> L

Key Metrics

MetricFormulaTarget
Booking Conversion RateBookings / Page Views> 40%
No-Show RateNo-Shows / Total Bookings< 5%
Reschedule RateReschedules / Total Bookings< 10%
Cancellation RateCancellations / Total Bookings< 8%
Avg. Time to BookPage open to booking confirmation< 90 seconds
Calendar Sync LagEvent change to sync completion< 5 seconds
Notification Delivery RateDelivered / Sent> 99%
Revenue per BookingTotal Revenue / Total BookingsVaries

Analytics Queries

SQL
-- Daily booking funnel (ClickHouse)
SELECT
    toDate(created_at) AS date,
    countIf(status = 'confirmed') AS confirmed_bookings,
    countIf(status = 'cancelled') AS cancelled_bookings,
    countIf(status = 'noshow') AS no_show_bookings,
    round(
        countIf(status = 'noshow') * 100.0 /
        nullIf(countIf(status IN ('confirmed', 'noshow')), 0), 2
    ) AS no_show_rate
FROM bookings
WHERE created_at >= today() - 30
GROUP BY date ORDER BY date;

-- Revenue analytics
SELECT
    toDate(b.created_at) AS date,
    sum(p.amount_cents) / 100.0 AS revenue_usd,
    count(DISTINCT b.booker_email) AS unique_customers,
    avg(p.amount_cents) / 100.0 AS avg_order_value
FROM bookings b
JOIN payments p ON b.id = p.booking_id
WHERE p.status = 'succeeded' AND b.created_at >= today() - 30
GROUP BY date ORDER BY date;

-- Host performance
SELECT
    h.name AS host_name,
    count(b.id) AS total_bookings,
    countIf(b.status = 'noshow') AS no_shows,
    round(countIf(b.status = 'noshow') * 100.0 /
          nullIf(count(b.id), 0), 2) AS no_show_rate,
    avg(extract(epoch from (b.end_time - b.start_time)) / 60)
        AS avg_meeting_duration_min
FROM bookings b
JOIN users h ON b.host_user_id = h.id
WHERE b.created_at >= today() - 30
GROUP BY h.id, h.name
ORDER BY total_bookings DESC;

-- Peak booking hours
SELECT
    extract(hour FROM start_time AT TIME ZONE host.timezone) AS hour_of_day,
    toDayOfWeek(start_time AT TIME ZONE host.timezone) AS day_of_week,
    count(*) AS booking_count
FROM bookings b
JOIN users host ON b.host_user_id = host.id
WHERE b.status = 'confirmed' AND b.created_at >= today() - 90
GROUP BY hour_of_day, day_of_week
ORDER BY day_of_week, hour_of_day;

22. Multi-Location Support

C#
public class Location
{
    public Guid Id { get; set; }
    public Guid OrgId { get; set; }
    public string Name { get; set; }
    public string Address { get; set; }
    public string? TimeZone { get; set; }
    public GeoCoordinates? Coordinates { get; set; }
    public List<LocationResource> Resources { get; set; }
    public bool IsActive { get; set; }
}

public class LocationResource
{
    public Guid Id { get; set; }
    public Guid LocationId { get; set; }
    public string Name { get; set; }
    public string ResourceType { get; set; }
    public int Capacity { get; set; }
    public List<string> Capabilities { get; set; }
    public bool RequiresBooking { get; set; }
}

public class LocationService : ILocationService
{
    private readonly AppDbContext _db;

    public async Task<List<TimeSlot>> GetLocationAvailabilityAsync(
        Guid locationId, DateTime dateFrom, DateTime dateTo)
    {
        var location = await _db.Locations
            .Include(l => l.Resources)
            .FirstOrDefaultAsync(l => l.Id == locationId);

        var resourceAvailability = new Dictionary<Guid, List<TimeSlot>>();
        foreach (var resource in location.Resources.Where(r => r.RequiresBooking))
        {
            var slots = await _resourceService.GetResourceAvailabilityAsync(
                resource.Id, dateFrom, dateTo);
            resourceAvailability[resource.Id] = slots;
        }

        if (!resourceAvailability.Any())
            return await _availabilityService.GetOrganizationAvailabilityAsync(
                location.OrgId, dateFrom, dateTo);

        var result = resourceAvailability.Values.First();
        foreach (var slots in resourceAvailability.Values.Skip(1))
        {
            result = result.Where(s =>
                slots.Any(rs => rs.Start == s.Start && rs.End == s.End))
                .ToList();
        }
        return result;
    }

    public async Task<List<Location>> FindNearestLocationsAsync(
        double latitude, double longitude, int maxResults = 5)
    {
        return await _db.Locations
            .Where(l => l.IsActive)
            .OrderBy(l => HaversineDistance(
                l.Coordinates.Latitude, l.Coordinates.Longitude,
                latitude, longitude))
            .Take(maxResults)
            .ToListAsync();
    }
}

23. Admin Dashboard

Dashboard Components

ComponentData SourceRefresh Rate
Booking OverviewClickHouse pre-aggregatedReal-time
Team PerformanceClickHouse aggregate queries5 minutes
Revenue TrackerPayment events streamReal-time
Calendar Sync HealthPrometheus metrics30 seconds
No-Show TrackingBooking status changesReal-time
Notification DeliveryNotification metrics5 minutes
User ActivityPage view + action tracking1 minute
Integration HealthWebhook delivery rates5 minutes
C#
[ApiController]
[Route("api/admin")]
[Authorize(Roles = "admin")]
public class AdminDashboardController : ControllerBase
{
    [HttpGet("overview")]
    public async Task<ActionResult<DashboardOverview>> GetOverview(
        [FromQuery] DateTime? from, [FromQuery] DateTime? to)
    {
        var dateFrom = from ?? DateTime.UtcNow.AddDays(-30);
        var dateTo = to ?? DateTime.UtcNow;
        var overview = await _analyticsService.GetDashboardOverviewAsync(
            User.GetOrgId(), dateFrom, dateTo);
        return Ok(overview);
    }

    [HttpGet("team-performance")]
    public async Task<ActionResult<List<HostPerformanceMetric>> GetTeamPerformance(
        [FromQuery] DateTime? from, [FromQuery] DateTime? to)
    {
        var metrics = await _analyticsService.GetTeamPerformanceAsync(
            User.GetOrgId(),
            from ?? DateTime.UtcNow.AddDays(-30),
            to ?? DateTime.UtcNow);
        return Ok(metrics);
    }

    [HttpGet("calendar-sync-health")]
    public async Task<ActionResult<SyncHealthReport>> GetSyncHealth()
    {
        return Ok(await _calendarSyncService.GetSyncHealthAsync(User.GetOrgId()));
    }

    [HttpGet("revenue")]
    public async Task<ActionResult<RevenueReport>> GetRevenue(
        [FromQuery] DateTime? from, [FromQuery] DateTime? to)
    {
        return Ok(await _analyticsService.GetRevenueReportAsync(
            User.GetOrgId(),
            from ?? DateTime.UtcNow.AddDays(-30),
            to ?? DateTime.UtcNow));
    }
}

24. API Design

RESTful API Endpoints

MethodEndpointDescription
GET/api/v1/event-typesList user's event types
POST/api/v1/event-typesCreate event type
PUT/api/v1/event-types/{id}Update event type
GET/api/v1/event-types/{id}/availabilityGet available slots
POST/api/v1/bookingsCreate booking
GET/api/v1/bookings/{id}Get booking details
PATCH/api/v1/bookings/{id}/cancelCancel booking
PATCH/api/v1/bookings/{id}/rescheduleReschedule booking
POST/api/v1/bookings/{id}/no-showMark as no-show
GET/api/v1/bookingsList bookings (with filters)
POST/api/v1/calendars/{id}/syncTrigger calendar sync
GET/api/v1/waitlistGet waitlist entries
POST/api/v1/pollsCreate meeting poll
GET/api/v1/analytics/overviewAnalytics overview
GET/api/v1/webhooksList webhook subscriptions
POST/api/v1/webhooksCreate webhook subscription

Cursor-Based Pagination

C#
[ApiController]
[Route("api/v1/[controller]")]
public class BookingsController : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<PagedResult<BookingResponse>>> ListBookings(
        [FromQuery] string? status,
        [FromQuery] DateTime? from,
        [FromQuery] DateTime? to,
        [FromQuery] string? cursor,
        [FromQuery] int limit = 20)
    {
        var query = _db.Bookings
            .Where(b => b.HostUserId == User.GetUserId());

        if (!string.IsNullOrEmpty(status))
            query = query.Where(b => b.Status == status);
        if (from.HasValue)
            query = query.Where(b => b.StartTime >= from.Value);
        if (to.HasValue)
            query = query.Where(b => b.StartTime <= to.Value);

        if (!string.IsNullOrEmpty(cursor))
        {
            var cursorBooking = await _db.Bookings.FindAsync(Guid.Parse(cursor));
            if (cursorBooking != null)
                query = query.Where(b => b.CreatedAt > cursorBooking.CreatedAt);
        }

        var bookings = await query
            .OrderBy(b => b.CreatedAt)
            .Take(limit + 1)
            .ToListAsync();

        var hasNextPage = bookings.Count > limit;
        var items = hasNextPage ? bookings.Take(limit).ToList() : bookings;

        return Ok(new PagedResult<BookingResponse>
        {
            Items = items.Select(MapToResponse).ToList(),
            NextCursor = hasNextPage ? items.Last().Id.ToString() : null,
            HasNextPage = hasNextPage
        });
    }
}

Authentication Methods

  • JWT Tokens: For web app and mobile app sessions
  • API Keys: For programmatic access (prefix: sk_live_)
  • OAuth2: For third-party integrations
  • Webhook Signatures: HMAC-SHA256 on outbound webhooks

25. Security & Compliance (HIPAA)

graph TB subgraph "Perimeter Security" A[Cloudflare WAF] B[DDoS Protection] C[Rate Limiting] end subgraph "Application Security" D[JWT / API Key Auth] E[RBAC - Role-Based Access] F[Input Validation] G[CSRF Protection] end subgraph "Data Security" H[Encryption at Rest - AES-256] I[Encryption in Transit - TLS 1.3] J[Field-Level Encryption - PII] K[Data Masking in Logs] end subgraph "Infrastructure Security" L[VPC Isolation] M[Private Subnets] N[Secrets Management - Vault] O[Container Scanning] end subgraph "Compliance" P[SOC 2 Type II] Q[HIPAA BAA] R[GDPR Data Deletion] S[Audit Logging] end A --> D D --> H H --> L L --> P

HIPAA Compliance for Healthcare Scheduling

HIPAA Requirements for Healthcare Scheduling

If your platform handles Protected Health Information (PHI) — such as patient names, appointment types (psychiatric, oncology), provider names, or medical record numbers — you must comply with HIPAA. This includes signing a Business Associate Agreement (BAA) with your cloud providers.

HIPAA RequirementImplementation
Access ControlUnique user IDs, automatic logoff, encryption of PHI at rest
Audit ControlsLog all access to PHI (who, when, what, from where)
Integrity ControlsVerify PHI has not been altered or destroyed
Transmission SecurityTLS 1.2+ for all PHI in transit
Breach Notification60-day notification window for breaches affecting 500+ individuals
Minimum NecessaryOnly expose PHI needed for the specific function
De-identificationSafe Harbor or Expert Determination for analytics
C#
public class HipaaAuditService : IAuditService
{
    private readonly AppDbContext _db;

    public async Task LogAccessAsync(AuditEntry entry)
    {
        var sanitizedEntry = new AuditLog
        {
            Id = Guid.NewGuid(),
            Timestamp = DateTime.UtcNow,
            UserId = entry.UserId,
            Action = entry.Action,
            ResourceType = entry.ResourceType,
            ResourceId = entry.ResourceId,
            IpAddress = entry.IpAddress,
            UserAgent = entry.UserAgent,
            Details = SanitizePhi(entry.Details),
            PhiAccessed = entry.PhiAccessed,
            AccessJustification = entry.AccessJustification
        };

        _db.AuditLogs.Add(sanitizedEntry);
        await _db.SaveChangesAsync();
        await _siemClient.SendEventAsync(sanitizedEntry);
    }

    private string SanitizePhi(string? details)
    {
        if (string.IsNullOrEmpty(details)) return details ?? "";
        return details
            .Replace(EmailRegex(), "[REDACTED_EMAIL]")
            .Replace(PhoneRegex(), "[REDACTED_PHONE]")
            .Replace(SSNRegex(), "[REDACTED_SSN]");
    }
}

GDPR Data Deletion

C#
public class GdprService : IGdprService
{
    public async Task<DeletionResult> ProcessDeletionRequestAsync(string userEmail)
    {
        var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == userEmail);
        if (user == null) return DeletionResult.UserNotFound();

        user.Name = "[DELETED]";
        user.Email = $"deleted_{user.Id}@redacted.invalid";
        user.AvatarUrl = null;
        user.PhoneNumber = null;
        user.DeletedAt = DateTime.UtcNow;

        var bookings = await _db.Bookings
            .Where(b => b.HostUserId == user.Id || b.BookerEmail == userEmail)
            .ToListAsync();

        foreach (var booking in bookings)
        {
            booking.BookerName = "[DELETED]";
            booking.BookerEmail = $"deleted_{booking.Id}@redacted.invalid";
            booking.BookerPhone = null;
            booking.Notes = null;
            booking.CustomAnswers = JsonDocument.Parse("{}");
        }

        var calendars = await _db.Calendars
            .Where(c => c.UserId == user.Id).ToListAsync();
        _db.Calendars.RemoveRange(calendars);

        await _db.SaveChangesAsync();
        return DeletionResult.Success();
    }
}

26. Monitoring & Observability

graph TB subgraph "Metrics" A[Prometheus] B[Grafana Dashboards] C[AlertManager] end subgraph "Logging" D[Fluentd] E[Elasticsearch] F[Kibana] end subgraph "Tracing" G[OpenTelemetry] H[Jaeger] end A --> B A --> C D --> E E --> F G --> H

Key Alerts

AlertConditionSeverityAction
High booking failure rate> 1% of requests fail with 5xxCriticalPage on-call, rollback
Calendar sync lagSync delay > 5 minutesWarningCheck provider rate limits
Double-booking detectedUnique constraint violation spikeCriticalCheck transaction isolation
Redis cache hit rate dropHit rate < 80%WarningCheck cache memory, eviction
Notification delivery failure> 5% failuresWarningCheck provider status, retry queue
Stripe webhook lagPayment webhook delay > 30sCriticalCheck webhook endpoint health
API latency spikep99 > 500ms for 5 minutesWarningCheck DB queries, add replicas
Disk usage> 80% on databaseWarningArchive old data, add storage
YAML
# Prometheus alerting rules
groups:
  - name: scheduling-platform
    rules:
      - alert: HighBookingFailureRate
        expr: rate(bookings_created_total{status="error"}[5m]) / rate(bookings_created_total[5m]) > 0.01
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Booking failure rate exceeded 1%"
          description: "Current rate: {{ $value | humanizePercentage }}"

      - alert: CalendarSyncLag
        expr: calendar_sync_lag_seconds > 300
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Calendar sync lag exceeds 5 minutes"

      - alert: DoubleBookingDetected
        expr: increase(booking_unique_violations_total[5m]) > 10
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Spike in double-booking violations detected"

      - alert: HighNoShowRate
        expr: rate(bookings_noshow_total[24h]) / rate(bookings_confirmed_total[24h]) > 0.1
        for: 1h
        labels:
          severity: warning
        annotations:
          summary: "No-show rate exceeded 10% in last 24h"

27. Cost Estimation

Monthly Cost Breakdown (Scale: 10M bookings/month)

ComponentSpecMonthly Cost
PostgreSQL (RDS Multi-AZ)db.r6g.2xlarge, 1TB GP3, read replicas$2,500
Redis (ElastiCache)r6g.xlarge cluster, 3 nodes$1,200
Kafka (MSK)kafka.m5.2xlarge, 3 brokers$2,400
ClickHouse (Analytics)3-node cluster, 2TB storage$1,800
Elasticsearchm6g.xlarge, 3 nodes, 500GB$1,500
Application Servers (ECS)8x c6g.2xlarge (booking, availability, sync)$3,200
Load Balancer (ALB)Application LB + WAF$500
CDN (CloudFront)5TB transfer, 100M requests$400
S3 Storage500GB assets + backups$50
DNS (Route 53)50 hosted zones$50
Email (SendGrid)2M emails/month (Pro)$350
SMS (Twilio)500K SMS/month$3,750
Secrets (Vault)Self-managed on EC2$200
Monitoring (Datadog)APM + Logs + Infrastructure$2,000
Backup & DRCross-region snapshots, daily$300
Total Infrastructure~$20,200

External API Costs (Monthly)

ServiceUsageCost
Google Calendar API100M sync requests$500 (after free tier)
Microsoft Graph API50M sync requests$400
Zoom API1M meetings createdEnterprise plan (negotiated)
Stripe2.9% + $0.30 per transaction$29M+ (passed to customers)
IP Geolocation10M lookups$100
Total External APIs~$1,000
Cost Optimization Strategies
  • Reserved Instances: Save 30-40% on EC2 and RDS with 1-year commitments
  • Spot Instances: Use for batch processing (analytics, notification queue processing) — 60-70% savings
  • SMS Bundling: Negotiate volume discounts with Twilio; consider WhatsApp for lower cost per message
  • Cache Aggressively: Availability data changes infrequently — long TTLs reduce DB load
  • Archive Old Data: Move bookings older than 2 years to S3 Glacier; keep aggregated analytics in ClickHouse

28. Testing Strategy

Testing Pyramid

graph TB A[E2E Tests - Cypress/Playwright - 5%] --> B[Integration Tests - xUnit + Testcontainers - 15%] B --> C[Unit Tests - xUnit - 80%] D[Contract Tests - Pact] --> E[API Compatibility] F[Load Tests - k6] --> G[Performance Validation] H[Chaos Tests - Chaos Monkey] --> I[Resilience Validation]

Key Test Scenarios

CategoryTest CaseType
Double-BookingTwo concurrent bookings for the same slotIntegration
Double-BookingBooking during calendar sync overlapIntegration
AvailabilityDST spring-forward slot generationUnit
AvailabilityDate override spanning a working hours dayUnit
AvailabilityBuffer time overlap with existing bookingsUnit
Calendar SyncSync token expiration and full re-syncIntegration
Calendar SyncConcurrent sync from multiple workersIntegration
PaymentsPayment intent creation and webhook handlingIntegration
PaymentsIdempotent webhook processingIntegration
Booking FlowEnd-to-end booking with all notification typesE2E
ReschedulingReschedule with policy enforcementUnit
No-ShowAuto-detection via Zoom attendanceIntegration
Round-RobinFair distribution across team membersUnit
CollectiveIntersection of 5+ host availabilitiesUnit
Time ZoneBooking across DST transitionUnit
C#
public class DoubleBookingTests
{
    [Fact]
    public async Task ConcurrentBookings_SameSlot_OneShouldFail()
    {
        await using var host = await TestHost.CreateAsync();
        var db = host.Services.GetRequiredService<AppDbContext>();

        // Create an event type with a slot at 10:00 UTC
        var eventType = await CreateEventType(db, userId, "10:00", "11:00");

        // Simulate 10 concurrent booking requests for the same slot
        var tasks = Enumerable.Range(0, 10).Select(i =>
            host.Services.GetRequiredService<IBookingService>()
                .CreateBookingAsync(new CreateBookingRequest
                {
                    EventTypeId = eventType.Id,
                    HostUserId = userId,
                    StartTime = DateTime.UtcNow.Date.AddDays(1).AddHours(10),
                    EndTime = DateTime.UtcNow.Date.AddDays(1).AddHours(11),
                    BookerEmail = $"user{i}@test.com",
                    BookerName = $"User {i}",
                    BookerTimezone = "UTC"
                },
                idempotencyKey: Guid.NewGuid().ToString()));

        var results = await Task.WhenAll(tasks);

        // Exactly one should succeed
        var succeeded = results.Count(r => r.IsSuccess);
        var failed = results.Count(r => !r.IsSuccess && r.Error == "slot_unavailable");

        Assert.Equal(1, succeeded);
        Assert.Equal(9, failed);
    }

    [Fact]
    public async Task Availability_DSTSpringForward_NoGapSlots()
    {
        await using var host = await TestHost.CreateAsync();
        var db = host.Services.GetRequiredService<AppDbContext>();

        // Set working hours 1:00 AM - 3:00 AM in US/Eastern
        // DST springs from 2:00 to 3:00, so only 1:00-2:00 should appear
        var eventType = await CreateEventTypeWithHours(db, "America/New_York", "01:00", "03:00");

        // Query availability for March 12, 2026 (DST transition date)
        var slots = await host.Services.GetRequiredService<IAvailabilityService>()
            .GetAvailableSlotsAsync(
                eventType.Id,
                new DateTime(2026, 3, 12, 0, 0, 0, DateTimeKind.Utc),
                new DateTime(2026, 3, 13, 0, 0, 0, DateTimeKind.Utc));

        // Should only have slots from 6:00-7:00 UTC (1:00-2:00 AM EST)
        // The 7:00-8:00 UTC slot (2:00-3:00 AM EST) should not exist
        // because 2:00 AM jumps to 3:00 AM
        foreach (var slot in slots)
        {
            var startEastern = TimeZoneInfo.ConvertTimeFromUtc(
                slot.Start,
                TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time"));
            Assert.NotEqual(2, startEastern.Hour); // No slot starting at 2:00 AM
        }
    }

    [Fact]
    public async Task Booking_IdempotencyKey_NoDuplicate()
    {
        await using var host = await TestHost.CreateAsync();
        var service = host.Services.GetRequiredService<IBookingService>();
        var key = Guid.NewGuid().ToString();

        var request = new CreateBookingRequest { /* ... */ };

        var result1 = await service.CreateBookingAsync(request, key);
        var result2 = await service.CreateBookingAsync(request, key);

        Assert.True(result1.IsSuccess);
        Assert.Equal(result1.Booking.Id, result2.Booking.Id);
    }
}

Load Testing with k6

JavaScript
// load-test.js - k6 load test for booking API
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';

const bookingRate = new Rate('successful_bookings');
const availabilityLatency = new Trend('availability_query_time');

export const options = {
    stages: [
        { duration: '2m', target: 100 },   // Ramp up
        { duration: '5m', target: 500 },   // Stay at 500 VUs
        { duration: '2m', target: 1000 },  // Peak load
        { duration: '5m', target: 1000 },  // Sustained peak
        { duration: '2m', target: 0 },     // Ramp down
    ],
    thresholds: {
        http_req_duration: ['p(99)<500'],
        successful_bookings: ['rate>0.95'],
    },
};

export default function () {
    // Test availability endpoint
    const availRes = http.get(
        `https://api.example.com/api/v1/event-types/${__ENV.EVENT_TYPE_ID}/availability?from=2026-08-01&to=2026-08-07`
    );
    availabilityLatency.add(availRes.timings.duration);
    check(availRes, { 'availability 200': (r) => r.status === 200 });

    sleep(1);

    // Test booking creation
    const bookingPayload = JSON.stringify({
        event_type_id: __ENV.EVENT_TYPE_ID,
        booker_email: `loadtest${Date.now()}@example.com`,
        booker_name: 'Load Test User',
        booker_timezone: 'UTC',
        host_user_id: __ENV.HOST_ID,
        start_time: new Date(Date.now() + 86400000).toISOString(),
        end_time: new Date(Date.now() + 86400000 + 1800000).toISOString(),
    });

    const bookingRes = http.post('https://api.example.com/api/v1/bookings', bookingPayload, {
        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${__ENV.API_KEY}` },
    });

    bookingRate.add(bookingRes.status === 201);
    check(bookingRes, { 'booking created': (r) => r.status === 201 });
}

29. Interview Q&A

System Design Questions

Q: How do you prevent double-booking when two users book the same slot simultaneously?

A: We use a multi-layered approach: (1) A partial unique index on (host_user_id, start_time) where status is 'confirmed' or 'pending' — this is the ultimate safety net at the database level. (2) Serializable transaction isolation for the booking transaction — the first transaction acquires a row-level lock, and the second one either waits or fails with a serialization error. (3) An idempotency key ensures retries don't create duplicates. (4) In-memory optimistic locks in the availability cache layer reduce contention before hitting the database.

Q: How would you handle 10,000 concurrent users checking availability for the same popular event type?

A: We use two-layer caching: L1 in-memory per application instance (TTL 60s, invalidated via Redis Pub/Sub) and L2 in Redis (TTL 60s). This means at most one availability computation per 60 seconds per event type, served from memory for all subsequent requests. We'd also pre-compute availability for the next 30 days as a materialized view that gets invalidated on changes. For extremely popular hosts (celebrity bookings), we'd use a dedicated Redis key with a longer TTL and proactive refresh.

Q: How do you handle calendar sync when Google Calendar rate-limits us?

A: We use exponential backoff with jitter, circuit breakers (Hystrix/Resilience4j), and a priority queue for sync requests. Active users (those with upcoming bookings) get higher priority. We also leverage Google's push notifications (Watch API) instead of polling, which reduces API calls by 90%+ and gives us sub-5-second sync latency.

Q: How do you compute collective availability for 10 hosts efficiently?

A: We compute each host's availability independently (parallel, each O(n) in time slots), then intersect them. With 30-day windows and 15-minute intervals, that's 2880 slots per host. The intersection is O(n * k) where k=10 hosts. Total: ~28,800 operations, completing in <50ms. We cache the result per group + date range with a 60-second TTL.

Q: How would you design the notification scheduler to handle millions of reminders per day?

A: We don't use a cron job polling a database — that doesn't scale. Instead, when a booking is created, we write individual notification records to a scheduled_notifications table with their scheduled delivery time. A partitioned worker pool (one partition per hour) picks up due notifications and publishes them to Kafka. The actual sending service consumes from Kafka with exactly-once semantics. We use partitioned tables (by month) so index scans stay fast.

Q: How do you handle the embeddable widget securely on any third-party website?

A: The widget runs inside an iframe with a strict CSP (Content Security Policy). We use PostMessage for parent-iframe communication with strict origin validation. The iframe's X-Frame-Options are set per-domain. The widget JavaScript is served from a CDN with Subresource Integrity (SRI) hashes. Payment processing uses Stripe Elements in the iframe so PCI data never touches our servers.

Q: How do you handle no-shows without alienating legitimate customers?

A: We use a graduated approach: (1) Aggressive reminder cadence (24h, 1h, 15min via multiple channels). (2) Automatic calendar invites to reduce forgetfulness. (3) A no-show counter per user — after 2 no-shows in 90 days, they're required to provide a card on file. (4) No-show fees only apply for paid bookings where the host has enabled it. (5) A grace period of 15 minutes after meeting start before auto-detecting no-shows. (6) Users can dispute no-shows through the admin dashboard.

Coding Questions

Q: Implement a function that generates available time slots for a given week.

A: The function takes working hours rules, existing calendar events, existing bookings, buffer times, and the date range. It iterates each day, generates potential slots based on rules, applies date overrides, filters out conflicts from calendar events and bookings, applies buffer constraints, and returns the final list. The key insight is to work in UTC throughout and only convert for display.

Q: Design the data model for a booking system that supports recurring appointments with individual instance modifications.

A: Use a linked list pattern: each booking has a recurring_schedule_id and a next_instance_id. The recurring_schedules table stores the recurrence rule (frequency, end_date) and points to the first instance. When a user cancels one instance, we set status to 'cancelled' without affecting other instances. When they cancel "this and future," we walk the linked list from the current instance forward and cancel each one.

30. Conclusion

Designing an appointment scheduling platform is a masterclass in distributed systems engineering. What appears to be a simple "pick a time, book it" feature turns out to involve calendar sync protocols across three major providers, real-time availability computation with sub-200ms latency, double-booking prevention with strong consistency guarantees, multi-channel notification scheduling, payment processing with idempotency, HIPAA compliance for healthcare use cases, and a globally distributed caching layer that must handle millions of concurrent availability queries.

The key architectural decisions we made are:

  • Event-driven architecture with Kafka for decoupling services and enabling independent scaling
  • Serializable transactions with partial unique indexes for bulletproof double-booking prevention
  • Two-layer caching (in-memory + Redis) for availability computation at scale
  • Sync token patterns for efficient, delta-based calendar synchronization
  • Idempotent APIs throughout the booking and payment flows
  • Provider abstraction patterns for calendar, video, and payment integrations
  • Structured audit logging with PHI sanitization for HIPAA compliance

Whether you are building this for a startup or an enterprise, these patterns and trade-offs will guide your architecture. The system we designed can handle 10 million bookings per month with 99.95% uptime, sub-200ms availability queries, and full compliance with healthcare regulations.

Key Takeaways for System Design Interviews
  • Always start with the data model — it drives everything else
  • Double-booking prevention is THE critical invariant — design it first
  • Calendar sync is the hardest operational problem — emphasize sync tokens and error handling
  • Time zones are a minefield — always store in UTC, use IANA IDs
  • Caching strategy for availability is what separates good from great designs
  • Don't forget the business requirements: payments, no-shows, analytics

How to Design an Appointment Scheduling Platform — Senior+ Guide | Ayodhyya

Sandip Mhaske

I’m a software developer exploring the depths of .NET, AWS, Angular, React, and digital entrepreneurship. Here, I decode complex problems, share insightful solutions, and navigate the evolving landscape of tech and finance.

Post a Comment

Previous Post Next Post