If you’re building an app that reads calendar data from Microsoft 365 or Outlook accounts, you can either work directly with the Microsoft Graph API or use Nylas as a unified layer that handles the provider differences for you.
With Nylas, the API call to list events is identical whether the account is Microsoft, Google, or iCloud. The differences show up in timezone handling, Teams conferencing data, recurring event behavior, and admin consent requirements. This guide covers all of that.
Why use Nylas instead of Microsoft Graph directly?
Section titled “Why use Nylas instead of Microsoft Graph directly?”The Microsoft Graph Calendar API is powerful, but integrating it means dealing with Azure AD app registration, OAuth scope configuration, MSAL token refresh logic, admin consent flows for enterprise tenants, and Windows timezone ID mapping (Graph returns identifiers like “Eastern Standard Time” instead of IANA timezones). You also need to handle Microsoft’s specific data formats for recurring events, conferencing links, and all-day event boundaries.
Nylas normalizes all of that behind a single REST API. Your code stays the same whether you’re reading from Outlook, Google Calendar, or iCloud. No Azure AD setup, no MSAL token lifecycle, no mapping Windows timezone IDs to IANA in your application code. If you need to support multiple calendar providers or want to skip the Graph onboarding, Nylas is the faster path.
That said, if you only need Microsoft calendars and already have Graph experience, direct integration works fine.
Before you begin
Section titled “Before you begin”You’ll need:
- A Nylas application with a valid API key
- A grant for a Microsoft 365 or Outlook account
- The
Calendars.Readscope enabled in your Azure AD app registration
New to Nylas? Start with the quickstart guide to set up your app and connect a test account before continuing here.
Microsoft admin consent
Section titled “Microsoft admin consent”Microsoft organizations often require admin approval before third-party apps can access calendar data. If your users see a “Need admin approval” screen during auth, their organization restricts user consent.
You have two options:
- Ask the tenant admin to grant consent for your app via the Azure portal
- Configure your Azure app to request only permissions that don’t need admin consent
Nylas has a detailed walkthrough: Configuring Microsoft admin approval. If you’re targeting enterprise customers, you’ll almost certainly need to deal with this.
You also need to be a verified publisher. Microsoft requires publisher verification since November 2020, and without it users see an error during auth.
List events
Section titled “List events”Make a List Events request with the grant ID and a calendar_id. Nylas returns the most recent events by default. You can use primary as the calendar_id to target the account’s default calendar. These examples limit results to 5:
curl --request GET \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/events?calendar_id=<CALENDAR_ID>&start=<TIMESTAMP>&end=<TIMESTAMP>' \ --header 'Accept: application/json, application/gzip' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --header 'Content-Type: application/json'{ "request_id": "cbd60372-df33-41d3-b203-169ad5e3AAAA", "data": [{ "busy": true, "calendar_id": "primary", "conferencing": { "details": { "meeting_code": "ist-****-tcz", "url": "https://meet.google.com/ist-****-tcz" }, "provider": "Google Meet" }, "created_at": 1701974804, "creator": { "name": "" }, "description": null, "grant_id": "1e3288f6-124e-405d-a13a-635a2ee54eb2", "hide_participants": false, "html_link": "https://www.google.com/calendar/event?eid=NmE0dXIwabQAAAA", "id": "6aaaaaaame8kpgcid6hvd", "object": "event", "organizer": { "name": "" }, "participants": [ { "status": "yes" }, { "status": "yes" } ], "read_only": true, "reminders": { "overrides": null, "use_default": true }, "status": "confirmed", "title": "Holiday check in", "updated_at": 1701974915, "when": { "end_time": 1701978300, "end_timezone": "America/Los_Angeles", "object": "timespan", "start_time": 1701977400, "start_timezone": "America/Los_Angeles" } }]}import 'dotenv/config'import Nylas from 'nylas'
const NylasConfig = { apiKey: process.env.NYLAS_API_KEY, apiUri: process.env.NYLAS_API_URI}
const nylas = new Nylas(NylasConfig)
async function fetchAllEventsFromCalendar() { try { const events = await nylas.events.list({ identifier: process.env.NYLAS_GRANT_ID, queryParams: { calendarId: process.env.CALENDAR_ID, } })
console.log('Events:', events) } catch (error) { console.error('Error fetching calendars:', error) }}
fetchAllEventsFromCalendar()from dotenv import load_dotenvload_dotenv()
import osimport sysfrom nylas import Client
nylas = Client( os.environ.get('NYLAS_API_KEY'), os.environ.get('NYLAS_API_URI'))
grant_id = os.environ.get("NYLAS_GRANT_ID")
events = nylas.events.list( grant_id, query_params={ "calendar_id": os.environ.get("CALENDAR_ID") })
print(events)require 'nylas'
nylas = Nylas::Client.new(api_key: "<NYLAS_API_KEY>")
query_params = { calendar_id: "<CALENDAR_ID>"}
# Read events from our main calendar in the specified date and timeevents, _request_ids = nylas.events.list(identifier: "<NYLAS_GRANT_ID>", query_params: query_params)
events.each {|event| case event[:when][:object] when 'timespan' start_time = Time.at(event[:when][:start_time]).strftime("%d/%m/%Y at %H:%M:%S") end_time = Time.at(event[:when][:end_time]).strftime("%d/%m/%Y at %H:%M:%S") event_date = "The time of the event is from: #{start_time} to #{end_time}" when 'datespan' start_time = event[:when][:start_date] end_time = event[:when][:end_date] event_date = "The date of the event is from: #{start_time} to: #{end_time}" when 'date' start_time = event[:when][:date] event_date = "The date of the event is: #{start_time}" end event[:participants].each {|participant| participant_details += "Email: #{participant[:email]} " \ "Name: #{participant[:name]} Status: #{participant[:status]} - " } print "Id: #{event[:id]} | Title: #{event[:title]} | #{event_date} | " puts "Participants: #{participant_details.chomp(' - ')}" puts "\n"}import com.nylas.NylasClient;import com.nylas.models.When;
import com.nylas.models.*;import java.text.SimpleDateFormat;import java.util.List;import java.util.Objects;
public class read_calendar_events { public static void main(String[] args) throws NylasSdkTimeoutError, NylasApiError { NylasClient nylas = new NylasClient.Builder("<NYLAS_API_KEY>").build();
// Build the query parameters to filter our the results ListEventQueryParams listEventQueryParams = new ListEventQueryParams.Builder("<CALENDAR_ID>").build();
// Read the events from our main calendar List<Event> events = nylas.events().list("<NYLAS_GRANT_ID>", listEventQueryParams).getData();
for (Event event : events) { System.out.print("Id: " + event.getId() + " | "); System.out.print("Title: " + event.getTitle());
// Dates are handled differently depending on the event type switch (Objects.requireNonNull(event.getWhen().getObject()).getValue()) { case "datespan" -> { When.Datespan date = (When.Datespan) event.getWhen();
System.out.print(" | The date of the event is from: " + date.getStartDate() + " to " + date.getEndDate()); } case "date" -> { When.Date date = (When.Date) event.getWhen();
System.out.print(" | The date of the event is: " +date.getDate()); } case "timespan" -> { When.Timespan timespan = (When.Timespan) event.getWhen();
String initDate = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"). format(new java.util.Date((timespan.getStartTime() * 1000L)));
String endDate = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"). format(new java.util.Date((timespan.getEndTime() * 1000L)));
System.out.print(" | The time of the event is from: " + initDate + " to " + endDate); } }
System.out.print(" | Participants: ");
for(Participant participant : event.getParticipants()){ System.out.print(" Email: " + participant.getEmail() + " Name: " + participant.getName() + " Status: " + participant.getStatus()); }
System.out.println("\n"); } }}import com.nylas.NylasClientimport com.nylas.models.*
import java.util.*
fun main(args: Array<String>) { val nylas: NylasClient = NylasClient(apiKey = "<NYLAS_API_KEY>")
val eventquery: ListEventQueryParams = ListEventQueryParams(calendarId = "<CALENDAR_ID>")
// Get a list of events val myevents: List<Event> = nylas.events().list( "<NYLAS_GRANT_ID>", queryParams = eventquery).data
// Loop through the events for(event in myevents){ print("Id: " + event.id + " | "); print("Title: " + event.title);
// Get the details of Date and Time of each event. when(event.getWhen().getObject().toString()) { "DATE" -> { val datespan = event.getWhen() as When.Date
print(" | The date of the event is: " + datespan.date); } "DATESPAN" -> { val datespan = event.getWhen() as When.Datespan
print(" | The date of the event is: " + datespan.startDate); } "TIMESPAN" -> { val timespan = event.getWhen() as When.Timespan val startDate = Date(timespan.startTime.toLong() * 1000) val endDate = Date(timespan.endTime.toLong() * 1000)
print(" | The time of the event is from: $startDate to $endDate"); } }
print(" | Participants: ");
// Get a list of the event participants val participants = event.participants
// Loop through and print their email, name and status for(participant in participants) { print(" Email: " + participant.name + " Name: " + participant.email + " Status: " + participant.status) }
println("\n") }}The calendar_id parameter is required for all Events endpoints. For Microsoft accounts, calendar IDs are long base64-encoded strings, but you can use primary as a shortcut to target the default calendar. The response format is the same regardless of provider, so your parsing logic works across Microsoft, Google, and iCloud without changes.
Filter events
Section titled “Filter events”You can narrow results with query parameters. Here’s what works with Microsoft accounts:
| Parameter | What it does | Example |
|---|---|---|
calendar_id | Required. Filter by calendar | ?calendar_id=primary |
title | Match on event title (case insensitive) | ?title=standup |
description | Match on description (case insensitive) | ?description=quarterly |
location | Match on location (case insensitive) | ?location=Room%20A |
start | Events starting at or after a Unix timestamp | ?start=1706000000 |
end | Events ending at or before a Unix timestamp | ?end=1706100000 |
attendees | Filter by attendee email (comma-delimited) | [email protected] |
busy | Filter by busy status | ?busy=true |
metadata_pair | Filter by metadata key-value pair | ?metadata_pair=project_id:abc123 |
Microsoft also supports several additional filter parameters beyond the standard set:
show_cancelled- include cancelled events in results (defaults tofalse)tentative_as_busy- treat tentative events as busy when checking availabilityupdated_after/updated_before- filter by last-modified timestamp, useful for incremental syncical_uid- find a specific event by its iCalendar UIDmaster_event_id- list all instances and overrides for a specific recurring series
Combining filters works the way you’d expect. This example pulls events in a specific time range:
curl --request GET \ --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/events?calendar_id=primary&title=standup&start=1706000000&end=1706100000&limit=10" \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>'const events = await nylas.events.list({ identifier: grantId, queryParams: { calendarId: "primary", title: "standup", start: 1706000000, end: 1706100000, limit: 10, },});events = nylas.events.list( grant_id, query_params={ "calendar_id": "primary", "title": "standup", "start": 1706000000, "end": 1706100000, "limit": 10, })Things to know about Microsoft
Section titled “Things to know about Microsoft”A few provider-specific details that matter when you’re building against Microsoft calendar accounts.
Timezone handling
Section titled “Timezone handling”Microsoft Graph stores timezone information using Windows timezone identifiers like “Eastern Standard Time” or “Pacific Standard Time” rather than IANA identifiers like “America/New_York” or “America/Los_Angeles”. Nylas normalizes these automatically, so event times in API responses always use IANA timezone identifiers. You don’t need to maintain a Windows-to-IANA mapping table in your application.
All-day events end one day later
Section titled “All-day events end one day later”When Nylas returns an all-day event (a datespan object) from Microsoft, the end_date is set to the day after the event actually ends. This matches how Microsoft Graph represents all-day events internally. A single-day event on December 1st comes back with start_date: "2024-12-01" and end_date: "2024-12-02".
If you’re displaying events in a calendar UI, subtract one day from the end_date to show the correct range. This behavior is the same for Google, so your display logic doesn’t need to be provider-specific.
Teams meeting links
Section titled “Teams meeting links”Events created with Microsoft Teams conferencing include a conferencing object in the response with provider set to "Microsoft Teams". The details array contains the join URL and any dial-in information. If you’re building a meeting list or join button, check for this field on every event - it’s only present when the organizer added Teams to the invite.
Tentative events
Section titled “Tentative events”Microsoft treats tentative events as busy by default when calculating availability. The tentative_as_busy query parameter controls this. If your app needs to distinguish between confirmed and tentative events, check the status field on each event object.
Calendar IDs
Section titled “Calendar IDs”Microsoft calendar IDs are long base64-encoded strings like AAMkAGI2TG93AAA=. These are stable and safe to store, but they take up more space than Google’s shorter numeric IDs. For the account’s default calendar, use primary as a shortcut instead of fetching the full ID. If you need to list events from a non-default calendar, call List Calendars first to get the available calendar IDs.
Recurring event quirks
Section titled “Recurring event quirks”Microsoft handles recurring events differently from Google in a few important ways:
- Overrides are removed on recurrence change. If you modify a recurring series (for example, changing from weekly to daily), Microsoft removes all existing overrides. Google keeps them if they still fit the pattern.
- No multi-day monthly BYDAY. You can’t create a monthly recurring event on multiple days of the week (like the first and third Thursday). Microsoft’s recurrence model doesn’t support different indices within a single rule.
- EXDATE recovery isn’t possible. Once you remove an occurrence from a recurring series, you can’t undo it through Nylas. You’d need to create a separate standalone event to fill the gap.
- Rescheduling constraints. Microsoft Exchange won’t let you move a recurring instance to the same day as, or the day before, the previous instance. Overlapping instances within a series aren’t allowed.
For the full breakdown of Google vs. Microsoft recurring event differences, see Recurring events.
Cancelled events
Section titled “Cancelled events”When the organizer deletes a recurring event instance, Microsoft and Google handle it differently. Google marks deleted occurrences as “cancelled” and keeps them retrievable with show_cancelled=true. Microsoft removes them entirely from the system - you can’t get them back through the API.
For non-recurring events, deletion behavior is more straightforward: the event disappears from list results. Use show_cancelled=true if you need to see events that participants declined or that the organizer cancelled but hasn’t fully removed yet.
Rate limits
Section titled “Rate limits”Microsoft throttles API requests at the per-mailbox level. If your app triggers a 429 response, Nylas handles the retry automatically with appropriate backoff, so you don’t need to implement retry logic yourself.
If you’re polling a calendar frequently, you’ll burn through rate limits fast. Webhooks solve this by notifying you of changes in real time without any polling requests.
Sync timing
Section titled “Sync timing”Calendar events typically appear within seconds of being created or updated. If an event you know exists isn’t showing up in list results yet, wait a moment and retry. This is a Microsoft-side sync delay, not a Nylas one.
For apps that need real-time awareness of calendar changes, use webhooks instead of polling. Nylas pushes a notification to your server as soon as the event syncs.
Paginate through results
Section titled “Paginate through results”The Events API returns paginated responses. When there are more results, the response includes a next_cursor value. Pass it back as page_token to get the next page:
curl --request GET \ --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/events?calendar_id=primary&limit=10&page_token=<NEXT_CURSOR>" \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>'let pageCursor = undefined;
do { const result = await nylas.events.list({ identifier: grantId, queryParams: { calendarId: "primary", limit: 10, pageToken: pageCursor, }, });
// Process result.data here
pageCursor = result.nextCursor;} while (pageCursor);page_cursor = None
while True: query = {"calendar_id": "primary", "limit": 10} if page_cursor: query["page_token"] = page_cursor
result = nylas.events.list(grant_id, query_params=query)
# Process result.data here
page_cursor = result.next_cursor if not page_cursor: breakKeep paginating until the response comes back without a next_cursor.
What’s next
Section titled “What’s next”- Events API reference for full endpoint documentation and all available parameters
- Using the Events API for creating, updating, and deleting events
- Recurring events for series creation, overrides, and provider-specific behavior
- Add conferencing to events to attach Teams, Zoom, or other meeting links
- Availability to check free/busy across multiple calendars
- Webhooks for real-time notifications instead of polling
- Microsoft admin approval to configure consent for enterprise organizations
- Microsoft publisher verification, required for production apps