NewThe TypeScript SDK is on npm as an alpha. Try the quickstart

Blog

Microsoft Graph change notifications for mail and calendar sync.

Graph tells you that a mailbox changed; a delta query tells you what changed, and subscriptions need constant upkeep to keep the first part working.

 · 6 min read

Polling Microsoft Graph for every mailbox stops working at scale. Outlook allows each app 10,000 requests per mailbox in a 10-minute window and four requests in flight at once (Outlook service limits), and a sync that checks every folder and calendar on a timer spends most of that on answers that say nothing changed.

Change notifications are the alternative: Graph POSTs to your HTTPS endpoint when a message, event or contact changes. They come with a validation handshake, subscriptions that expire, a second endpoint for lifecycle events, and delivery rules that drop notifications for slow receivers. This post covers what a mail and calendar sync needs from them, and why a notification only tells you where to look while a delta query tells you what changed.

Creating a subscription.

A subscription is a POST /subscriptions naming a resource, the change types and the URLs to deliver to (Receive change notifications through webhooks). For a mailbox the resource is me/messages, or one folder such as me/mailFolders('inbox')/messages; for a calendar sync it is me/events.

create-subscription.http
POST https://graph.microsoft.com/v1.0/subscriptions
Content-Type: application/json

{
  "changeType": "created,updated,deleted",
  "notificationUrl": "https://hooks.example.com/graph",
  "lifecycleNotificationUrl": "https://hooks.example.com/graph/lifecycle",
  "resource": "me/messages",
  "expirationDateTime": "2026-10-15T09:00:00Z",
  "clientState": "<random secret, at most 128 characters>"
}

Before Graph creates the subscription, it checks that you own the URL. It POSTs to notificationUrl?validationToken={token}, and your endpoint must answer within 10 seconds with 200 OK, Content-Type: text/plain and the URL-decoded token as the body. If a lifecycleNotificationUrl is set, it is validated the same way. A failed validation means no subscription.

clientState is your proof of origin. Graph echoes it in every notification, and Microsoft’s guidance is to treat any notification whose clientState does not match as forged. Generate one per subscription and keep it secret; the subscription resource caps it at 128 characters.

Lifetimes and renewal.

Subscriptions expire. For Outlook messages, events and contacts the maximum is 10,080 minutes, just under seven days, or 1,440 minutes for rich notifications that include resource data (subscription lifetime). Once expirationDateTime passes, Graph deletes the subscription and stops sending.

Renewal is a PATCH /subscriptions/{id} with a later expirationDateTime. Each notification carries subscriptionExpirationDateTime, but a quiet mailbox sends none, so schedule renewals yourself and leave slack for downtime. Renew instead of creating a second subscription: a request with the same changeType and resource as an existing one fails with 409 Conflict.

Lifecycle notifications.

Graph sends three kinds of lifecycle notification to lifecycleNotificationUrl (Reduce missing change notifications and removed subscriptions). reauthorizationRequired applies to every resource; subscriptionRemoved and missed apply to Outlook messages, events and personal contacts. The URL can only be set when the subscription is created, so a subscription without one has to be deleted and created again to get them.

lifecycle notification
{
  "value": [
    {
      "subscriptionId": "<subscription id>",
      "subscriptionExpirationDateTime": "2026-10-15T09:00:00Z",
      "tenantId": "<tenant id>",
      "clientState": "<your secret>",
      "lifecycleEvent": "reauthorizationRequired"
    }
  ]
}

In each case, answer 202 Accepted, check clientState, then:

  • reauthorizationRequired. The endpoint’s access token or the subscription is about to expire, or an administrator revoked access. Call POST /subscriptions/{id}/reauthorize, or PATCH a new expiration, which reauthorizes and renews at once. Microsoft warns against sending both for the same subscription within 10 minutes. Ignored, delivery eventually pauses, and changes made during the pause are not delivered.
  • subscriptionRemoved. The subscription is gone. Create a new one, then sync the resource to pick up what changed in between.
  • missed. Some notifications were not delivered, for example because of throttling. Resync the resource.

Notifications are hints; delta is the record.

