calendar-scheduling · git:20260602.b03d416 · 2026-06-02 · sha256 6c071e2662513d92

calendar-scheduling git:20260602.b03d416A

Immutable. This exact content never changes and is served at /api/v1/blob/6c071e2662513d92.

---
name: calendar-scheduling
description: "Use when adding booking/scheduling to a product — a 'pick a slot' page, an embedded Cal.com/Calendly widget, or a flow that shows real availability and writes the confirmed meeting to Google/Outlook/Apple — and when fixing the three classic bugs: double-booking, wrong-time-after-DST, and orphaned events on reschedule. Triggers: 'add a book-a-call page', 'embed Cal.com and white-label it', 'show my real availability and create the calendar event', 'we keep getting double-booked when two people pick the same slot', 'bookings show the wrong time after the clocks changed', 'fire a webhook on each booking', 'añade reservas de citas a la web y evita solapamientos con mi calendario de Outlook', 'evita que se reserve dos veces el mismo hueco'. NOT raw calendar CRUD, free/busy plumbing, or watch channels for an internal app with no booking surface (that is google-workspace), NOT a generic non-booking inbound event receiver (that is webhooks), NOT charging for a paid appointment (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 this skill
exists to prevent:

- **Double-booking** — two people grab the same slot because availability was
  computed in the browser or written before a re-check.
- **Timezone drift** — a meeting shows the wrong hour after a DST change because
  a wall-clock time was stored instead of an instant + IANA zone.
- **Orphaned events** — a reschedule creates a *second* calendar event instead
  of moving the first, so the calendar fills with phantoms.

Everything below is in service of not shipping those.

## 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. Full comparison in `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.

**Calendly:** OAuth 2.1 or a personal access token. **Cal.com self-host:** no
rate-limit anxiety, so no token-bucket gymnastics needed in your client.

## 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`. Payloads in
`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) |

## References

- `references/provider-matrix.md` — Cal.com vs Calendly vs Google direct vs
  Nylas v3 vs Cronofy: hosting, auth/scopes, webhook events, cross-domain,
  when each wins.
- `references/google-calendar-sync.md` — `freebusy.query` and `events.insert`
  (with `conferenceData` for Meet) payloads, `watch` channels + sync tokens,
  recurring-event edge cases, refresh-token handling.

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).