Interview scheduling API for Google and Outlook calendars
You are building the part of an ATS or recruiting tool that finds a time a panel of interviewers shares, invites a candidate who never connects a calendar, and keeps up when someone moves. What makes it hard is that interviewers are split across Google and Microsoft 365, Graph's findMeetingTimes only covers Microsoft work mailboxes, and each provider adds meeting links its own way. With Pyas, each interviewer connects once, meetingTimes.find returns times the whole panel is free across both providers, and webhooks tell you when an interview changes.
What your app does, step by step
- 09:00
Connect each interviewer's calendar
Interviewers connect Google or Outlook through PyasConnect, a dialog in your settings page. Your server returns your own user id, which Pyas saves on the account as
subjectId. Microsoft 365 interviewers may need their administrator to approve calendar access first.// server.ts app.post('/api/pyas-session', pyas.connect.sessionHandler((req) => req.user.id)) // InterviewerSettings.tsx <PyasConnect getSessionToken={getSessionToken} onConnected={(account) => saveInterviewerAccount(account.accountId)} /> - 09:30
Find times the whole panel is free
Pass every interviewer, Google and Outlook alike, in one call. Mark trainees or shadows
optional, keep a buffer around their other meetings, and leave a day of notice so the candidate is never offered a slot that starts in an hour.const slots = await pyas.meetingTimes.find({ attendees: [ { accountId: hiringManagerAccountId }, { accountId: engineerAccountId }, { accountId: shadowAccountId, optional: true }, ], from: '2026-11-02T00:00:00Z', to: '2026-11-07T00:00:00Z', durationMinutes: 60, timezone: 'America/Chicago', buffer: { before: 10, after: 15 }, minimumNoticeMinutes: 24 * 60, }) // slots[0].startsAt, slots[0].optionalFree, slots[0].optionalBusy - 10:00
Book the interview and invite the candidate
Nothing locks a slot, so check the panel again right before booking. Create the event on the organizer's calendar with the candidate and panel as attendees, and use your interview id as the idempotency key so a retry never books twice.
const now = await pyas.freeBusy.query({ accountIds: panelAccountIds, from: slot.startsAt, to: slot.endsAt }) if (now.busy.length > 0) return offerNextSlot() // someone on the panel is now busy const interview = await pyas.events.create({ accountId: hiringManagerAccountId, title: 'Interview: Jordan Lee, Senior Engineer', startsAt: slot.startsAt, endsAt: slot.endsAt, attendees: [ { email: 'jordan.lee@example.com', name: 'Jordan Lee' }, { email: 'priya.shah@acme.com', name: 'Priya Shah' }, ], conferencing: { provider: 'zoom', accountId: hiringManagerZoomAccountId }, // their connected Zoom account }, { idempotencyKey: `interview-${interviewId}` }) - 10:30
Catch reschedules, declines and cancellations
Pyas sends a signed webhook when an interview changes in anyone's calendar. It carries IDs only, so read the event and compare it with your copy to see what moved.
app.post('/webhooks/pyas', express.raw({ type: 'application/json' }), async (req, res) => { const event = await pyas.webhooks.constructEvent(req.body, req.header('Pyas-Signature'), secret) if (event.type === 'event.updated') { const current = await pyas.events.get({ accountId: event.accountId, eventId: event.data.object.id, calendarRef: event.data.object.calendarId, }) await compareWithStoredInterview(current) // moved? attendee changed? } if (event.type === 'event.deleted') await markInterviewCancelled(event.data.object.id) 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.
- Meeting linksGoogle Meet, Microsoft Teams or Zoom on the event, from one field.
- WebhooksSigned webhooks that name the exact event that changed, so you read only that.
- Pyas AuthSign-in Google, Microsoft and Zoom have already verified, so you skip your own OAuth and app reviews.
Interviewers are on two calendar systems
A recruiting tool sells to many companies, and they don't share a calendar vendor. One customer runs Google Workspace, the next runs Microsoft 365, and a staffing firm books candidates with interviewers at several clients at once, on whatever each client uses. Neither provider can see the other's calendars, so you read each interviewer on their own provider and merge the answers.
The two native APIs are not alike. Google's freeBusy.query returns busy intervals only, for up to 50 calendars a request. A calendar it could not read comes back with an errors entry, such as notFound, instead of busy times. Treat that interviewer as unknown, not free.
Microsoft Graph has a higher-level call, findMeetingTimes, which suggests meeting times itself. Three things about it matter for interviews:
- It supports delegated work or school accounts only. For personal Microsoft accounts and for application permissions, the reference says "Not supported."
- It "checks the free/busy status in the primary calendars of the organizer and attendees", so interviewers whose calendars live in Google are outside what it can check.
- It ranks suggestions by confidence. Free counts as a 100% chance of attending, unknown 49%, busy 0%, averaged over the attendees, and the default threshold is 50%. On a four-person panel, a time when one interviewer is busy scores 75% and is suggested. For a panel, send
minimumAttendeePercentage: 100.
Google's and Microsoft's free/busy calls are compared in full, with getSchedule, in Free/busy across Google and Outlook.
Panels have required and optional interviewers
A loop often has a hiring manager and an engineer who must be there, plus a shadow interviewer who joins when free. findMeetingTimes "assumes that any attendee who is a person is always required", so it cannot express that. Google stores an optional flag on an event's attendees, but freeBusy.query has no notion of it: you get busy intervals and decide yourself.
In Pyas, mark the shadow optional: true in attendees. Their calendar no longer blocks a slot, and each slot lists the optional people who are free and busy, so you can rank slots where the shadow can come first.
Back-to-back interviews need room between them
An onsite loop can put a candidate in four interviews in a row, and each interviewer needs a few minutes after theirs to write notes before their next meeting. Free/busy says when someone is busy, not how much room they need around it, so the padding is yours to add. Working hours are uneven too: Graph's default, when a mailbox has none set, is "Monday to Friday, 8am to 5pm in the time zone set for the mailbox", and Google's free/busy answer carries no working hours at all.
meetingTimes.find takes buffer: { before, after } in minutes, kept free around each interviewer's other events, minimumNoticeMinutes so nobody is offered a slot that starts in an hour, and workingHours for people whose provider reports none.
The candidate never connects a calendar
Candidates won't authorize your app to read their calendar, and you shouldn't ask. They are attendees on an event the organizer owns. Your UI shows the times the panel shares, the candidate picks one, and you create the event on the organizer's calendar with the candidate's email in attendees. Pyas has no booking pages, so that picker is yours.
The invitation then comes from the organizer's calendar. If you call Google directly, whether anyone is emailed depends on sendUpdates: all, externalOnly ("Notifications are sent to non-Google Calendar guests only") or none, and Google warns that none "can have significant adverse effects". Through Pyas, Google sends the invitations unless you pass notifyAttendees: false, and Outlook always sends them, so the candidate gets one either way.
Sometimes no real person's calendar should own the event: a coordinator at an agency, or a recruiting assistant that books for the team. A staffing firm on Hacker News described "calendar invites connecting parties who never directly communicate." A shared recruiting account can be the organizer instead: connect it like any interviewer's calendar, and the invitations come from that address. Beyond the calendar invitation, nothing sends email for you, so reminders and follow-ups to candidates stay in your stack.
The meeting link depends on the organizer's calendar
- Google Meet. The events.insert reference says
conferenceDataVersion"Version 1 enables support for copying of ConferenceData as well as for creating new conferences", and "The default is 0." Without it there is no Meet link. - Teams. An Outlook event's
onlineMeetingProvidertakesteamsForBusiness, which needs a work or school account. On an Outlook.com personal account, Graph created the event without a link and without an error. Pyas refuses that request with an error that says why. - Zoom. Zoom works whatever the calendar. The staffing firm above kept "separate Zoom accounts to avoid double-booking links." Pyas creates a Zoom meeting on the host's connected Zoom account for each interview and puts its link on the Google or Outlook event.
More detail in Meeting links for Meet, Teams and Zoom.
Enterprise interviewers need their administrator first
Large employers tend to run Microsoft 365, and Microsoft's default consent setting no longer lets users approve calendar permissions themselves. The first time an interviewer at such a company connects, they can see "Need admin approval", with Pyas Auth as with Native Auth. An administrator grants admin consent once for the organization, then everyone connects. Microsoft also offers an admin consent workflow "to allow users to request an administrator's review and approval" from the blocked screen, if the tenant turns it on.
Put this in customer onboarding: ask the customer's IT team for consent before the first interview loop, not the morning of it. The full fix is in Need admin approval.
Admin approval comes from the interviewer's company, not from you. A staffing firm whose interviewers sit at several client companies goes through it once per client on Microsoft 365.
One reschedule moves the rest of the loop
The same staffing firm: "A client reschedules one interview and it cascades into four others." Your app has to notice the first change. Interviewers drag events in their own calendar app. Outlook lets invitees propose a new time unless the organizer turns it off (allowNewTimeProposals, "The default is true"). On Google, an attendee who says no has responseStatus set to declined.
Pyas sends event.updated and event.deleted for these changes, a candidate's decline included. Webhooks carry IDs only, with no previous values, so keep your own copy of each interview's time and attendees and compare. When an interview moved or someone dropped out, run meetingTimes.find again for the interviews after it, and update each with ifMatch: event.version so you don't overwrite an edit made since you read it. Changes your own API calls made carry request.traceId, so you can skip them instead of reacting to yourself.
Questions
- Is there an interview scheduling API in Microsoft Graph?
- The closest is
findMeetingTimes, which suggests times from the organizer's and attendees' free/busy. It works only with delegated work or school accounts, not personal Microsoft accounts or app-only access, and only for calendars Exchange can read. If some interviewers are on Google, read both providers and merge the busy times, or callpyas.meetingTimes.findwith accounts from both. - How do I build an AI interview scheduling agent?
- Give your model calendar tools and keep the decisions in your app. Pyas's MCP server,
@pyas/mcp, and@pyas/ai-tools(Vercel AI SDK, LangChain, OpenAI and Anthropic tool use) includefind_meeting_times,create_eventandupdate_event. Pyas runs no AI models: you bring your own. See AI scheduling agents. - Does Pyas integrate with Greenhouse, Lever or other ATSs?
- No. Pyas has no ATS integrations. It connects interviewers' Google, Outlook and Zoom accounts to your product, and candidate and job data stays in your app or comes from the ATS through whatever API it offers.
- Is Pyas interview scheduling software?
- No. It is the calendar API you build interview scheduling on. It has no booking pages, round-robin, interview recording, transcripts or scorecards. Those are your product; Pyas reads and writes the calendars and meeting links underneath.
- Can a candidate join on Teams if the interviewer uses Outlook.com?
- No. Teams meetings from a calendar need a Microsoft 365 work or school account. Pyas refuses a Teams request on an Outlook.com personal account with an error that says why. Offer Zoom there, on the interviewer's connected Zoom account.
- How do I stop two recruiters booking the same interviewer at once?
- Nothing reserves a slot between showing it and booking it. Read the panel's free/busy again with
pyas.freeBusy.queryright before you create the event, and pass your interview id as the idempotency key so a retried request returns the first event instead of creating a second.
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.