Skip to content

How to use the Outlook Calendar API with Microsoft Graph in Node.js

Updated

TL;DR

Sign users in with @azure/msal-node (getAuthCodeUrl, then acquireTokenByCode), ask for offline_access and Calendars.ReadWrite, keep MSAL's token cache, and get fresh tokens with acquireTokenSilent. Read events with GET /me/calendarView?startDateTime=...&endDateTime=..., which expands recurring meetings, and send Prefer: outlook.timezone, or every start and end comes back in UTC. Follow @odata.nextLink until it stops. On a 429, wait the Retry-After seconds: Outlook allows 10,000 requests per 10 minutes and four concurrent requests per app per mailbox.

Register the app and choose an authority

Register a web app in Microsoft Entra's App registrations, add your callback as a Web redirect URI, and create a client secret. Your server is a confidential client, so it uses ConfidentialClientApplication.

The authority decides which accounts can sign in. Microsoft's MSAL configuration page gives these for an app used outside your own organization:

Authority Who can sign in
https://login.microsoftonline.com/common/ "work and school accounts or personal Microsoft accounts"
https://login.microsoftonline.com/organizations/ "work and school accounts"
https://login.microsoftonline.com/consumers/ "personal Microsoft accounts (MSA) only"

The authority "needs to be consistent with the Supported account types" you chose in the app registration.

Work accounts often can't approve a calendar app themselves. Microsoft's default consent policy, "Let Microsoft manage your consent settings", lets users consent to delegated permissions except a list that includes Calendars.Read, Calendars.ReadBasic and Calendars.ReadWrite. On a tenant with that default, users see "Need admin approval" until an administrator consents. See Outlook "Need admin approval".

Sign the user in with MSAL Node

MSAL Node's authorization code flow has two legs: getAuthCodeUrl() builds the sign-in URL, and acquireTokenByCode() exchanges the code that comes back to your redirect URI.

Ask for offline_access. Microsoft's identity platform says your app "must explicitly request the offline_access scope, to receive refresh tokens". The access token "is valid for around one hour" and the refresh token "is typically valid for 90 days".

msal.tsTypeScript
import { ConfidentialClientApplication } from '@azure/msal-node';

export const scopes = ['offline_access', 'Calendars.ReadWrite'];
export const redirectUri = 'https://app.example.com/auth/microsoft/callback';

export function createMsalClient() {
  return new ConfidentialClientApplication({
    auth: {
      clientId: process.env.MICROSOFT_CLIENT_ID!,
      authority: 'https://login.microsoftonline.com/common',
      clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
    },
  });
}
sign-in.tsTypeScript
// 1. Send the user to Microsoft's sign-in and consent page.
const msalClient = createMsalClient();
const signInUrl = await msalClient.getAuthCodeUrl({ scopes, redirectUri, state });

// 2. On the callback (after checking state), exchange the code.
const tokenResult = await msalClient.acquireTokenByCode({
  code: request.query.code,
  scopes,
  redirectUri,
});
await saveConnection(userId, {
  account: tokenResult.account,
  tokenCache: msalClient.getTokenCache().serialize(),
});

Keep the token cache, not the refresh token

This is where most people get stuck. MSAL Node does not hand you the refresh token. Microsoft's docs say it "is never returned to the user in a response, but can be accessed from the user cache", and recommend acquireTokenSilent(), with which "MSAL will handle the caching and refreshing of tokens automatically."

So store the serialized cache per user, load it before each call, and save it again afterwards, as in Microsoft's Node.js web app sample:

access-token.tsTypeScript
import { InteractionRequiredAuthError } from '@azure/msal-node';

export async function getAccessToken(connection) {
  const msalClient = createMsalClient();
  msalClient.getTokenCache().deserialize(connection.tokenCache);
  try {
    const tokenResult = await msalClient.acquireTokenSilent({
      account: connection.account,
      scopes,
    });
    await saveTokenCache(connection.id, msalClient.getTokenCache().serialize());
    return tokenResult.accessToken;
  } catch (error) {
    if (error instanceof InteractionRequiredAuthError) {
      // The refresh token is expired or revoked. The user has to sign in again.
    }
    throw error;
  }
}

The cache holds tokens, so encrypt it at rest.

calendarView or events

Both list events. They answer different questions.

  • GET /me/events returns "single instance meetings and series masters." A weekly meeting appears once, as the series master with its recurrence pattern.
  • GET /me/calendarView returns "the occurrences, exceptions, and single instances of events" in a time range. The weekly meeting appears once per week, with any moved or cancelled occurrence as it really is.

For anything that shows a schedule or checks availability, use calendarView. It requires startDateTime and endDateTime, and works for delegated work, school and personal Microsoft accounts with Calendars.ReadBasic as the least privileged permission.

