How to sync Microsoft Outlook calendar events using the Graph API (the parts Microsoft doesn't tell you)

A practical guide to full and incremental calendar sync with the Microsoft Graph API, covering delta queries, batch hydration of recurring series, RRULE conversion, time zones, and the undocumented behavior that will cost you a week.

engineeringmicrosoftcalendar-sync

If you're building a calendar app that syncs with Microsoft Outlook, you're going to hit problems the official documentation doesn't prepare you for. Query parameters that are accepted and then silently ignored. Endpoints that return a different shape of data than their siblings. Properties that exist in the published schema but don't reliably come back in a response.

We spent weeks building calendar sync for Outlook at nocal. This is the guide we wish we'd had.

A note on timing: this reflects the state of Microsoft Graph as of July 2026, verified against the current documentation and the live v1.0 metadata. Microsoft moves things between the beta and v1.0 endpoints without much announcement, so treat version-specific claims as "true when we checked" and verify against the Graph changelog if you're reading this later.

The core problem: know which responses you can trust

The most important thing to understand about Graph calendar sync is that the three endpoints you'll use return three different levels of completeness. Treating them the same is where the hard-to-diagnose bugs come from, because nothing errors. You just get less data than you expected.

Here's the actual breakdown:

EndpointWhat you get
GET /eventsFull event data, but $expand is ignored, so recurring series arrive without their exceptions or cancellations
GET /events/deltaOnly id, type, start, and end. Genuinely partial
POST /$batchFull event data, and $expand works because the batch runs individual GETs

Two consequences fall out of this, and they're different for your first sync and your ongoing ones.

On bootstrap, list data is fine for non-recurring events. A singleInstance event from GET /events is complete and you can use it directly. What you can't get from the list endpoint is the exception and cancellation data hanging off a recurring series, because $expand doesn't work there. So you only need to re-fetch the series masters.

On incremental sync, you have to re-fetch everything that changed, because delta gives you almost nothing. The IDs are the payload; the event data alongside them is not usable.

That asymmetry matters for cost. Re-hydrating every event on the first sync of a large calendar is a lot of wasted calls when most of those events are single instances you already have in full.

How it all fits together

BOOTSTRAP (first sync)

  GET /events  (paginate via @odata.nextLink)
    |
    +-- singleInstance events --> use the list data as-is
    |
    +-- seriesMaster events ----> POST /$batch  (20 per batch)
    |                             $expand=exceptionOccurrences
    |                                 |
    |                                 +--> exceptions + cancellations
    |
    v
  Filter locally (retention window)
    |
    v
  GET /events/delta  (traverse every page, discard the event data)
    |
    v
  Store the final @odata.deltaLink


INCREMENTAL (ongoing)

  Webhook fires
    |
    v
  GET {stored deltaLink}  (traverse every page)
    |
    +-- items with @removed --> mark deleted
    |
    +-- changed IDs ----------> POST /$batch  (20 per batch)
                                $expand=exceptionOccurrences
                                    |
                                    +-- 200 --> upsert
                                    +-- 404 --> mark deleted
    |
    v
  Store the new deltaLink  (only after everything above succeeded)

Bootstrap: the initial full sync

Step 1: List the events

Use the /events endpoint to page through the calendar:

GET https://graph.microsoft.com/beta/me/calendars/{calendarId}/events

Set Prefer: odata.maxpagesize=500 to get larger pages. Microsoft doesn't publish a default or a maximum for this endpoint, so don't hard-code an assumption about either. Set what you want, then follow @odata.nextLink until it stops appearing, which is the only reliable termination condition.

Use /events, not /calendarView. /calendarView expands recurring events into individual occurrences inside a time window. /events returns series masters and single instances, which is what you want for sync, because a series master carries the recurrence pattern you can store once and expand locally.

Don't bother adding $expand here. It will be accepted and ignored.

Step 2: Sort by type

/events returns two event types:

type valueMeaning
singleInstanceA normal, non-recurring event
seriesMasterThe definition of a recurring series, not an occurrence of one

You will never see occurrence or exception from /events. Those come from /calendarView and /instances, which we don't use.

Single instances are complete. Move them straight into your processing pipeline. Series masters need one more call.

Step 3: Batch-hydrate the series masters

Collect the series master IDs and fetch them through POST /$batch with $expand:

POST https://graph.microsoft.com/beta/$batch
{
  "requests": [
    {
      "id": "0",
      "method": "GET",
      "url": "/users/{userId}/events/{eventId}?$expand=exceptionOccurrences&$select=cancelledOccurrences,id,subject,body,start,end,location,attendees,organizer,isAllDay,isCancelled,showAs,type,seriesMasterId,recurrence,originalStart,originalStartTimeZone"
    }
  ]
}

A batch holds a maximum of 20 requests. Split larger sets into chunks of 20. Each sub-request is throttled independently, so an individual member can come back 429 while the rest succeed.

For a series master you now get two things you couldn't get from the list:

  • exceptionOccurrences is an array of modified instances, each a full event object
  • cancelledOccurrences is a string array of deleted instance IDs

Both require $select and $expand, and both are only returned on a GET of the series master by its own ID. That last precondition is easy to trip: expanding on a list or on a non-master event returns nothing, quietly.

Step 4: Filter locally

/events has no reliable server-side time filter, so apply retention after hydration:

  • Keep all future events
  • Keep recent past events, six months in our case
  • Always keep series masters, however old. A series that started three years ago can still generate occurrences next week

Step 5: Get the initial delta token

A delta token is a URL Microsoft hands you that acts as a bookmark. Call it later and you get back only what changed since.

GET https://graph.microsoft.com/beta/me/calendars/{calendarId}/events/delta

Follow every @odata.nextLink until the response contains an @odata.deltaLink. Store that URL. It's the starting point for every future sync.

Discard the event data in these responses. You already have the real data from steps 1 through 3. You're traversing purely to reach the final page and collect the token.

Incremental sync: staying up to date

Step 1: Fetch changes

Call the delta URL you stored:

GET https://graph.microsoft.com/beta/me/calendars/{calendarId}/events/delta?$deltatoken=...

Follow all pages, sorting results into two lists: items carrying an @removed annotation, and everything else.

Step 2: Apply the deletes

Deletes from a delta response are authoritative. Mark them deleted immediately. Don't try to confirm one with a follow-up GET, which will just 404.

Step 3: Batch-hydrate everything that changed

Unlike bootstrap, hydrate all changed IDs here, single instances included. Delta gave you four fields; you need the rest.

Handle each sub-response by status:

StatusAction
200Upsert
404Treat as deleted. The event disappeared between the delta response and your fetch
403Log and skip
429 or 5xxRetry that specific ID on its own

Never fail a whole sync because one event wouldn't load. Process what you can.

Step 4: Save the new token last

The final delta page carries a fresh @odata.deltaLink.

Only store it after every preceding step succeeded. Delta tokens are sequential. Save a token covering changes you didn't finish applying and those events are skipped permanently, with nothing to indicate it happened.

Which endpoint version do you actually need?

The honest answer in July 2026 is narrower than the advice you'll find in older write-ups, including an earlier draft of this one.

exceptionOccurrences and cancelledOccurrences are declared on v1.0 and have been documented there since April 2025. The live v1.0 $metadata defines both on the event entity. So the widely repeated claim that recurring-exception data is beta-only is out of date as a statement about the API surface.

What is still genuinely beta-only is the unbounded /events/delta endpoint. On v1.0 you only get /calendarView/delta, which requires both startDateTime and endDateTime, giving you a moving window rather than a whole calendar. If you want change tracking across an entire calendar with no date bounds, beta is still the only way. Two details on the beta version worth knowing: startDateTime is optional there, and endDateTime isn't supported at all.

One property genuinely hasn't been promoted: occurrenceId appears in the beta schema but is absent from v1.0 $metadata. Since cancelledOccurrences is defined as a collection of occurrenceId values, a v1.0 caller can still read cancelled-occurrence IDs from the series master, but can't $select occurrenceId on the occurrences themselves.

Practical advice: don't take anyone's word for which properties populate, including ours. Log what you actually receive. If a series master comes back without exceptionOccurrences when you expanded for it, treat that as an error condition worth alerting on rather than an empty result, because the two are indistinguishable in the response body and only one of them is correct.

Recurring events

Exception occurrences

When someone edits a single instance of a series, changing its time or title or anything else, that instance becomes an exception occurrence. Each one is a full event object with its own id, start, end, and subject, plus an originalStart field. Use originalStart as the recurrence ID to work out which instance was modified.

Cancelled occurrences

When someone deletes a single instance, it appears in cancelledOccurrences on the series master. Each entry is a string ending in .YYYY-MM-DD:

AQMkADAwATM0MDAAMi04NzE5LWE0NmItMDACLTAwCg...wAAAA==.2026-06-22

Parse the date suffix to identify the cancelled occurrence. Then build a cancelled record by copying the series master's properties, setting the start from the parsed date and the duration from the master, and marking it cancelled.

Converting recurrence patterns to RRULE

Graph doesn't use RRULE. It returns structured pattern and range objects:

{
  "recurrence": {
    "pattern": {
      "type": "weekly",
      "interval": 1,
      "daysOfWeek": ["monday", "wednesday", "friday"],
      "firstDayOfWeek": "sunday"
    },
    "range": {
      "type": "endDate",
      "startDate": "2026-03-23",
      "endDate": "2026-09-14",
      "recurrenceTimeZone": "Eastern Standard Time"
    }
  }
}

If you use RRULE internally, as most calendar systems do, you need a conversion layer:

pattern.typeRRULE FREQ
dailyDAILY
weeklyWEEKLY
absoluteMonthlyMONTHLY
relativeMonthlyMONTHLY with BYDAY and BYSETPOS
absoluteYearlyYEARLY
relativeYearlyYEARLY with BYDAY and BYSETPOS

For the range, endDate maps to UNTIL, numbered maps to COUNT, and noEnd means no termination clause.

Watch the index field. Weekly patterns include index: "first" by default. It does not mean "first week of the month." index is only meaningful for relativeMonthly and relativeYearly. Apply it to a weekly pattern and you'll generate wrong RRULEs, and your recurring events will land on wrong dates in a way that looks like a time zone bug.

Time zones

Graph defaults to Windows time zone IDs like "Eastern Standard Time" rather than IANA IDs like "America/New_York". You'll meet them in recurrence.range.recurrenceTimeZone, in originalStartTimeZone and originalEndTimeZone, and on the calendar's own timeZone property.

There are two ways to get IANA out of Graph, and it's worth knowing both before you write a mapping layer:

  • Prefer: outlook.timezone on any GET that returns events sets the zone for start and end. Without it, those come back in UTC.
  • GET /me/outlook/supportedTimeZones(TimeZoneStandard='Iana') returns the supported zone list in IANA form. This is on v1.0.

Neither one removes the need for a mapping table, for two reasons. Prefer: outlook.timezone only governs start and end, so originalStartTimeZone still arrives as whatever was set at creation, typically a Windows ID. And the IANA names Graph returns include legacy link names rather than canonical zones. Its own documented sample response contains US/Samoa and US/Aleutian, whose canonical forms are Pacific/Pago_Pago and America/Adak. Anything feeding a strict tz database needs normalizing.

For the mapping itself, use the CLDR windowsZones.xml file from the Unicode Consortium, taking the territory="001" entry as the global default when you don't know the user's region. That's a convention rather than Microsoft guidance, but it's the one everyone converges on. Generate a static lookup at build time rather than parsing XML per request, and regenerate periodically.

Webhooks

Setting up subscriptions

POST /subscriptions
{
  "changeType": "created,updated,deleted",
  "notificationUrl": "https://your-server.com/webhooks/microsoft/",
  "resource": "/me/calendars/{calendarId}/events",
  "expirationDateTime": "2026-08-05T00:00:00Z",
  "clientState": "your-secret"
}

On creation, Microsoft sends a validation POST carrying a validationToken query parameter. Return that token as text/plain with a 200. If you're on a framework like Django REST Framework, this request can be rejected by content negotiation before your view code runs at all, so handle it early in the request lifecycle.

They expire quickly

Outlook event subscriptions max out at 10,080 minutes, just under seven days. If you subscribe with resource data included, so-called rich notifications, that drops to 1,440 minutes. Either way you need a renewal strategy:

  1. Proactive renewal. When handling a notification, check whether the subscription expires within 24 hours and PATCH /subscriptions/{id} if so
  2. Lifecycle notifications. Microsoft sends subscriptionRemoved and reauthorizationRequired when something breaks. Recreate on those
  3. Client-side recovery. If your app notices an expired subscription during normal operation, resubscribe

Google Calendar has the same shape of problem. Its watch channels default to a 604800 second TTL, and Google explicitly declines to document a maximum, noting that internal limits may apply. Read the expiration you get back and renew against it rather than assuming any fixed window on either platform.

Webhooks are triggers, not data

A notification payload is minimal. Use it to identify the calendar and start an incremental sync. Don't treat anything in it as a source of truth.

One sync at a time

If several notifications land for the same calendar at once, coalesce them into a single pending run. Delta tokens are sequential, and concurrent syncs against one calendar will corrupt your token state. Use a database advisory lock.

What does this cost in API calls?

Bootstrap, for N events of which S are series masters

StepCalls
List eventsceil(N / 500)
Batch-hydrate series mastersceil(S / 20)
Traverse delta for the initial token1 to 2

The S term is what makes this cheap. Most calendars are mostly single instances, and those cost nothing beyond the listing. A 500 event calendar with 20 recurring series is 1 + 1 + 1, so 3 calls. A 1,000 event calendar with 50 series is 2 + 3 + 2, so 7 calls.

If you hydrate every event instead of just the masters, that same 1,000 event calendar becomes roughly 55 calls, for data you already had.

Incremental, for C changes

StepCalls
Traverse delta1 or more
Batch-hydrate changesceil(C / 20)

Anything under 20 changes is 2 calls. Incremental sync is cheap, which is the whole point of the delta token.

Recovery

ConditionWhat to do
Delta returns 410 GoneThis is a sync reset, not an expiry. Microsoft issues it during internal maintenance or tenant migration. Discard the token and run a full bootstrap
Delta returns a 4xx with syncStateNotFoundThe token expired. Same recovery, full bootstrap
Batch item returns 404Treat as deleted
Batch item returns 429 or 5xxRetry that ID individually
Whole batch request failsRetry its IDs individually
Subscription 404s on renewalCreate a new subscription
Series master missing exceptionOccurrences after an expandLog an error and continue. Do not treat it as "no exceptions"
OAuth refresh failsPrompt the user to reauthenticate

Note what isn't in that table: a fixed token lifetime. For Outlook entities Microsoft doesn't publish one. Tokens live in an internal cache and are evicted by capacity, so a token can stop working sooner than you'd like and there's no expiry timestamp to plan around. Handle both failure modes and don't build a refresh schedule on an assumed window. The seven day figure you may have seen applies to directory objects, not calendars.

How this compares to Google Calendar

If you've built Google Calendar sync, here's the mental model shift:

AspectGoogle CalendarMicrosoft Graph
Incremental sync dataFull events via syncTokenIDs only, so you must re-hydrate
Recurring exceptionsSeparate events in the sync response$expand=exceptionOccurrences on a GET of the series master
Cancelled instancesEvents with status: cancelledcancelledOccurrences, a string array with .YYYY-MM-DD suffixes
Recurrence formatRRULE stringsStructured objects needing conversion
Time zonesIANAWindows by default, IANA available with extra work
Token expiryDocumented windowNo published lifetime for Outlook entities
Endpoint stabilityv3, stablev1.0 for most of it, beta still required for unbounded event delta

Google's is simpler. Microsoft's rewards defensive coding, and the batch hydration step is real complexity you don't need on the Google side. Once the architecture is right, though, incremental sync is fast on both.

Final advice

  1. Know which responses are complete. List data is fine for single instances, useless for series exceptions, and delta data is useless for everything except IDs
  2. Hydrate series masters on bootstrap, everything on incremental. The asymmetry is the difference between 3 calls and 55 on a first sync
  3. Verify what you receive rather than what's documented. An empty array and a silently dropped $expand look identical
  4. Save delta tokens last. Anything else silently skips events
  5. One sync at a time per calendar. Sequential tokens don't survive concurrency

The Graph calendar API is capable, and most of the difficulty comes from silent behavior rather than genuine complexity. Once you stop assuming every endpoint returns the same shape of event, the rest follows.

FAQ

Do I still need the beta endpoint for Outlook calendar sync? For most of it, no. The recurring-exception properties exceptionOccurrences and cancelledOccurrences are declared on v1.0 and have been documented there since April 2025. The one thing still beta-only is the unbounded /events/delta endpoint. On v1.0 you get /calendarView/delta, which requires a start and end date, so if you want change tracking over a whole calendar with no date window, beta remains the only option.

Why does $expand not work on GET /events? Microsoft doesn't document this specifically, but its known-issues page acknowledges the general behavior, noting that unsupported query parameters may fail silently. In practice $expand=exceptionOccurrences only returns data on a GET of a single series master by its ID, which is why the batch endpoint works and the list endpoint doesn't.

How long does a delta token last? For Outlook entities including events, Microsoft doesn't publish a lifetime. Tokens are held in an internal cache and evicted by capacity rather than age, so there's no expiry timestamp to plan against. Handle both a 410 Gone sync reset and a 4xx syncStateNotFound expiry by falling back to a full bootstrap.

Why am I getting "Eastern Standard Time" instead of "America/New_York"? Graph returns Windows time zone IDs by default. You can request IANA formatting for start and end times with a Prefer: outlook.timezone header, and get the IANA zone list from supportedTimeZones(TimeZoneStandard='Iana'). Neither covers originalStartTimeZone, so a Windows-to-IANA mapping table is still worth building.

How many API calls does a first sync take? Roughly ceil(N/500) to list, plus ceil(S/20) to hydrate series masters, plus one or two to get the initial delta token, where N is the event count and S the number of recurring series. A 1,000 event calendar with 50 series comes to about 7 calls. Hydrating every event instead pushes the same calendar past 50.

What happens if I run two syncs on the same calendar at once? Delta tokens are sequential, so concurrent runs can commit tokens out of order and permanently skip the changes in between. Serialize per calendar with a database advisory lock, and coalesce bursts of webhook notifications into one pending run.

This guide reflects Microsoft Graph as of July 2026, checked against the current documentation and the live v1.0 metadata. Version-specific behavior moves; check the Microsoft Graph changelog if you're reading this much later.