A Complete System Design Deep-Dive — From Calendly-Style Core to Enterprise-Grade Healthcare Scheduling
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.
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
| ID | Requirement | Priority |
|---|---|---|
| FR-1 | Users can create scheduling pages with configurable event types | P0 |
| FR-2 | Bookers can view available time slots and book appointments | P0 |
| FR-3 | Sync availability with Google Calendar, Outlook, and iCal | P0 |
| FR-4 | Support working hours, date overrides, and buffer times | P0 |
| FR-5 | Support one-on-one, group, round-robin, and collective booking | P1 |
| FR-6 | Automatic time zone detection and conversion | P0 |
| FR-7 | Email, SMS, and push notification reminders | P1 |
| FR-8 | Payment collection at booking (Stripe integration) | P1 |
| FR-9 | Recurring appointment scheduling | P2 |
| FR-10 | Waiting lists for fully booked slots | P2 |
| FR-11 | Rescheduling and cancellation with policies | P0 |
| FR-12 | Team scheduling with round-robin and collective routing | P1 |
| FR-13 | Meeting polling to find mutually available times | P2 |
| FR-14 | Embeddable JavaScript widget for external websites | P1 |
| FR-15 | Zoom, Google Meet, and Teams auto-provisioning | P1 |
| FR-16 | Analytics dashboard (booking rates, no-show rates, revenue) | P1 |
| FR-17 | CRM integration (Salesforce, HubSpot) | P2 |
| FR-18 | Multi-location support | P2 |
| FR-19 | HIPAA-compliant scheduling for healthcare | P2 |
| FR-20 | RESTful API and webhook system | P1 |
Non-Functional Requirements
| Requirement | Target |
|---|---|
| Availability | 99.95% uptime (4.38 hours downtime/year) |
| Latency | Slot availability query < 200ms p99 |
| Throughput | 10,000 bookings/second peak |
| Consistency | Strong consistency for double-booking prevention |
| Scalability | 100M+ calendar syncs, 50M+ bookings/month |
| Security | SOC 2 Type II, HIPAA BAA available |
| Real-time | Calendar sync lag < 5 seconds |
3. High-Level Architecture
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
| Service | Responsibility | Scaling Strategy |
|---|---|---|
| Booking Service | Creates, modifies, cancels bookings | Horizontal, partitioned by host_id |
| Availability Service | Computes available time slots | Read-heavy, Redis-cached, replicated |
| Calendar Sync Service | Bidirectional sync with external calendars | Worker pool, rate-limited per provider |
| Notification Service | Email, SMS, push notifications | Async via Kafka, retry with backoff |
| Payment Service | Payment intent creation, refunds | Stateless, idempotent Stripe calls |
| Analytics Service | Aggregates booking metrics | OLAP (ClickHouse), batch + stream |
| Webhook Service | Outbound event delivery to customers | At-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.
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)
);
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.
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
| Provider | Auth | Delta Mechanism | Rate Limits | Push Notifications |
|---|---|---|---|---|
| Google Calendar | OAuth2 | Sync tokens | 1M req/day, 500/100s | Yes (Watch API) |
| Outlook (MS Graph) | OAuth2 | Delta links | 10K req/min per app | Yes (Change Notifications) |
| iCal / CalDAV | URL or Basic Auth | Full re-fetch | No formal limits | No |
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
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
| Trigger | Scope | Method |
|---|---|---|
| Calendar sync | Affected user, date range | Redis key pattern delete |
| Working hours changed | Affected user, future dates | Redis key pattern delete |
| Date override added | Affected user, specific date | Redis key pattern delete |
| New booking / cancellation | Affected host, date range | Redis key pattern delete |
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.
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
| Pitfall | Example | Solution |
|---|---|---|
| DST Spring Forward | 2:00 AM becomes 3:00 AM | Detect gap and skip slot |
| DST Fall Back | 2:00 AM occurs twice | Always resolve to first occurrence in UTC |
| UTC Offset Drift | Nepal is UTC+5:45, India is UTC+5:30 | Use IANA timezone IDs, not manual offsets |
| Half-Hour Timezones | India, Iran, Myanmar | Support non-whole-hour offsets in slot generation |
| No DST Observation | Arizona, Hawaii | Use IANA IDs which encode DST rules correctly |
| Historical Changes | Russia changed zones in 2014 | Use 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.
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
| Type | Description | Algorithm | Use Case |
|---|---|---|---|
| One-on-One | Single host, single booker | Check host's availability only | Consultations, interviews |
| Group | Single host, multiple bookers | Check host availability, cap at max | Webinars, group sessions |
| Round-Robin | Assign to next available host | Least-recently-assigned from pool | Sales calls, support |
| Collective | All hosts must be free | Intersect all host availabilities | Panel 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.
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
| Policy | Description | Implementation |
|---|---|---|
| Free Cancellation | Cancel anytime before the meeting | Allow cancel, no restrictions |
| 24-Hour Policy | Cancel up to 24 hours before | Check startTime - now > 24h |
| 48-Hour Policy | Cancel up to 48 hours before | Check startTime - now > 48h |
| No Cancellation | Cannot cancel once booked | Disable cancel button entirely |
| Late Fee | Charge a fee for late cancellation | Partial 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.
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
| Channel | Provider | Latency | Cost | Open Rate |
|---|---|---|---|---|
| SendGrid / SES | 1-5 seconds | $0.0001/email | ~50% | |
| SMS | Twilio | 1-3 seconds | $0.0075/SMS (US) | ~98% |
| Push (iOS) | APNs | 1-10 seconds | Free | ~40% |
| Push (Android) | FCM | 1-10 seconds | Free | ~40% |
| Twilio API | 1-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.
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.
| Layer | Mechanism | Timing |
|---|---|---|
| Prevention | Multi-channel reminders | 24h, 1h, 15min before |
| Prevention | Calendar invite auto-add | On booking |
| Detection | Meeting provider attendance check | Meeting start + 15min |
| Detection | Manual mark as no-show by host | After meeting |
| Enforcement | Charge no-show fee | After marking |
| Enforcement | Require card on file for future | On first no-show |
| Recovery | Auto-offer slot to waitlist | Immediate |
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
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
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)
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.
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
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
| Provider | Auth | Auto-Provision | Attendance Check | Recording |
|---|---|---|---|---|
| Zoom | OAuth2 | Yes | Yes (participant list) | Cloud + local |
| Microsoft Teams | MS Graph OAuth2 | Yes | Yes (meeting analytics) | Cloud (requires policy) |
| Google Meet | Google OAuth2 | Yes (via Calendar) | Limited | Requires Workspace |
21. Analytics & Reporting
Key Metrics
| Metric | Formula | Target |
|---|---|---|
| Booking Conversion Rate | Bookings / Page Views | > 40% |
| No-Show Rate | No-Shows / Total Bookings | < 5% |
| Reschedule Rate | Reschedules / Total Bookings | < 10% |
| Cancellation Rate | Cancellations / Total Bookings | < 8% |
| Avg. Time to Book | Page open to booking confirmation | < 90 seconds |
| Calendar Sync Lag | Event change to sync completion | < 5 seconds |
| Notification Delivery Rate | Delivered / Sent | > 99% |
| Revenue per Booking | Total Revenue / Total Bookings | Varies |
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
| Component | Data Source | Refresh Rate |
|---|---|---|
| Booking Overview | ClickHouse pre-aggregated | Real-time |
| Team Performance | ClickHouse aggregate queries | 5 minutes |
| Revenue Tracker | Payment events stream | Real-time |
| Calendar Sync Health | Prometheus metrics | 30 seconds |
| No-Show Tracking | Booking status changes | Real-time |
| Notification Delivery | Notification metrics | 5 minutes |
| User Activity | Page view + action tracking | 1 minute |
| Integration Health | Webhook delivery rates | 5 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
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/event-types | List user's event types |
POST | /api/v1/event-types | Create event type |
PUT | /api/v1/event-types/{id} | Update event type |
GET | /api/v1/event-types/{id}/availability | Get available slots |
POST | /api/v1/bookings | Create booking |
GET | /api/v1/bookings/{id} | Get booking details |
PATCH | /api/v1/bookings/{id}/cancel | Cancel booking |
PATCH | /api/v1/bookings/{id}/reschedule | Reschedule booking |
POST | /api/v1/bookings/{id}/no-show | Mark as no-show |
GET | /api/v1/bookings | List bookings (with filters) |
POST | /api/v1/calendars/{id}/sync | Trigger calendar sync |
GET | /api/v1/waitlist | Get waitlist entries |
POST | /api/v1/polls | Create meeting poll |
GET | /api/v1/analytics/overview | Analytics overview |
GET | /api/v1/webhooks | List webhook subscriptions |
POST | /api/v1/webhooks | Create 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)
HIPAA Compliance 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 Requirement | Implementation |
|---|---|
| Access Control | Unique user IDs, automatic logoff, encryption of PHI at rest |
| Audit Controls | Log all access to PHI (who, when, what, from where) |
| Integrity Controls | Verify PHI has not been altered or destroyed |
| Transmission Security | TLS 1.2+ for all PHI in transit |
| Breach Notification | 60-day notification window for breaches affecting 500+ individuals |
| Minimum Necessary | Only expose PHI needed for the specific function |
| De-identification | Safe 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
Key Alerts
| Alert | Condition | Severity | Action |
|---|---|---|---|
| High booking failure rate | > 1% of requests fail with 5xx | Critical | Page on-call, rollback |
| Calendar sync lag | Sync delay > 5 minutes | Warning | Check provider rate limits |
| Double-booking detected | Unique constraint violation spike | Critical | Check transaction isolation |
| Redis cache hit rate drop | Hit rate < 80% | Warning | Check cache memory, eviction |
| Notification delivery failure | > 5% failures | Warning | Check provider status, retry queue |
| Stripe webhook lag | Payment webhook delay > 30s | Critical | Check webhook endpoint health |
| API latency spike | p99 > 500ms for 5 minutes | Warning | Check DB queries, add replicas |
| Disk usage | > 80% on database | Warning | Archive 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)
| Component | Spec | Monthly 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 |
| Elasticsearch | m6g.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 Storage | 500GB 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 & DR | Cross-region snapshots, daily | $300 |
| Total Infrastructure | ~$20,200 |
External API Costs (Monthly)
| Service | Usage | Cost |
|---|---|---|
| Google Calendar API | 100M sync requests | $500 (after free tier) |
| Microsoft Graph API | 50M sync requests | $400 |
| Zoom API | 1M meetings created | Enterprise plan (negotiated) |
| Stripe | 2.9% + $0.30 per transaction | $29M+ (passed to customers) |
| IP Geolocation | 10M lookups | $100 |
| Total External APIs | ~$1,000 |
- 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
Key Test Scenarios
| Category | Test Case | Type |
|---|---|---|
| Double-Booking | Two concurrent bookings for the same slot | Integration |
| Double-Booking | Booking during calendar sync overlap | Integration |
| Availability | DST spring-forward slot generation | Unit |
| Availability | Date override spanning a working hours day | Unit |
| Availability | Buffer time overlap with existing bookings | Unit |
| Calendar Sync | Sync token expiration and full re-sync | Integration |
| Calendar Sync | Concurrent sync from multiple workers | Integration |
| Payments | Payment intent creation and webhook handling | Integration |
| Payments | Idempotent webhook processing | Integration |
| Booking Flow | End-to-end booking with all notification types | E2E |
| Rescheduling | Reschedule with policy enforcement | Unit |
| No-Show | Auto-detection via Zoom attendance | Integration |
| Round-Robin | Fair distribution across team members | Unit |
| Collective | Intersection of 5+ host availabilities | Unit |
| Time Zone | Booking across DST transition | Unit |
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
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.
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.
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.
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.
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.
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.
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
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.
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.
- 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