The range parameters follow their own offset: they "are interpreted using the timezone offset specified in the value and aren't impacted by the value of the Prefer: outlook.timezone header". With no offset, the value "is interpreted as UTC."

Time zones and the Prefer header

Without Prefer: outlook.timezone, Graph returns every event's start and end in UTC. Microsoft: "If the Prefer: outlook.timezone header is not specified, the start and end times are returned in UTC." This is the usual cause of "Graph always gives me UTC".

The header takes Windows zone names such as Eastern Standard Time, plus a list of IANA names such as America/New_York. The dateTime value has no offset (2017-04-21T10:00:00.0000000), so always read it together with timeZone. To find the zone an event was created in, read originalStartTimeZone.

When you create or update an event, send start and end in the user's zone. Microsoft warns that create and update "might not support all" of the listed zones. A weekly 9:00 meeting created in UTC moves by an hour for its attendees when daylight saving time changes.

Paging

Graph pages its results. Microsoft's paging guide: "Use the entire URL in the @odata.nextLink property in a GET request to retrieve the next page," and "Don't try to extract the $skiptoken or $skip value." Keep going until there is no @odata.nextLink. Don't stop at an empty page: "A page of results might contain zero or more results."

The next link carries the original query parameters, not your headers, so send Prefer again on every page.

calendar-view.tsTypeScript
export async function listEvents(accessToken: string) {
  const query = new URLSearchParams({
    startDateTime: '2026-11-02T00:00:00-05:00',
    endDateTime: '2026-11-09T00:00:00-05:00',
  });
  let pageUrl: string | undefined =
    `https://graph.microsoft.com/v1.0/me/calendarView?${query}`;
  const events = [];
  let attempt = 0;

  while (pageUrl) {
    const response = await fetch(pageUrl, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
        Prefer: 'outlook.timezone="Eastern Standard Time"',
      },
    });
    if (response.status === 429) {
      attempt += 1;
      const waitSeconds = Number(response.headers.get('Retry-After')) || 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
      continue;
    }
    if (!response.ok) throw new Error(`Graph returned ${response.status}`);
    attempt = 0;
    const page = await response.json();
    events.push(...page.value);
    pageUrl = page['@odata.nextLink'];
  }
  return events;
}

Throttling

When you go over a limit, Graph answers 429 Too Many Requests with error code TooManyRequests and a Retry-After header. Wait that many seconds and retry. Retrying sooner makes it worse, because "Microsoft Graph continues to log resource usage while a client is being throttled." If there is no Retry-After, Microsoft recommends exponential backoff.

The Outlook limits apply "to each app ID and mailbox combination":

Limit Value
Requests 10,000 in a 10-minute period
Concurrency Four concurrent requests
Uploads (PATCH, POST, PUT) 150 MB in a 5-minute period

The concurrency limit is the one people hit first. A Promise.all over twenty requests for one mailbox gets throttled even though the total is small. Going over the limit for one mailbox "doesn't affect the ability of the application to access another mailbox."

JSON batching does not get around this. "Requests in a batch are evaluated individually against throttling limits"; the batch returns 200 while individual requests inside it return 429. The SDKs retry throttled requests automatically, but not the ones inside a batch.

To avoid throttling in the first place, Microsoft recommends change notifications and delta queries over polling.

How Pyas handles it

Pyas connects Outlook on Microsoft 365 and Outlook.com through one API, next to Google Calendar. With Pyas Auth, your users approve Pyas on Microsoft's own consent screen; with Native Auth, you use your own Entra app. Microsoft's default consent policy can still require an administrator to approve calendar access for work accounts, with Pyas Auth too.

Pyas stores and refreshes the tokens. GET /v2/accounts/{accountId}/status says whether an account works and, if not, why and the fix, and the connection.broken webhook fires when one stops working. Every event has startsAt in UTC, startsAtLocal and an IANA timezone, whatever Outlook stored, and Pyas renews Graph subscriptions itself.

Common questions

Why are all my event times in UTC?

You didn't send Prefer: outlook.timezone. Without it, Graph returns start and end in UTC. Add the header to every request, including each @odata.nextLink page.

Why does a weekly meeting show up only once?

You called /me/events, which returns the series master. Use /me/calendarView with a start and end to get each occurrence.

Where is the refresh token in MSAL Node?

In MSAL's token cache. MSAL doesn't return it to your code. Persist the serialized cache per user and call acquireTokenSilent, which refreshes when needed.

Does this work for personal Outlook.com accounts?

Yes for calendarView, which supports personal Microsoft accounts. Use the common or consumers authority. Some endpoints don't: getSchedule is "Not supported" for personal accounts.

How to fix "Need admin approval" (AADSTS65001, AADSTS90094) for an Outlook calendar appHow to build calendar webhooks that keep working (Google push channels and Microsoft Graph subscriptions)Microsoft Graph or EWS for Outlook calendars after the EWS retirement