Skip to content

How to get free/busy across Google and Outlook calendars in the right time zones

Updated

TL;DR

Google's POST /calendar/v3/freeBusy returns busy intervals only, for up to 50 calendars per query. Microsoft Graph's POST /me/calendar/getSchedule returns free, tentative, busy, out of office and working hours for up to 20 people over less than 62 days, in UTC unless you send Prefer: outlook.timezone, and it does not support personal Outlook.com accounts. Convert everything to UTC intervals, merge them, and only then apply working hours in each person's time zone.

Google: freeBusy.query

Google free/busyHTTP
POST https://www.googleapis.com/calendar/v3/freeBusy
Content-Type: application/json

{
  "timeMin": "2026-11-03T00:00:00Z",
  "timeMax": "2026-11-08T00:00:00Z",
  "timeZone": "UTC",
  "items": [{ "id": "primary" }, { "id": "ana@example.com" }]
}

From the freeBusy.query reference:

  • timeMin and timeMax are RFC3339 timestamps.
  • timeZone is the "Time zone used in the response. Optional. The default is UTC."
  • items takes calendar IDs or group IDs. calendarExpansionMax has a "Maximum value is 50", and groupExpansionMax a maximum of 100, with "An error is returned for a group with more members than this value."
  • Each calendar returns busy[], where start is "(inclusive)" and end is "(exclusive)", and an optional errors[] when that calendar could not be computed. Check it: a calendar with an error and no busy blocks is not a free calendar.
  • The narrowest scopes are calendar.freebusy and calendar.events.freebusy.

Google does not document a maximum span between timeMin and timeMax. Keep queries to weeks, not months, and test the range you need.

What counts as busy on Google

The answer is a list of intervals and nothing else. An event blocks time when its transparency is opaque, the default, and doesn't when it is transparent. There is no tentative state and no working hours in the response.

Microsoft 365: getSchedule

Microsoft Graph getScheduleHTTP
POST https://graph.microsoft.com/v1.0/me/calendar/getSchedule
Prefer: outlook.timezone="UTC"
Content-Type: application/json

{
  "schedules": ["ana@contoso.com", "ben@contoso.com"],
  "startTime": { "dateTime": "2026-11-03T00:00:00", "timeZone": "UTC" },
  "endTime": { "dateTime": "2026-11-08T00:00:00", "timeZone": "UTC" },
  "availabilityViewInterval": 30
}

From the getSchedule reference and Microsoft's free/busy guide:

  • Up to 20 entities per request. "This limit applies to the number of users identified individually or as members of a distribution list."
  • "The time period to look up must be less than 62 days."
  • availabilityViewInterval defaults to 30 minutes, minimum 5, maximum 1440.
  • Response times are in UTC unless you send Prefer: outlook.timezone.
  • More than 1,000 entries in a time slot returns error 5006, "The result set contains too many calendar entries."
  • Delegated access for personal Microsoft accounts: "Not supported."

Each person comes back with scheduleItems (each with a status), an availabilityView string, and workingHours. The digits in availabilityView mean "0= free or working elsewhere", "1= tentative", "2= busy", "3= out of office". Time after the last item is free: getSchedule "assumes the remaining time in that time period to be free."

Working hours come back with their own time zone, which can be a customTimeZone with a bias and DST rules instead of a name. To read a user's working hours outside getSchedule, use GET /me/mailboxSettings/workingHours, which needs MailboxSettings.Read. That permission is on the list Microsoft's default consent policy keeps users from approving; see Need admin approval.

Side by side

Google freeBusy Graph getSchedule
Calendars per request 50 (calendarExpansionMax) 20 entities, distribution list members included
Maximum span Not documented Less than 62 days
States Busy only Free, tentative, busy, out of office, working elsewhere
Working hours No Yes, with their own time zone
Response time zone timeZone field, default UTC UTC, or Prefer: outlook.timezone
Request time zone Offset in each timestamp timeZone on startTime and endTime
Personal accounts Gmail calendars use the same call Not supported

Decide what busy means

