Calendar scheduling — booking surface + calendar sync, shipped together

SkillProductivity

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

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Calendar scheduling — booking surface + calendar sync, shipped together skill

What this skill tells your AI

The instructions your AI receives, as published by ericrisco/rsc-harness in skills/calendar-scheduling/SKILL.md and read by ahel’s review.

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 forWhat you ownEscape hatch
A booking page fast, minimal codeEmbed Cal.com or CalendlyNothing — the widget owns slots/syncCall the API later to read bookings / fire automation
Bookings in your UI, your branding/data modelCal.com Booker atom or Scheduling API (Cal.com / Calendly)Your UI; provider owns syncDrop to raw provider API if the data model chafes
Read/write one provider's calendar directlyGoogle freebusy.query + events.insertOAuth, refresh, slot math, watch/syncIf it's pure CRUD with no booking → google-workspace
Many providers (Google + Outlook + Apple), no N integrationsUnified API (Cronofy or Nylas v3)One auth/availability surfaceCronofy 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:

  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?

SituationHold/lock?
Low traffic, single host, instant writeNo — steps 1→5 with the in-transaction re-check is enough
Multi-step booking form, payment, or high contentionYes — 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 timeZone alongside dateTime, 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 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-patternWhy it bitesDo instead
Compute available slots in the browserStale/raced data → double-bookfreebusy.query server-side, re-check in the write txn
Request auth/calendar for a read-only widgetRestricted scope → blocked by security assessmentNarrowest scope: calendar.freebusy / calendar.app.created
Store local "wall-clock" timesDrift after DST → wrong-hour meetingsUTC instant + IANA zone; set timeZone on Google payloads
Trust the embed for confirmation stateEmbed lies on network failuresConfirm only on a signature-verified webhook
Create a new event on rescheduleOrphaned phantom events pile upevents.update the same event id; release old slot
No idempotency on the webhookRetries duplicate the bookingDedupe on provider event id before acting
Cache a single-use scheduling link foreverCalendly links expire after 90 daysGenerate on demand; treat expiry as expected
Poll the calendar for changesSlow, rate-limited, misses editswatch push channels + incremental sync tokens
Write new code against Calendly v1v1 API + webhooks dead since May 2025Calendly 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.

Signals

GitHub stars
82
Forks
3
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
calendar-scheduling
Source
github.com/ericrisco/rsc-harness