Foyer

Google Calendar

OAuth scoped to calendar.events only. Tentative hold first, confirmed lock on owner approval, push-channel reconciliation when the owner edits the event from their phone.

Why calendar.events and not the full scope

Foyer reads and writes calendar events on a single calendar — the one the business books against. It does not need to enumerate the owner’s calendar list, change ACLs, or touch other calendars. Requesting https://www.googleapis.com/auth/calendar.events is the minimum scope that lets the agent create, update, and delete events on a specific calendar by ID. Anything broader is a consent screen the owner will refuse, justifiably.

1. Create the OAuth client

Google Cloud Console → APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID. Application type: Web application. Authorized redirect URIs:

https://api.foyer.example.com/v1/google/oauth/callback

Drop the client ID and secret into .env:

GOOGLE_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=...
GOOGLE_REDIRECT_URI=https://api.foyer.example.com/v1/google/oauth/callback

2. Enable the APIs

APIs & Services → Library → enable both Google Calendar API and Google Geocoding API. Geocoding is what validates the customer’s address against the service area before the agent proposes a slot. The Geocoding API key goes in .env separately as GOOGLE_GEOCODING_KEY — it is a server key, not an OAuth credential.

3. Owner consent flow

From the Filament admin, the owner clicks “Connect Google Calendar.” Foyer redirects to Google’s consent screen withscope=https://www.googleapis.com/auth/calendar.events andaccess_type=offline so the refresh token is returned. The callback handler exchanges the code, stores the refresh token via a KMS-pointer reference rather than plaintext, and asks the owner to pick which calendar to use.

4. Slot search

The agent calls events.listwith the business-hours + quiet-hours + lead-time window, then conflicts the result against Foyer’s own slot_holds and bookings tables. The agent only proposes slots that are clear in both — never relies solely on Calendar, because a concurrent customer might have already taken the hold inside Foyer in the same millisecond.

5. The hold

When the agent proposes a slot and the customer accepts, Foyer inserts a row into slot_holds (the Postgres exclusion constraint enforces no overlap) and creates a tentative Calendar event in the same job. If the Calendar API call fails, the DB hold is released within the same job — no orphans. The event title is Foyer hold — pending, transparency is opaque so the slot looks busy in the owner’s view.

6. The lock

When the owner confirms in the Filament inbox, Foyer updates the Calendar event title to the real one, attaches the customer details to the description, and the booking row transitions to confirmed. The confirm endpoint requires an Idempotency-Key header — a double-click sends one Calendar update and one SMS, not two.

7. Drift detection

If the owner moves or deletes a Foyer-managed event from their phone, Foyer needs to know. The setup includes a calendar push channel subscription via events.watch; pushes land at POST /v1/google/calendar-push and trigger a reconciliation job. If the watch channel expires (Google rotates them every 30 days max), a five-minute fallback poll catches the gap.

The owner dashboard surfaces a sync-health indicator if drift exceeds threshold — the only honest answer to “is the calendar accurate?” is “yes, and here is the last time we confirmed.”

Token refresh

Access tokens last an hour. Foyer refreshes ten minutes before expiry using the stored refresh token. If the refresh fails — the owner revoked access, or Google invalidated the token — the business is flagged in the admin, agent dispatch stops on that business, and the owner is asked to reconnect.