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.
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.
{
"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. CallPOST /subscriptions/{id}/reauthorize, orPATCHa 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/eventsandme/contacts, for created, updated and deleted changes, each with a lifecycle URL. -
The endpoint echoes
validationTokenas plain text. Every subscription gets its own randomclientState; 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
PATCHat half their lifetime. A subscription Graph no longer knows is created again. -
reauthorizationRequiredandsubscriptionRemovedmark the subscription expired, so the account’s next pass renews or recreates it. Every verified notification,missedincluded, 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/deltaper folder plusmailFolders/delta, events withcalendarView/deltaper 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@removedentry 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
429answers wait forRetry-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:
// 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/eventssubscription 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/sdkis at 0.1.0-alpha.
Webhooks lists every event type, and the webhooks guide covers signatures, retries and replays.