Use when a product needs a booking surface — a pick-a-slot page, a Cal.com/Calendly embed, or real availability plus the confirmed meeting written to Google/Outlook — or when fixing double-booking, DST drift, or orphaned reschedule events. NOT calendar CRUD with no booking surface (that is `google-workspace`), NOT the payment (that is `stripe`).
Installs into .claude/skills of the current project.
Are you the author of Calendar Scheduling?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-calendar-scheduling)
---
name: calendar-scheduling
description: "Use when a product needs a booking surface — a pick-a-slot page, a Cal.com/Calendly embed, or real availability plus the confirmed meeting written to Google/Outlook — or when fixing double-booking, DST drift, or orphaned reschedule events. NOT calendar CRUD with no booking surface (that is `google-workspace`), NOT the payment (that is `stripe`)."
tags: [scheduling, booking, calendar, calcom, calendly, google-calendar, availability, webhooks]
recommends: [google-workspace, webhooks, automation-flows, email-connector, stripe, sales-pipeline]
profiles: []
origin: risco
---
# 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`](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.
```ts
// 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:
1. **`freebusy.query`** across every relevant calendar (the host's, plus any
secondary calendars that block time). Never trust a cached availability blob.
2. **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.
3. **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.
4. **Write the event LAST** — only after the lock is held.
5. **Re-check `freebusy` inside 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`](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 `timeZone`** alongside `dateTime`, or Google
interprets the time in the calendar's default zone and the meeting drifts.
```json
// 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 fires
`BOOKING_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 `user` or `organization`. 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 — never
`events.insert` a 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`](../google-workspace/SKILL.md); charging for a
paid appointment → [`../stripe/SKILL.md`](../stripe/SKILL.md); booking funnel as
sales stages → [`../sales-pipeline/SKILL.md`](../sales-pipeline/SKILL.md);
sending the confirmation email itself → [`../email-connector/SKILL.md`](../email-connector/SKILL.md).