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.

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:
| Endpoint | What you get |
|---|---|
GET /events | Full event data, but $expand is ignored, so recurring series arrive without their exceptions or cancellations |
GET /events/delta | Only id, type, start, and end. Genuinely partial |
POST /$batch | Full 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 value | Meaning |
|---|---|
singleInstance | A normal, non-recurring event |
seriesMaster | The 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:
exceptionOccurrencesis an array of modified instances, each a full event objectcancelledOccurrencesis 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:
| Status | Action |
|---|---|
| 200 | Upsert |
| 404 | Treat as deleted. The event disappeared between the delta response and your fetch |
| 403 | Log and skip |
| 429 or 5xx | Retry 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.type | RRULE FREQ |
|---|---|
daily | DAILY |
weekly | WEEKLY |
absoluteMonthly | MONTHLY |
relativeMonthly | MONTHLY with BYDAY and BYSETPOS |
absoluteYearly | YEARLY |
relativeYearly | YEARLY 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.timezoneon any GET that returns events sets the zone forstartandend. 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:
- Proactive renewal. When handling a notification, check whether the subscription expires within 24 hours and
PATCH /subscriptions/{id}if so - Lifecycle notifications. Microsoft sends
subscriptionRemovedandreauthorizationRequiredwhen something breaks. Recreate on those - 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
| Step | Calls |
|---|---|
| List events | ceil(N / 500) |
| Batch-hydrate series masters | ceil(S / 20) |
| Traverse delta for the initial token | 1 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
| Step | Calls |
|---|---|
| Traverse delta | 1 or more |
| Batch-hydrate changes | ceil(C / 20) |
Anything under 20 changes is 2 calls. Incremental sync is cheap, which is the whole point of the delta token.
Recovery
| Condition | What to do |
|---|---|
Delta returns 410 Gone | This 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 syncStateNotFound | The token expired. Same recovery, full bootstrap |
| Batch item returns 404 | Treat as deleted |
| Batch item returns 429 or 5xx | Retry that ID individually |
| Whole batch request fails | Retry its IDs individually |
| Subscription 404s on renewal | Create a new subscription |
Series master missing exceptionOccurrences after an expand | Log an error and continue. Do not treat it as "no exceptions" |
| OAuth refresh fails | Prompt 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:
| Aspect | Google Calendar | Microsoft Graph |
|---|---|---|
| Incremental sync data | Full events via syncToken | IDs only, so you must re-hydrate |
| Recurring exceptions | Separate events in the sync response | $expand=exceptionOccurrences on a GET of the series master |
| Cancelled instances | Events with status: cancelled | cancelledOccurrences, a string array with .YYYY-MM-DD suffixes |
| Recurrence format | RRULE strings | Structured objects needing conversion |
| Time zones | IANA | Windows by default, IANA available with extra work |
| Token expiry | Documented window | No published lifetime for Outlook entities |
| Endpoint stability | v3, stable | v1.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
- 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
- Hydrate series masters on bootstrap, everything on incremental. The asymmetry is the difference between 3 calls and 55 on a first sync
- Verify what you receive rather than what's documented. An empty array and a silently dropped
$expandlook identical - Save delta tokens last. Anything else silently skips events
- 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.