Integrations
Microsoft Graph webhook quirks: a shared-mailbox cheatsheet
A matter-intake agent for a 33-person Rotterdam law firm taught us which Microsoft Graph subscription calls return 202 Accepted and silently drop every notification.

The 11pm test that exposed our subscription
It is 23:14 on a Tuesday in Rotterdam. A senior partner forwarded a procurement tender to intake@, the firm's shared mailbox, three hours ago. The matter-intake agent we built should have parsed the brief, opened a conflict-check task in Clio, and paged the duty paralegal. It did none of those things. The Microsoft Graph subscription says it is healthy. Every renewal call returns 202 Accepted. The change-notification endpoint sits there waiting, untriggered.
This was the third time that week. The firm has thirty-three people, two tenant admins, and one IT contractor who shows up on Fridays. We were the ones on the hook for the missing tender. By the end of the rollout we had a cheatsheet of twenty-one Graph and Outlook 365 webhook quirks that bite when you connect any AI workflow to a shared mailbox. About a third of them return a 202 Accepted on subscription and never deliver a single payload. The rest fail in ways that look intermittent until you understand them.
Here is the ranked list, with the silent-drop offenders first.
The silent-drop tier: 202 Accepted, zero notifications
These are the items that ate two full evenings before we could explain them to the firm. All of them return 202 when you POST the subscription, and the Graph subscription endpoint happily echoes back an id and an expirationDateTime. Then nothing arrives.
1. Delegated permissions on a shared-mailbox resource
This is the one that caused the 23:14 incident. If you authenticate as a user via the authorization_code flow and POST a subscription against /users/intake@firm.nl/messages, Graph accepts it. The subscription will deliver exactly zero change notifications. Shared-mailbox change notifications require Application permissions (Mail.Read as Application, with admin consent), not delegated. The delegated Mail.Read.Shared scope lets you read the mailbox via REST, but it does not authorise the subscription channel.
The fix is to stand up a separate Entra ID app for the agent with Mail.Read (Application), have the tenant admin grant consent once, and use client-credentials for the subscription lifecycle. Keep the delegated app for anything user-context.
2. Tenant-wide subscriptions without RBAC for Applications
You can subscribe to /users at the tenant level with Application permissions. Without configuring RBAC for Applications in Exchange Online, you will get a 202 and notifications for every mailbox the app could theoretically reach. Many tenant admins assume the absence of an explicit policy is restrictive. It is the opposite. Until you bind the app to a mail-enabled security group via New-ApplicationAccessPolicy, you are subscribed to the partners' inboxes too.
3. Subscribing to a distribution list as if it were a mailbox
Distribution lists and mail-enabled security groups look like mailboxes to a Power Automate user. Graph accepts the subscription URL. No notifications fire, because there is no mailbox behind the list. Confirm mailboxType via /users/{upn}?$select=mailboxSettings before subscribing.
4. Encrypted notifications with a rotated public key
If you opted into rich notifications with resource data because you want the message body, not just a pointer, Graph encrypts the payload against a public key you registered. If your key rotates and you forget to update encryptionCertificate on the subscription, notifications are still attempted, fail to decrypt on your side, and Graph eventually marks the channel dead. The lifecycle notification arrives twelve hours later, by which point the partner has forwarded the tender to a competitor.
5. Validation handshake longer than ten seconds
On POST /subscriptions, Graph calls your notificationUrl with a validationToken query parameter. You have ten seconds to echo it back as text/plain. If your handler is behind a cold-start Lambda, the first call times out. Graph retries a few times, then gives up. The subscription is never created. The 202 you saw in your client library was the underlying HTTP call queueing, not Graph confirming.
6. ClientState mismatch logged on Graph's side, silent on yours
If your handler validates the clientState in the notification payload and rejects mismatches with anything other than a 2xx, Graph treats your endpoint as unhealthy and starts backing off. The result reads like throttling. Return 202 first, validate clientState second, drop quietly third.
The almost-silent tier: works until it does not
7. Token TTL shorter than subscription TTL
Subscriptions on messages cap at 4230 minutes, roughly 2.94 days. Your client-credentials token lives for one hour. If you only renew the subscription and never refresh the token, the renewal call eventually returns 401 and the channel dies on schedule.
8. ImmutableId is not enabled by default
Out of the box, the id property on a message changes when the message moves between folders, when a retention label applies, or when a delegate replies. Without setting the Prefer: IdType="ImmutableId" header, the message ID in your notification can refer to a message that no longer exists under that ID when you GET it. The setup is documented in the Graph immutable-identifiers page and it requires a tenant-level switch plus the header on every request.
9. Moves are deletes plus creates
If a paralegal drags the intake email into the matter folder, you will see a deleted notification followed by a created notification with a different ID. Your dedup logic has to account for this or the agent will think the email vanished and re-pull from the inbox.
10. The /me endpoint silently does not exist for shared mailboxes
Anything you copy from Graph Explorer that uses /me/messages will not work for the shared-mailbox flow. Replace with /users/{upn} everywhere. Easy to forget when prototyping.
11. SendAs versus SendOnBehalf is a separate permission
The agent can read the shared mailbox with one permission and still 403 when trying to reply as the mailbox. SendAs has to be granted in the Exchange admin centre, per app, per mailbox.
12. Categories are not on the delta endpoint
If you use /users/{upn}/mailFolders/inbox/messages/delta to catch up after downtime, the message objects returned do not include categories. You have to GET each message individually if you rely on category-based routing.
13. Conversation threading needs internetMessageHeaders
The conversationId property changes when a partner replies from a different mail client. To thread reliably across the lifetime of a matter, request $select=internetMessageHeaders and match on In-Reply-To and References.
14. Folder hierarchy changes need a separate subscription
Subscribing to messages does not give you folder events. If a partner archives the inbox into a new sub-folder, your agent silently loses its target.
The operational tier: knowable in advance, but they bite
15. Lifecycle notifications go to a different URL
subscriptionRemoved, reauthorizationRequired, and missed events deliver to lifecycleNotificationUrl, not notificationUrl. If you only registered one URL, you will never see the reauth signal until the subscription is already dead.
16. Subscription expiration is capped per resource type
Messages cap at 4230 minutes. Calendar events cap at 4230 too. Drive items cap at 41760 minutes (roughly 29 days). Teams chat messages cap at 60 minutes. If you copy a renewal loop from a OneDrive integration into an Outlook one, the longer interval gets rejected on some Graph regions, sometimes silently.
17. 429 throttling uses Retry-After in seconds, not milliseconds
The header is in seconds. Misreading it as milliseconds means hammering Graph for the next quarter hour and getting your app temporarily blocked.
18. Delta state tokens expire after 30 days of idle
If the agent is paused for a long weekend plus a Dutch national holiday, the delta token returns 410. You have to fall back to a full sync and reconcile.
19. notificationUrlAppId is required behind APIM
If your webhook endpoint sits behind Azure API Management with its own Entra ID validation, set notificationUrlAppId on the subscription so Graph attaches a token APIM can verify. Without it, your APIM logs read like a brute-force attack from Microsoft IP space.
20. Resource path is case-sensitive on subscription
mailFolders works. mailfolders returns 200 on a plain GET (Graph normalises) but the subscription endpoint rejects it. We logged thirty-one of these before tightening linting on the resource path.
21. Encrypted payloads are capped at 4MB
If the message body plus attachments encoded into the resource data exceed 4MB, Graph drops the inline payload and sends a pointer-only notification. Your handler has to be ready to fall back to a GET. We assumed encrypted mode meant every notification arrived fat. It does not.
If your subscription creation call returns 202 and nothing follows, check delegated-versus-application permissions before anything else. It was the root cause of most of our shared-mailbox incidents during the Rotterdam rollout.
A webhook handler that survives the obvious traps
This is the minimum endpoint we ship for any Graph-driven agent. It handles the validation token in time, acknowledges before doing work, and validates clientState off the hot path.
// Express handler that survives the 10s validation
// and the silent clientState gotcha.
import express from "express";
const app = express();
app.post("/webhooks/graph", express.text({ type: "*/*" }), (req, res) => {
// 1. Validation handshake: echo the token within 10 seconds.
if (req.query.validationToken) {
res.set("Content-Type", "text/plain");
return res.status(200).send(String(req.query.validationToken));
}
// 2. Acknowledge BEFORE doing any work. Graph treats anything
// slower than a few seconds as a sign the endpoint is unhealthy.
res.status(202).end();
// 3. Parse, validate clientState, hand off to a queue.
const payload = JSON.parse(req.body);
for (const note of payload.value ?? []) {
if (note.clientState !== process.env.GRAPH_CLIENT_STATE) {
console.warn("clientState mismatch, dropping", { id: note.subscriptionId });
continue;
}
queue.publish("graph.message.changed", note);
}
});
And the subscription body we POST against /v1.0/subscriptions. Note the separate lifecycleNotificationUrl, the includeResourceData opt-in, and the encryptionCertificateId we rotate on a quarterly schedule.
{
"changeType": "created,updated",
"notificationUrl": "https://intake.firm.nl/webhooks/graph",
"lifecycleNotificationUrl": "https://intake.firm.nl/webhooks/graph/lifecycle",
"resource": "users/intake@firm.nl/mailFolders/inbox/messages",
"expirationDateTime": "2026-06-17T08:30:00Z",
"clientState": "rotated-quarterly-secret",
"includeResourceData": true,
"encryptionCertificate": "MIIDdz...",
"encryptionCertificateId": "intake-key-2026Q2",
"notificationUrlAppId": "e8d4..."
}
What to do tomorrow morning
When we built the matter-intake agent for the Rotterdam firm, the thing we ran into first was that the tenant admin had signed off the subscription request as if it were a normal user OAuth flow. We ended up splitting the read path (delegated, user-context, scoped to the requesting paralegal) from the subscription path (Application, with an Exchange application access policy binding the app to one mail-enabled security group). That single split removed six of the twenty-one items above. The same pattern shows up in every AI agent we build on top of a shared inbox.
The smallest thing you can do today: open your subscription creation call, decode the bearer token, and look at its claims. If the token contains a roles claim with the Application permission your resource path needs, you are fine. If it contains scp instead, you have a delegated token, and the 202 you are about to receive is lying to you.
Key takeaway
Shared-mailbox webhook subscriptions need Application permissions. Delegated permissions return 202 Accepted and silently drop every notification.
FAQ
Why does my Microsoft Graph subscription return 202 but never deliver notifications?
Most often because you used delegated permissions on a shared-mailbox subscription. Graph requires Application permissions for shared-mailbox change notifications, with admin consent.
How long can an Outlook message subscription stay alive?
4230 minutes, just under 2.94 days. Renew before expiry. If the subscription dies, you have to rebuild and reconcile any events that landed during the gap.
Do I need to handle the Graph validation token?
Yes. Graph posts a validationToken query string when you create the subscription. You have ten seconds to echo it back as text/plain or the subscription is silently rejected.
What is the difference between notificationUrl and lifecycleNotificationUrl?
notificationUrl receives change events. lifecycleNotificationUrl receives reauthorizationRequired, missed, and subscriptionRemoved events. Register both or you miss reauth signals.
Can one subscription cover both messages and folder events?
No. You need separate subscriptions for the messages resource and the mailFolders resource. A messages subscription will not fire when a folder is created, renamed, or moved.