The two providers disagree on states, so your product has to choose. The decisions that matter:

  • Tentative. Graph reports it; Google has nothing equivalent. Treating tentative as busy is the safe default, because the person may still accept. Offering tentative slots as a second choice is the other reasonable rule.
  • Out of office. Treat it as busy. Graph marks it 3; Google's freeBusy response can't tell it apart from any other busy block.
  • Working elsewhere. Graph folds it into 0, free. That is usually right for a call and wrong for an in-person meeting.

Write the rule down once and apply it to both providers, or two people with identical calendars will get different answers.

Time zones

Run the math in UTC, and apply time zones only at the edges.

  1. Ask both providers for UTC. Set timeZone to UTC for Google and send Prefer: outlook.timezone="UTC" to Graph, so nothing in the response depends on a mailbox setting.
  2. Graph's dateTimeTimeZone takes Windows zone names like Pacific Standard Time and a set of IANA names. Sending UTC avoids a mapping table on the request side.
  3. Convert each person's working hours to UTC intervals per day with a real time zone library. A 9 to 5 day in America/New_York is a different UTC window on either side of a DST change, and Graph's customTimeZone has to be read from its bias and transition rules.
  4. Show results in the viewer's IANA time zone.

All-day events on Outlook

Outlook keeps all-day events created through Graph at midnight to midnight UTC. Read as returned, an all-day event in a New York mailbox shows busy from 8 PM the evening before. Read all-day events as dates in the mailbox's time zone, and ask for a day either side of your window.

Personal Outlook.com accounts

getSchedule fails on Outlook.com and Hotmail accounts. Fall back to reading events with calendarView, which supports personal accounts, and build the busy list from each event's showAs (free, tentative, busy, oof, workingElsewhere, unknown). calendarView returns occurrences of recurring events, so you don't expand recurrences yourself. Times are UTC unless you send Prefer: outlook.timezone.

Combine them

Once every source is a list of UTC intervals, merging is simple:

mergeBusy.tsTypeScript
type Interval = { start: number; end: number }; // epoch milliseconds, end exclusive

export function mergeBusy(intervals: Interval[]): Interval[] {
  const sorted = [...intervals].sort((first, second) => first.start - second.start);
  const merged: Interval[] = [];
  for (const interval of sorted) {
    const last = merged[merged.length - 1];
    if (last && interval.start <= last.end) {
      last.end = Math.max(last.end, interval.end);
    } else {
      merged.push({ ...interval });
    }
  }
  return merged;
}

Then walk the gaps between merged intervals inside each person's working hours, keep the ones at least as long as the meeting, and snap starts to a grid (every 15 or 30 minutes) so you don't offer 10:07.

Batch your calls to respect each provider's limits: 50 calendars per Google request, 20 people per Graph request, and windows under 62 days for Graph.

How Pyas handles it

Pyas reads free/busy from Google and Outlook through one call: GET /v2/free-busy for one account, POST /v2/free-busy for several. To find slots, POST /v2/meeting-times takes accountIds, from, to, durationMinutes, an IANA timezone and optional workingHours, and returns times when everyone is free across Google and Outlook. Every event also carries startsAt in UTC, startsAtLocal and an IANA timezone, whatever the provider stored. Microsoft work accounts can still need an administrator's approval under Microsoft's default consent policy, with Pyas Auth too.

Common questions

Can Google free/busy tell me about tentative events?

No. freeBusy returns busy intervals only. If you need response status, list the events and read each attendee's responseStatus.

Why does getSchedule show someone free when they have an event?

The event may be marked free or working elsewhere, both 0 in availabilityView. Read scheduleItems for the real status.

Can I use findMeetingTimes instead of getSchedule?

On Microsoft 365, yes, but it only covers Microsoft calendars and applies its own business logic. Microsoft's comparison says getSchedule "simply returns the free/busy status", which is what you need to combine with Google.

How far ahead can I query?

Graph: less than 62 days per request. Google: not documented. Split long ranges into several requests.

Outlook stores every all-day event in UTCThree Canadian time zones Outlook uses that older Windows-to-IANA mappings lackHow to fix "Need admin approval" (AADSTS65001, AADSTS90094) for an Outlook calendar app