Build a booking system on Google Calendar and Outlook
You are adding booking to your product, so guests pick a time and the meeting lands on your user's Google or Outlook calendar. The hard parts are that both providers return busy blocks instead of open slots, nothing holds a slot while a guest decides, and hosts move or cancel meetings in the calendar itself. With Pyas, each host connects once, meetingTimes.find returns open slots, the event is created on the host's calendar with your booking id as the idempotency key, and webhooks report changes. You build the booking page.
What your app does, step by step
- 09:00
Connect the host's calendar
Hosts connect Google or Outlook once, in a PyasConnect dialog on your settings page. Your server hands it a session token, and the user id you return is saved on the account as
subjectId. Save theaccountIdagainst the host.// server.ts app.post('/api/pyas-session', pyas.connect.sessionHandler((req) => req.user.id)) // HostCalendarSettings.tsx <PyasConnect getSessionToken={getSessionToken} onConnected={(account) => saveHostAccount(account.accountId)} /> - 09:30
Find the open slots
meetingTimes.findsubtracts the host's busy time from their working hours and applies your booking rules. Slots come back in UTC, earliest first. Render them in the guest's time zone.const slots = await pyas.meetingTimes.find({ accountIds: [host.accountId], from: weekStart, to: weekEnd, durationMinutes: 30, timezone: host.timezone, buffer: { before: 10, after: 10 }, minimumNoticeMinutes: 240, slotMinutes: 30, limit: 100, }) - 10:00
Recheck the slot and book it
Read the slot again right before creating, since another guest or the host may have taken it. Your booking id as the idempotency key means a retried create never makes a second event.
const now = await pyas.freeBusy.query({ accountIds: [host.accountId], from: slot.startsAt, to: slot.endsAt }) if (now.busy.length > 0) return askGuestToPickAgain() // taken since the page loaded const event = await pyas.events.create({ accountId: host.accountId, title: `Consultation with ${guest.name}`, startsAt: slot.startsAt, endsAt: slot.endsAt, attendees: [{ email: guest.email, name: guest.name }], conferencing: { provider: 'meet' }, // 'teams' on Outlook, 'zoom' with a connected Zoom account }, { idempotencyKey: `booking-${booking.id}` }) - 10:30
Follow changes made in the calendar
Subscribe once with
pyas.webhooks.create({ url, events: ['event.*', 'connection.broken'] }). Webhooks carry IDs, so read the event and compare it with your booking.app.post('/webhooks/pyas', express.raw({ type: 'application/json' }), async (req, res) => { const webhook = await pyas.webhooks.constructEvent(req.body, req.headers['pyas-signature'], process.env.PYAS_WEBHOOK_SECRET) const booking = await findBookingByEventId(webhook.data.object.id) if (booking && webhook.type === 'event.deleted') await markCancelled(booking) if (booking && webhook.type === 'event.updated') { const { accountId, data } = webhook const current = await pyas.events.get({ accountId, eventId: data.object.id, calendarRef: data.object.calendarId }) if (current.startsAt !== booking.startsAt) await markRescheduled(booking, current) } res.sendStatus(200) })
What you use
- Free timeFree/busy, and the first times everyone has free inside their working hours.
- Calendar APIRead and write events and calendars on Google Calendar and Outlook, in one shape.
- WebhooksSigned webhooks that name the exact event that changed, so you read only that.
- Meeting linksGoogle Meet, Microsoft Teams or Zoom on the event, from one field.
- PyasConnectThe dialog your users connect Google Calendar, Outlook and Zoom in, inside your app.
- Pyas AuthSign-in Google, Microsoft and Zoom have already verified, so you skip your own OAuth and app reviews.
Free/busy returns busy time, not open slots
A Stack Overflow question asks whether the Google Calendar API can return empty calendar spaces, because "what I can see returned is just current booked slots". Google's freeBusy.query returns, for each calendar, a busy list: "List of time ranges during which this calendar should be regarded as busy." Microsoft Graph's getSchedule returns schedule items with a status, an availabilityView string and the person's working hours, and Microsoft lists it as not supported for personal Microsoft accounts.
Open slots are your computation: bookable hours minus busy blocks, cut into slots. The free/busy guide does it by hand for both providers. In Pyas, meetingTimes.find does it across Google and Outlook accounts in one call.
The rules that make a gap bookable
A gap is not yet a slot you can offer. Each booking rule is a meetingTimes.find parameter:
- Working hours. Graph returns them with getSchedule, in a time zone that can be a custom one, with a bias and daylight saving rules instead of a name. Google's freeBusy response has no working hours. Pass
workingHoursfor hosts whose provider reports none;respectWorkingHoursis on by default. - Buffers.
buffer: { before, after }keeps minutes free around the host's other events, so a call doesn't start the minute the last one ends. - Minimum notice.
minimumNoticeMinutesstops a guest from booking a slot that starts in ten minutes. - Length and grid.
durationMinutesis the meeting length.slotMinutes(5, 10, 15, 20, 30 or 60) is the grid starts line up to, so you offer 10:00 and 10:30, not 10:07.
Tentative events count as busy unless you set tentativeIsBusy: false. One request covers at most 62 days.
Three time zones in every booking
The host works in one zone, the guest books from another, and each provider stores time its own way. Google stores an IANA name such as Europe/Zurich on an event's start and end. Graph's dateTimeTimeZone uses Windows names such as Pacific Standard Time plus a list of IANA names. Converting them has traps, such as three Canadian zones.
Do the arithmetic in UTC and convert at the edges. In Pyas, the timezone you pass to meetingTimes.find is the zone slots line up with, usually the host's. Slot times come back in UTC, and your page renders them in the guest's browser zone. Every event you read has startsAt and endsAt in UTC, startsAtLocal, and an IANA timezone, whatever Google or Outlook stored.
Two guests, one slot
Free/busy is a read. Nothing holds the time between showing a slot and creating the event, so two guests can pick the same 10:00, and the host can fill it from their phone in between.
Retries are the other source of duplicates, because a create that timed out may have succeeded. Graph has transactionId "to avoid redundant POST operations in case of client retries to create the same event." Google lets you choose the event id, unique per calendar, but warns "we cannot guarantee that ID collisions will be detected at event creation time."
What holds up:
- Insert the booking row first, with a unique constraint on host and start time (or an exclusion constraint on the time range if slot lengths vary). The second guest fails here, before any event exists.
- Read the slot again with
freeBusy.query. This catches events the host added in their calendar. - Create the event with your booking id as the idempotency key. Pyas keeps the answer for 24 hours and replays it, so a retried create returns the first event.
Pyas has no lock. The recheck narrows the gap to the moment between it and the create, and an event the host adds in that moment arrives as a webhook.
Guests outside the organization
Booking guests are rarely in the host's company, and many use neither Google nor Outlook. They connect nothing. Each booking is an event on the host's calendar with the guest as an attendee, so the guest receives a normal invitation from the host's calendar and can accept it in whatever calendar they use.
On Microsoft, "When you create an event that includes attendees, the server sends invitations to all attendees", and this "can't be configured" (Create event). On Google, sendUpdates defaults to false, and attendees get an email only when you set it to all or externalOnly. Pyas asks Google to send invitations unless you pass notifyAttendees: false, so the guest gets one on either provider. Booking from a Google service account fails differently: service accounts cannot invite attendees without domain-wide delegation.
Meeting links
Each provider has its own mechanism. Google Meet needs conferenceDataVersion=1 and a createRequest, and Google creates conferences asynchronously, so the first response can say pending. Graph creates a Teams meeting when you set onlineMeetingProvider to teamsForBusiness, and after that "you can't change onlineMeetingProvider again" (event resource). On Outlook.com calendars, the event saves without a Teams link.
In Pyas it is one field, conferencing: { provider }: meet on Google, teams on Outlook work accounts, zoom on either once the host connects Zoom. Pyas creates the Zoom meeting and puts the link on the event. Teams on an Outlook.com account is refused with an error that says why, so offer those hosts Zoom. More in meeting links for Meet, Teams and Zoom.
Reschedules and cancellations happen in the calendar
Your booking record goes stale the moment the host drags the meeting to Thursday in Google Calendar, deletes it in Outlook, or the guest declines. Outlook guests can also propose a new time, because Graph's allowNewTimeProposals defaults to true.
Google's push notifications "do not include a message body", and "there's no automatic way to renew a notification channel" (push guide). Graph subscriptions on Outlook events last at most 10,080 minutes, under seven days. A deleted Google event still reads back, with status cancelled.
Pyas keeps Google's channels and Graph's subscriptions renewed and sends a signed webhook per change, event.updated or event.deleted. Webhooks carry IDs, not previous values, so compare the event with your booking: a different startsAt is a reschedule. Changes made through your own API calls carry request.traceId, so you can skip them.
When your app moves a booking, pass the version you read, so a change the host made in the meantime isn't overwritten:
const current = await pyas.events.get({ accountId, eventId })
await pyas.events.update(
{ accountId, eventId, startsAt: newSlot.startsAt, endsAt: newSlot.endsAt },
{ ifMatch: current.version },
)On Outlook, updating the description removes the Teams join link, so leave the description alone on a reschedule unless you rebuild it.
Connecting hosts
With Pyas Auth you skip Google's OAuth review, but under Microsoft's default consent policy, work accounts can still need an administrator's approval. When a host's connection stops working, connection.broken fires; stop offering their slots until they reconnect.
What you build yourself
Pyas is an API. It has no booking pages and no round-robin, and beyond the calendar invitation it sends no email. The booking page, the guest form, confirmations, reminders, payments and routing between hosts are yours. It covers Google Calendar, Microsoft 365, Outlook.com and Zoom, not iCloud or on-premises Exchange.
Questions
- Can the Google Calendar API return available slots for booking?
- No. Google's
freeBusy.queryreturns the busy time ranges for each calendar, and Microsoft'sgetSchedulereturns schedule items and working hours. You compute open slots yourself, or call Pyas'smeetingTimes.find, which returns them for Google and Outlook accounts in one call. - Is Pyas a scheduling API like Calendly's?
- Pyas is a calendar API, not a booking page product. It finds free time on your users' Google and Outlook calendars, creates the event with a meeting link, and tells you when it changes. The booking page, the guest form and any round-robin between hosts are yours to build, inside your own product.
- How do I stop two guests booking the same slot?
- Free/busy is a read, and Pyas has no lock. Insert the booking in your own database first, with a unique constraint on host and start time, then read the slot again with
freeBusy.query, then create the event with your booking id as the idempotency key. See idempotency. - Do guests need a Google or Outlook account?
- No. Only the host connects a calendar. The guest is an attendee on the host's event and gets an ordinary calendar invitation by email.
- Does my booking app need Google's OAuth verification?
- Not with Pyas Auth, on any plan, the free one included. Hosts approve Pyas on Google's, Microsoft's and Zoom's own consent screens. Microsoft's default consent policy can still require an administrator to approve calendar access for work accounts.
- What does it cost?
- Pricing counts connected accounts, so hosts count and guests don't. A host who connects Google and Zoom is two connected accounts. It is free for 3 connected accounts with no credit card, then $15 a month for 10, $45 for 60 and $75 for 200. See pricing.
Read next
Build it against the sandbox today
Every app gets sample people with real-looking calendars, so you can build this before anyone connects an account.