Calendar & conferencing integrations
Bookly hosts connect their own accounts from Admin → Calendars and Admin → Conferencing. The server admin registers the OAuth apps once and puts the keys in the environment; a provider without keys shows as "Not set up on this server".
| Provider | Gives you | Env |
|---|---|---|
| Calendar conflicts, bookings in Google Calendar, Google Meet links | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
|
| Microsoft | Outlook conflicts, bookings in Outlook, Teams links | MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, MICROSOFT_TENANT |
| Zoom | Zoom meetings | ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET |
| Daily.co | Bookly video rooms, no account needed by hosts or attendees (cloud: Pro and up) | DAILY_API_KEY, DAILY_DOMAIN, optional MEET_URL |
| Recall.ai | Notetaker: auto-capture on Google Meet, Teams and Zoom calls | RECALL_API_KEY, RECALL_REGION, RECALL_WEBHOOK_SECRET, optional RECALL_BOT_NAME |
Redirect URI for every OAuth provider: <APP_URL>/api/integrations/<provider>/callback. In
cloud mode APP_URL is the platform host (https://bookly-app.io/...), one URI for every
workspace: the signed state carries the workspace, the callback verifies the user is a member
of it and sends them back to their own subdomain (the session cookie spans subdomains).
Going public with the hosted service needs each provider's review, done once by the operator:
- Google: the Calendar scopes are "sensitive", so the OAuth consent screen must be verified
before anyone outside the test users can connect: a privacy policy on the app's verified domain
that names the Google API Services User Data Policy (ours does,
/privacy), the homepage on the same domain, and a short screencast of the consent flow and what Bookly does with the data. Until verified, only listed test users can connect and their tokens expire after 7 days. - Microsoft: users in other organisations cannot consent to an unverified multi-tenant app at
all (personal accounts can), so publisher verification is needed before work accounts connect:
a Partner Center (MPN) account for the legal entity, and the publisher domain proven by
public/.well-known/microsoft-identity-association.json, which lists the app's client id (update it if the registration is ever recreated). Enter the Partner ID under the app's Branding & properties once Microsoft has verified the business. - Zoom: an unpublished app can only be authorised by accounts on its allow list. For any Zoom user to connect, the app must pass Marketplace review (privacy and support URLs, a deauthorization notification URL, the security questionnaire) and be published.
How bookings use them#
- When a booking is confirmed (immediately, or when the host approves a pending one) Bookly creates the meeting for the event type's location: Zoom meeting, Daily room, or a Google / Outlook calendar event with a Meet / Teams link.
- The booking is then added to every other connected calendar (the "Add new bookings to" calendar).
- Cancelling or rescheduling deletes the meeting and the calendar events.
- Availability subtracts busy time from the calendars ticked under "Check for conflicts in" and from every subscribed calendar feed (below). Results are cached for one minute.
Everything is best-effort. If a provider fails, the booking still goes through: Bookly tries Bookly video next, then leaves the location as the event type's text. A permanently broken connection (revoked token) is marked in the admin and the host gets one email asking to reconnect.
Tokens are stored encrypted (AES-256-GCM, key derived from AUTH_SECRET). Changing AUTH_SECRET
invalidates all connections.
Google#
- Google Cloud Console → APIs & Services → Enable "Google Calendar API".
- OAuth consent screen: External, add scopes
calendar.readonly,calendar.events,email,openid. While the app is in "Testing", add your Google account as a test user (tokens then expire after 7 days; publish the app to remove that limit). - Credentials → Create OAuth client ID → Web application. Authorized redirect URI:
https://<your-host>/api/integrations/google/callback. To offer "Continue with Google" on the sign-in page, also addhttps://<your-host>/api/auth/callback/google(docs/self-hosting.md, "Accounts"). Keeplocalhostredirects out of this project: Google's app checkup flags every web client of a published app that allows loopback redirects ("Use secure flows"), so development gets its own project (see "Local development"). - Set
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET.
Meet links are created through the Calendar API (conferenceData), so Google Meet works for any
Google account that can create Meet calls from Calendar. Google also emails the attendee a
normal calendar invitation (sendUpdates=all), in addition to Bookly's confirmation email.
Microsoft (Outlook + Teams)#
- Entra admin center → App registrations → New registration. Supported account types: "Accounts
in any organizational directory and personal Microsoft accounts" (matches
MICROSOFT_TENANT=common). - Redirect URI (Web):
https://<your-host>/api/integrations/microsoft/callback, plushttps://<your-host>/api/auth/callback/microsoftfor "Continue with Microsoft". - Certificates & secrets → new client secret →
MICROSOFT_CLIENT_SECRET. Application (client) ID →MICROSOFT_CLIENT_ID. - API permissions (delegated):
Calendars.ReadWrite,OnlineMeetings.ReadWrite,User.Read,offline_access,openid,email.
Teams links come from creating the Outlook event with isOnlineMeeting; personal Microsoft
accounts get Skype/Teams-for-personal links.
Calendar feeds (ICS subscriptions)#
Any calendar that can publish a private ICS address works without OAuth: Google Calendar
("Settings → Integrate calendar → Secret address in iCal format"), Outlook.com and Microsoft 365
("Calendar settings → Shared calendars → Publish a calendar", the ICS link), iCloud (share the
calendar as public, use the webcal:// link), Fastmail, Nextcloud, Proton (calendar link). Hosts
add them under Admin → Calendars → Calendar feeds, up to five each.
- Read-only. Feeds hide busy time; bookings are not written to them, and the host's own bookings still reach any OAuth-connected calendar as before. Invites go out by email as usual.
- Busy means every event that is not
TRANSP:TRANSPARENT, notSTATUS:CANCELLEDand not marked free by Outlook (X-MICROSOFT-CDO-BUSYSTATUS:FREE). Recurring events are expanded with their exceptions; all-day events count as busy for the whole day unless the calendar marks them free (Google does by default). Zones come from the feed'sVTIMEZONEblocks, with the IANA zone of the same name as the fallback. - Refresh. A feed is fetched when it is added and then by the
feeds.syncjob every 15 minutes (the cron tick does the same on serverless); "Refresh" on the card fetches it now. The busy blocks for the next 400 days are stored on the feed row, so availability never waits on a remote calendar. A fetch that fails keeps the last good blocks and shows the error on the card. - Safety. The address is stored encrypted like OAuth tokens (a private feed URL grants read
access to the whole calendar). Fetches allow
https://,http://andwebcal://only, refuse credentials in the URL, localhost and private or link-local ranges (also after redirects, which are followed at most three times), time out after 15 seconds and stop at 5 MB.
Push notifications (instant conflict updates)#
Busy time from connected calendars is cached for 60 seconds. When APP_URL is a public HTTPS address, Bookly also subscribes to changes on every conflict calendar (Google watch channels, Microsoft Graph subscriptions) so an event added or removed in the calendar drops the cache immediately. Channels are opened when a calendar is connected or its conflict calendars change, renewed by the calendar.sync job (every 6 hours; the cron tick does the same on serverless) and closed on disconnect. The endpoints are /api/webhooks/calendar/google and /api/webhooks/calendar/microsoft; each notification carries a per-channel secret that is checked before anything happens. Local http:// servers skip this and rely on the 60-second cache.
Zoom#
- Zoom App Marketplace → Develop → Build App → General App, user-managed.
- OAuth redirect URL (strict mode) and allow list:
https://<your-host>/api/integrations/zoom/callback. - Scopes:
meeting:write:meeting,meeting:read:meeting,meeting:delete:meeting,user:read:user. No product surface, no embed, no in-client features. - Set
ZOOM_CLIENT_ID/ZOOM_CLIENT_SECRET. A General App has development credentials (usable at once by your own Zoom account) and production credentials (active after publication); swap them when the app is published. - Features → Access: copy the Secret Token into
ZOOM_WEBHOOK_SECRET, turn on Event Subscription, addhttps://<your-host>/api/webhooks/zoomand subscribe toapp_deauthorizedonly. Zoom validates the URL when you save (the route answers the challenge). When a user removes Bookly from their Zoom account, the route deletes their connection and tokens at once; Zoom requires that within ten days of the event.
Until the app is published only Zoom users on its allow list can authorise it. Publishing needs the Marketplace review: listing texts, privacy/terms/support URLs, a short demo video, the deauthorization endpoint above and the security questionnaire.
Daily.co (Bookly video)#
- Create a Daily account; note your domain (
something.daily.co) and create an API key. - Set
DAILY_API_KEYandDAILY_DOMAIN=something.daily.co. - Optional: serve meeting pages from their own host, e.g.
MEET_URL=https://meet.example.com. Point that host at the same deployment (add it as a domain in Vercel / your reverse proxy); the proxy rewritesmeet.example.com/<room>to/meet/<room>.
Bookly registers one Daily webhook for the install, at <APP_URL>/api/webhooks/daily, for
participant.joined and the transcript.* events; both auto-capture and "attendee joined" pings
depend on it. It is created (or replaced when APP_URL changed) the first time a room is
provisioned and checked on every job tick, and stored in platform_state under daily.webhook.
APP_URL must be public https for Daily to accept it; Console → Health shows whether it exists.
The webhook route finds the booking by room name across workspaces. Attendees' links carry
?t=<manage token>, which the meeting page turns into a Daily token that pre-fills their name;
signed-in workspace members get an owner token, so the host is identified by owner: true rather
than by the name they typed. Transcription on Bookly video starts once the owner and one other
participant are in the room.
Rooms are private and named after the booking: the host and the booked attendee join with their tokens, anyone else who opens the link asks to join and waits in Daily's lobby until the host admits them (a host who opens the link signed out is offered a sign-in link instead of knocking). Rooms can be opened any time and expire two hours after the end.
End-to-end encrypted calls. A Bookly video location can be marked End-to-end encrypted in the
event type's location editor. Its rooms are limited to two people and, once a participant has
joined, the meeting page switches the call to Daily's peer topology (setNetworkTopology; the
room-level sfu_switchover alone is not honoured on mesh-SFU domains, so the page asks
explicitly and one side asking switches both), so audio and video travel directly between the
two browsers and no server, Daily's included, can decrypt them; the regular rooms relay media through Daily's SFU, which
decrypts it to forward it. The trade-offs: the call is one-to-one (the event type must have one
seat, no guests and a single host, which the save action checks), there is no transcription or
auto-capture (Daily's transcription runs on the SFU), chat and signalling still pass through Daily
over TLS, and the connection depends on the two networks reaching each other directly (Daily's
TURN relay forwards the encrypted packets without decrypting them when they cannot). The booking
page and emails call the location "Bookly video (end-to-end encrypted)", and the meeting page shows
whether the media path really is peer-to-peer, from Daily's network-connection event.
The /meet/<room> page is Bookly's own call UI on Daily's call object (@daily-co/daily-react;
Daily Prebuilt is no longer used): a prejoin screen with camera preview, device pickers and mute
toggles (and a name field for visitors without a token, who then knock); in the call a fitted
tile grid with the active speaker outlined, screen sharing with a tile strip, in-call chat and
emoji reactions over Daily app messages (messages.ts is the protocol; nothing is stored), hand
raising via participant userData (a ✋ in the tile's corner), a People panel where hosts admit or
turn away people who are knocking, device switching, background effects (three blur strengths,
bundled scenes under public/backgrounds/, or an uploaded jpg/png kept in memory; the choice is
remembered per browser) and noise cancellation, on by default (Daily's WebAssembly processors,
hence 'wasm-unsafe-eval' in the CSP), live captions that name the speakers ("(host)" after the
host), the workspace's logo, name and accent on plans with their own branding, picture-in-picture, full screen, a network
indicator, a room-closing countdown and ⌘/Ctrl+D / ⌘/Ctrl+E for mic and camera. The page is
always dark and uses Bookly's theme tokens. Background effects need a browser with
insertable-streams support (Chrome, Edge, recent Safari).
Notetaker (Recall.ai): capture on Meet, Teams and Zoom#
Bookly is not inside a Google Meet, Teams or Zoom call, so auto-capture on those providers uses a
notetaker bot. When a booking on such a call has capture on (event type set to Always, or Ask
and the attendee agreed), Bookly books a Recall.ai bot for the meeting link one minute before the
start. The bot joins as a participant named RECALL_BOT_NAME ("Bookly Notetaker"), streams every
finished utterance to /api/webhooks/recall/transcript while the call runs, and when it leaves,
Bookly downloads the full transcript, merges it with the live lines and runs the same recap as
Bookly video. The confirmation email says a notetaker joins; hosts admit it from the waiting room
or Teams lobby when their meeting has one (Zoom without a waiting room lets it straight in).
- Create a Recall.ai account in the region you want data to stay in (
RECALL_REGION:us-west-2,us-east-1,eu-central-1orap-northeast-1) and copy the API key intoRECALL_API_KEY. - Dashboard → Developers → API Keys & Secrets → Create Workspace Secret, and put it
(
whsec_…) inRECALL_WEBHOOK_SECRET; it signs every request Recall sends (status webhooks and the real-time transcript stream). Then Dashboard → Webhooks → Add Endpoint →https://<your-host>/api/webhooks/recall. Accounts created before December 2025 have no workspace secret: use the endpoint's own signing secret from the Webhooks page instead. Bot status events drive the capture: recording opens the transcript,donetriggers the download and recap,fatalor a denied recording marks the capture failed. - Nothing to do in Zoom, Google or Microsoft: the bot joins like any guest.
Budget and plan rules are the same as Bookly video: the bot is not booked when the workspace's
minutes are used up, is sent away if they run out before it starts recording, and its recording
is capped at the remaining minutes. Cancelling or rescheduling a booking cancels the bot. Without
RECALL_API_KEY the auto-capture setting is only offered on Bookly video event types.
Local development#
Use APP_URL=http://localhost:3002 and register the localhost redirect URIs in each provider
on credentials that only your .env.local uses. For Google that means a separate Cloud
project (its own consent screen in Testing status, with you as a test user, and its own
client), because the checkup looks at every web client in a published project. Google and
Microsoft accept http://localhost redirects; Zoom requires HTTPS, so use a tunnel (e.g.
cloudflared tunnel --url http://localhost:3002) and set APP_URL to the tunnel URL.