Calendar scheduling — booking surface + calendar sync, shipped together
Scheduling is always two halves bolted together: a booking surface (an external person reserves a slot — embed, atom, or API call) and calendar sync (you read free/busy to compute availability and write the confirmed event back). Ship one without the other and you get the three bugs the rest of this skill exists to prevent: double-booking, timezone drift after a DST change, and orphaned events on reschedule.
Decide the altitude first
Pick the lowest-code option that still owns the data model you actually need.
| You need… | Reach for | What you own | Escape hatch |
|---|---|---|---|
| A booking page fast, minimal code | Embed Cal.com or Calendly | Nothing — the widget owns slots/sync | Call the API later to read bookings / fire automation |
| Bookings in your UI, your branding/data model | Cal.com Booker atom or Scheduling API (Cal.com / Calendly) | Your UI; provider owns sync | Drop to raw provider API if the data model chafes |
| Read/write one provider's calendar directly | Google freebusy.query + events.insert |
OAuth, refresh, slot math, watch/sync | If it's pure CRUD with no booking → google-workspace |
| Many providers (Google + Outlook + Apple), no N integrations | Unified API (Cronofy or Nylas v3) | One auth/availability surface | Cronofy if cross-domain scheduling matters; Nylas v3 is domain-scoped |
Why per row: the embed is zero-maintenance but a black box; the atom/API buys
your own UI without owning sync; raw provider is full control and full
liability; a unified API trades a vendor for not maintaining three calendar
integrations. Hosting, auth/scopes, webhook events, cross-domain support and
when each wins, per provider:
references/provider-matrix.md.
- Cal.com is open-source and self-hostable. Self-hosted instances get
unlimited API access (no cloud rate limit) and full white-label by
pointing the embed script at your own domain. REST base is
https://api.cal.com/v2. - Calendly v1 API and its webhooks were discontinued in May 2025. Use v2 (REST/JSON, OAuth 2.1 or personal access token). Do not write new v1 code.
OAuth scopes — narrowest that works
The default mistake is requesting the broad scope "to be safe." On Google, both
calendar and calendar.events are restricted scopes — they force a
third-party security assessment before you can ship to production. Avoid
them when a granular scope does the job.
// Bad — restricted scope, blocks production until a security assessment.
const SCOPES = ["https://www.googleapis.com/auth/calendar"];
// Good — granular ladder, no restricted tier for the common booking case.
const SCOPES = [
"https://www.googleapis.com/auth/calendar.app.created", // app-owned secondary calendar it creates
"https://www.googleapis.com/auth/calendar.freebusy", // your own availability
// add only if you must read the user's existing events to compute slots:
"https://www.googleapis.com/auth/calendar.events.owned", // manage only events your app created
"https://www.googleapis.com/auth/calendar.events.freebusy", // others' busy blocks
];
Scope ladder, narrowest first:
calendar.app.created— a dedicated secondary calendar your app creates and owns. Best dodge for the restricted assessment when you only need your events.calendar.freebusy/calendar.events.freebusy— read availability (own / others') without reading event contents.calendar.readonly/calendar.events.readonly— read paths only.calendar.events.owned— write, but only events your app created.calendar/calendar.events— restricted; request only if you genuinely manage arbitrary events the app didn't create.
Availability without double-booking — the core flow
Both classic races (computing slots in the browser, and writing the event before re-checking) are eliminated by doing this server-side, in order:
freebusy.queryacross every relevant calendar (the host's, plus any secondary calendars that block time). Never trust a cached availability blob.- Compute slots server-side applying buffers (gap before/after), minimum notice (no "book in 5 minutes"), working hours, and slot length. The browser may render slots; it must never decide them.
- Place a short-lived hold/lock on the chosen slot (a row with a TTL, or a tentative event) so a second request in the same window collides on the lock, not on the calendar.
- Write the event LAST — only after the lock is held.
- Re-check
freebusyinside the write transaction. If the slot went busy between step 1 and now, abort and re-offer. This is the line that actually prevents the double-book.
Decision — do you need a hold step?
| Situation | Hold/lock? |
|---|---|
| Low traffic, single host, instant write | No — steps 1→5 with the in-transaction re-check is enough |
| Multi-step booking form, payment, or high contention | Yes — a TTL lock so the slot survives the form and releases if abandoned |
Google's availability primitive is freebusy.query (POST, returns busy blocks
per calendar); the write is events.insert. Both payloads (with
conferenceData for Meet), watch channels + sync tokens, recurring-event edge
cases and refresh-token handling:
references/google-calendar-sync.md.
Timezone correctness
DST is where naive scheduling code dies. Rules:
- Store the instant in UTC and carry the IANA zone id (e.g.
Europe/Andorra) separately. Never store a bare wall-clock string. - Render in the invitee's zone, derived from the IANA id — not from a
browser UTC offset. An offset (
+02:00) is correct only on the day it was captured; it silently breaks across a DST boundary. - Google event payloads MUST set
timeZonealongsidedateTime, or Google interprets the time in the calendar's default zone and the meeting drifts.
// Bad — floating wall-clock, no zone. Drifts after the clocks change.
{ "start": { "dateTime": "2026-10-25T10:00:00" } }
// Good — instant + explicit IANA zone on both ends.
{
"start": { "dateTime": "2026-10-25T10:00:00", "timeZone": "Europe/Andorra" },
"end": { "dateTime": "2026-10-25T10:30:00", "timeZone": "Europe/Andorra" }
}
Webhooks that survive retries
A booking is not confirmed because the embed said so — it is confirmed when the webhook says so. Providers retry, deliver duplicates, and arrive out of order. Your handler must assume all three.
- Verify the signature before trusting the payload (Cal.com and Calendly each sign; reject unsigned).
- Dedupe on the provider event id — an idempotency key persisted before you act, so a retry is a no-op.
- Handle the lifecycle: Calendly fires
invitee.created/invitee.canceled(and routing-form submissions); Cal.com firesBOOKING_CREATED/BOOKING_CANCELLED/BOOKING_RESCHEDULED. Map both to your own created/canceled/rescheduled handlers. - Calendly webhooks require a paid plan (Standard/Teams/Enterprise) and are
scoped
userororganization. Single-use scheduling links expire after 90 days if unused — don't hand out links you cache forever.
The generic inbound-receiver scaffolding (queue, retry, replay) lives in the webhooks skill; this skill only owns the booking-specific lifecycle mapping. For "on booking, also create a CRM record + Slack + sheet" cross-tool fan-out, that orchestration is automation-flows, not here.
Reschedule and cancel without orphans
- Reschedule = update the same calendar event id. Look up the event you
created,
events.update(or the provider's PATCH) the times — neverevents.inserta second one. The phantom-event bug is always a missing lookup. - Release the freed slot — if you held a lock or marked a row busy, free it so the old time is offerable again.
- Cancel = delete/cancel the same event and release the slot; record the cancellation so reminders and downstream automation stop.
Anti-patterns
| Anti-pattern | Why it bites | Do instead |
|---|---|---|
| Compute available slots in the browser | Stale/raced data → double-book | freebusy.query server-side, re-check in the write txn |
Request auth/calendar for a read-only widget |
Restricted scope → blocked by security assessment | Narrowest scope: calendar.freebusy / calendar.app.created |
| Store local "wall-clock" times | Drift after DST → wrong-hour meetings | UTC instant + IANA zone; set timeZone on Google payloads |
| Trust the embed for confirmation state | Embed lies on network failures | Confirm only on a signature-verified webhook |
| Create a new event on reschedule | Orphaned phantom events pile up | events.update the same event id; release old slot |
| No idempotency on the webhook | Retries duplicate the booking | Dedupe on provider event id before acting |
| Cache a single-use scheduling link forever | Calendly links expire after 90 days | Generate on demand; treat expiry as expected |
| Poll the calendar for changes | Slow, rate-limited, misses edits | watch push channels + incremental sync tokens |
| Write new code against Calendly v1 | v1 API + webhooks dead since May 2025 | Calendly v2 (OAuth 2.1 / PAT) |
Adjacent skills: raw calendar CRUD / watch channels with no booking →
../google-workspace/SKILL.md; charging for a
paid appointment → ../stripe/SKILL.md; booking funnel as
sales stages → ../sales-pipeline/SKILL.md;
sending the confirmation email itself → ../email-connector/SKILL.md.