A basic Outlook notification carries the changed resource’s id and little else (change notifications overview), and delivery is not guaranteed. Graph considers a notification delivered once your endpoint answers 2xx within 3 seconds; otherwise it retries for up to 4 hours. An endpoint where more than 10% of responses take longer than 3 seconds in a 10-minute window is marked slow and gets new notifications 10 minutes late. Past 15% over the 10-second retry timeout, it is marked drop, and notifications for the next 10 minutes are discarded for good. Add the gaps around reauthorization and removal, and some changes never produce a notification.

So a notification should mean one thing: look at this mailbox now. The change itself comes from a delta query (delta query overview). GET /me/mailFolders/{id}/messages/delta returns a folder’s messages page by page, ending with an @odata.deltaLink; calling that link later returns only what changed since (message: delta). Treat the links as opaque. Delta tokens for Outlook entities can expire, answered with an error such as syncStateNotFound, and a 410 Gone means starting over with a full sync.

The receiving endpoint follows from this: check clientState, queue the mailbox, and answer 202 within the 3 seconds, as Microsoft recommends. Do the reading elsewhere.

Throttling.

A busy mailbox sends notifications in bursts, and one POST can carry several. Fetching each message by id on every notification runs straight into the four-concurrent-request limit. Coalesce instead: many notifications for one mailbox become one delta pass, which reads every change in a few pages.

When Graph throttles, it answers 429 Too Many Requests with a Retry-After header, and waiting that long is the fastest way to recover, since Graph keeps counting requests made while throttled (Microsoft Graph throttling guidance). The limit is per app and mailbox, so one heavy mailbox does not slow the others.

How Workwae handles it.

  • Each Microsoft 365 account gets one subscription per resource: me/messages, me/events and me/contacts, for created, updated and deleted changes, each with a lifecycle URL.
  • The endpoint echoes validationToken as plain text. Every subscription gets its own random clientState; Workwae keeps only its SHA-256 hash and compares in constant time. Notifications for unknown subscriptions or with the wrong state are dropped.
  • Subscriptions are requested for three days and renewed with PATCH at half their lifetime. A subscription Graph no longer knows is created again.
  • reauthorizationRequired and subscriptionRemoved mark the subscription expired, so the account’s next pass renews or recreates it. Every verified notification, missed included, wakes that account’s sync, once per account and sync for a batch.
  • A pass subscribes before it reads, so a change made during the pass still wakes the next one. Mail syncs with messages/delta per folder plus mailFolders/delta, events with calendarView/delta per calendar, contacts with one delta per folder. An expired delta token triggers a full re-read of that folder, diffed against the index, and an @removed entry is confirmed with a GET before a message is reported deleted.
  • A backstop poll keeps running while push is active, for notifications Graph dropped. If a subscription cannot be created, the account is polled more often instead.
  • Requests are capped at four in flight per mailbox, and 429 answers wait for Retry-After. GCC High and DoD accounts follow the same flow against their cloud’s Graph host.

Your backend never sees a Graph notification. It receives Workwae webhooks for what the delta pass found: message.created, message.updated, message.deleted, the calendar_event.* and contact.* events, signed per Standard Webhooks. parseWebhook from @workwae/sdk verifies the signature and returns the typed event:

message.updated
// Your webhook, after a message was read in Outlook (abridged)
{
  "id": "48213",
  "type": "message.updated",
  "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
  "createdAt": "2026-10-12T09:30:00.000Z",
  "data": {
    "message": {
      "id": "c4e6a8b0-1d3f-4a5c-8e7b-9d1f3a5c7e9b",
      "subject": "Quarterly report",
      "isRead": true
    },
    "mailboxes": [{ "id": "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a", "name": "Inbox", "role": "inbox" }],
    "changes": ["isRead"]
  }
}

Limits.

  • Graph sets the pace. Microsoft lists message and contact notifications at under a minute on average and 3 minutes at most, and event latency as unknown (latency). A Workwae webhook follows after the delta pass.
  • Calendars. Graph sends no notification for a new calendar, so one appears with the next calendar listing. Whether the me/events subscription covers non-default calendars is not documented; the backstop poll covers them either way. Calendars of other mailboxes are not synced.
  • At least once. Workwae retries webhook deliveries, so deduplicate on webhook-id.
  • Alpha SDK. @workwae/sdk is at 0.1.0-alpha.

Webhooks lists every event type, and the webhooks guide covers signatures, retries and replays.