Integrations
Microsoft Graph webhooks: 21 valkuilen bij shared mailbox
Een matter-intake agent voor een Rotterdams kantoor van 33 mensen leerde ons welke Microsoft Graph subscription-calls 202 Accepted teruggeven en daarna elke notificatie stil laten vallen.

De 23:14-test die onze subscription verraadde
Het is 23:14 op een dinsdag in Rotterdam. Drie uur geleden stuurde een senior partner een aanbestedingsdossier door naar intake@, de shared mailbox van het kantoor. De matter-intake agent die we bouwden had de brief moeten parsen, een conflict-check taak moeten openen in Clio, en de dienstdoende paralegal moeten oppiepen. Niets van dat alles gebeurde. De Microsoft Graph subscription meldt dat alles in orde is. Elke renewal-call geeft 202 Accepted terug. De change-notification endpoint zit daar te wachten, ongetriggerd.
Dit was de derde keer die week. Het kantoor telt drieëndertig mensen, twee tenant admins, en één IT-contractor die op vrijdag langskomt. Wij waren degenen die verantwoordelijk waren voor de gemiste aanbesteding. Aan het einde van de uitrol hadden we een cheatsheet van eenentwintig Graph en Outlook 365 webhook-valkuilen die toeslaan zodra je een AI-workflow koppelt aan een shared mailbox. Ongeveer een derde geeft een 202 Accepted bij subscription en levert vervolgens geen enkele payload af. De rest faalt op manieren die intermitterend lijken, tot je ze doorgrondt.
Hier is de gerangschikte lijst, met de stille-droppers eerst.
De stille-drop categorie: 202 Accepted, nul notificaties
Dit zijn de items die twee volle avonden opslokten voordat we ze konden uitleggen aan het kantoor. Allemaal geven ze 202 terug zodra je de subscription POST, en de Graph subscription endpoint kaatst opgewekt een id en een expirationDateTime terug. Daarna komt er niets meer.
1. Delegated permissions op een shared-mailbox resource
Dit is de oorzaak van het incident van 23:14. Als je authenticeert als gebruiker via de authorization_code flow en een subscription POST naar /users/intake@firm.nl/messages, accepteert Graph dat. De subscription levert vervolgens precies nul change notifications. Shared-mailbox change notifications vereisen Application permissions (Mail.Read als Application, met admin consent), niet delegated. De delegated Mail.Read.Shared scope laat je de mailbox lezen via REST, maar geeft geen autorisatie voor het subscription-kanaal.
De fix is een aparte Entra ID app opzetten voor de agent met Mail.Read (Application), de tenant admin één keer consent laten geven, en client-credentials gebruiken voor de subscription lifecycle. Houd de delegated app aan voor alles wat user-context vereist.
2. Tenant-brede subscriptions zonder RBAC for Applications
Je kunt op tenant-niveau subscriben op /users met Application permissions. Zonder RBAC for Applications in Exchange Online te configureren, krijg je een 202 én notificaties voor elke mailbox die de app theoretisch kan bereiken. Veel tenant admins gaan ervan uit dat het ontbreken van een expliciete policy juist restrictief is. Het tegenovergestelde is waar. Totdat je de app bindt aan een mail-enabled security group via New-ApplicationAccessPolicy, ben je ook geabonneerd op de inboxen van de partners.
3. Subscriben op een distribution list alsof het een mailbox is
Distribution lists en mail-enabled security groups zien eruit als mailboxen voor een Power Automate gebruiker. Graph accepteert de subscription URL. Er vuren geen notificaties, want er zit geen mailbox achter de lijst. Verifieer mailboxType via /users/{upn}?$select=mailboxSettings vóór je subscribet.
4. Encrypted notifications met een geroteerde public key
Heb je gekozen voor rich notifications with resource data omdat je de message body wilt en niet alleen een pointer, dan encrypt Graph de payload tegen een public key die jij hebt geregistreerd. Roteert je key en vergeet je encryptionCertificate op de subscription bij te werken, dan blijven notificaties wel binnenkomen, maar mislukt de decryptie aan jouw kant en markeert Graph het kanaal uiteindelijk als dood. De lifecycle notificatie volgt twaalf uur later, tegen die tijd heeft de partner de aanbesteding al doorgestuurd naar een concurrent.
5. Validation handshake langer dan tien seconden
Bij POST /subscriptions roept Graph je notificationUrl aan met een validationToken query parameter. Je hebt tien seconden om die terug te echoën als text/plain. Zit je handler achter een cold-start Lambda, dan loopt de eerste call tegen een timeout aan. Graph probeert het een paar keer en geeft het op. De subscription wordt nooit aangemaakt. De 202 die je in je client library zag, was de onderliggende HTTP-call die in de queue ging staan, niet Graph die bevestigde.
6. ClientState mismatch gelogd aan de kant van Graph, stil aan die van jou
Als je handler de clientState in de notificatie-payload valideert en mismatches afkeurt met iets anders dan een 2xx, beschouwt Graph je endpoint als ongezond en begint terug te schalen. Het resultaat oogt als throttling. Stuur eerst een 202, valideer daarna clientState, en drop daarna stilletjes.
De bijna-stille categorie: werkt totdat het niet meer werkt
7. Token TTL korter dan subscription TTL
Subscriptions op messages zitten gemaximeerd op 4230 minutes, ongeveer 2,94 dagen. Je client-credentials token leeft één uur. Als je alleen de subscription vernieuwt en nooit het token ververst, geeft de renewal-call uiteindelijk een 401 en sterft het kanaal precies op schema.
8. ImmutableId staat niet standaard aan
Standaard verandert de id property op een message zodra de message tussen folders verplaatst wordt, een retention label wordt toegepast, of een delegate antwoordt. Zonder de header Prefer: IdType="ImmutableId" kan het message-ID in je notificatie verwijzen naar een message die onder dat ID niet meer bestaat zodra je een GET doet. De setup staat op de Graph immutable-identifiers pagina en vereist een tenant-level schakelaar plus de header op elke request.
9. Verplaatsingen zijn deletes plus creates
Sleept een paralegal de intake-e-mail naar de matter folder, dan zie je een deleted notificatie gevolgd door een created notificatie met een ander ID. Je dedup-logica moet daarop voorbereid zijn, anders denkt de agent dat de e-mail verdwenen is en haalt opnieuw uit de inbox.
10. De /me endpoint bestaat stilletjes niet voor shared mailboxes
Alles wat je uit Graph Explorer kopieert dat /me/messages gebruikt, werkt niet voor de shared-mailbox flow. Vervang het overal door /users/{upn}. Makkelijk te vergeten tijdens het prototypen.
11. SendAs versus SendOnBehalf is een aparte permission
De agent kan de shared mailbox lezen met de ene permission en alsnog een 403 krijgen wanneer hij wil antwoorden namens de mailbox. SendAs moet je toekennen in het Exchange admin centre, per app, per mailbox.
12. Categories zitten niet op de delta endpoint
Gebruik je /users/{upn}/mailFolders/inbox/messages/delta om na downtime bij te lezen, dan bevatten de teruggegeven message objects geen categories. Je moet elke message apart GET'en als je leunt op category-gebaseerde routing.
13. Conversation threading heeft internetMessageHeaders nodig
De conversationId property verandert wanneer een partner antwoordt vanuit een andere mailclient. Wil je betrouwbaar threaden over de hele looptijd van een matter, vraag dan $select=internetMessageHeaders op en match op In-Reply-To en References.
14. Wijzigingen in de folderhiërarchie vragen een aparte subscription
Subscriben op messages levert je geen folder events. Archiveert een partner de inbox in een nieuwe sub-folder, dan raakt je agent stilletjes zijn doel kwijt.
De operationele categorie: vooraf kenbaar, maar ze bijten alsnog
15. Lifecycle notifications gaan naar een andere URL
subscriptionRemoved, reauthorizationRequired, en missed events worden afgeleverd op lifecycleNotificationUrl, niet op notificationUrl. Heb je slechts één URL geregistreerd, dan zie je het reauth-signaal pas wanneer de subscription al dood is.
16. Subscription-expiratie is gemaximeerd per resource-type
Messages zitten op 4230 minuten. Calendar events ook op 4230. Drive items op 41760 minuten (ongeveer 29 dagen). Teams chat messages op 60 minuten. Kopieer je een renewal-loop uit een OneDrive integratie naar een Outlook-versie, dan wordt het langere interval op sommige Graph regio's afgewezen, soms stilletjes.
17. 429 throttling gebruikt Retry-After in seconden, niet milliseconden
De header staat in seconden. Lees je 'm verkeerd als milliseconden, dan ram je Graph het komende kwartier plat en wordt je app tijdelijk geblokkeerd.
18. Delta state tokens verlopen na 30 dagen inactiviteit
Staat de agent een lang weekend plus een nationale feestdag stil, dan geeft het delta token een 410 terug. Je moet terugvallen op een full sync en reconciliëren.
19. notificationUrlAppId is verplicht achter APIM
Zit je webhook endpoint achter Azure API Management met zijn eigen Entra ID validatie, zet dan notificationUrlAppId op de subscription zodat Graph een token meestuurt dat APIM kan verifiëren. Zonder dat lijken je APIM logs op een brute-force aanval vanuit Microsoft IP-ruimte.
20. Resource path is hoofdlettergevoelig bij subscription
mailFolders werkt. mailfolders geeft een 200 terug op een gewone GET (Graph normaliseert), maar de subscription endpoint wijst het af. We logden eenendertig van deze gevallen voordat we de linting op de resource path aanscherpten.
21. Encrypted payloads zijn gemaximeerd op 4MB
Overschrijden de message body plus attachments, geëncodeerd in de resource data, samen 4MB, dan dropt Graph de inline payload en stuurt alleen een pointer-notificatie. Je handler moet klaar staan om terug te vallen op een GET. Wij namen aan dat encrypted mode betekende dat elke notificatie dik binnenkwam. Dat is niet zo.
Komt je subscription creation call terug met 202 en gebeurt er daarna niets, controleer dan vóór alles delegated- versus application-permissions. Het was de hoofdoorzaak van de meeste shared-mailbox incidenten tijdens de Rotterdamse uitrol.
Een webhook handler die de voor de hand liggende valkuilen overleeft
Dit is het minimum-endpoint dat we uitleveren voor elke Graph-gedreven agent. Hij verwerkt het validation token op tijd, bevestigt voordat hij werk doet, en valideert clientState buiten het 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);
}
});
En de subscription body die we POST'en naar /v1.0/subscriptions. Let op de aparte lifecycleNotificationUrl, de opt-in via includeResourceData, en de encryptionCertificateId die we per kwartaal roteren.
{
"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..."
}
Wat je morgenochtend kunt doen
Toen we de matter-intake agent voor het Rotterdamse kantoor bouwden, was het eerste wat we tegenkwamen dat de tenant admin het subscription-verzoek had afgetekend alsof het een normale user OAuth flow betrof. We splitsten het read path (delegated, user-context, gescoped op de aanvragende paralegal) uiteindelijk van het subscription-pad (Application, met een Exchange application access policy die de app aan één mail-enabled security group bindt). Die ene splitsing schrapte zes van de eenentwintig items hierboven. Hetzelfde patroon komt terug bij elke AI-agent die we bovenop een gedeelde inbox bouwen.
Het kleinste wat je vandaag kunt doen: open je subscription creation call, decodeer het bearer token, en kijk naar de claims. Bevat het token een roles claim met de Application permission die jouw resource path nodig heeft, dan zit je goed. Bevat het in plaats daarvan een scp claim, dan heb je een delegated token, en liegt de 202 die je zo gaat ontvangen tegen je.
Kern
Shared-mailbox webhook subscriptions vereisen Application permissions. Delegated permissions geven 202 Accepted terug en laten daarna stilletjes elke notificatie vallen.
FAQ
Waarom geeft mijn Microsoft Graph subscription een 202 terug maar levert hij nooit notificaties?
Meestal omdat je delegated permissions hebt gebruikt op een shared-mailbox subscription. Graph vereist Application permissions voor shared-mailbox change notifications, mét admin consent.
Hoe lang kan een Outlook message subscription in leven blijven?
4230 minuten, nét onder de 2,94 dagen. Vernieuw vóór de expiry. Sterft de subscription, dan moet je 'm opnieuw opbouwen en de events tijdens de gap reconciliëren.
Moet ik de Graph validation token afhandelen?
Ja. Graph POST een validationToken in de query string zodra je de subscription aanmaakt. Je hebt tien seconden om die terug te echoën als text/plain, anders wordt de subscription stilletjes afgewezen.
Wat is het verschil tussen notificationUrl en lifecycleNotificationUrl?
notificationUrl ontvangt change events. lifecycleNotificationUrl ontvangt reauthorizationRequired, missed, en subscriptionRemoved events. Registreer ze allebei, anders mis je reauth-signalen.
Kan één subscription zowel messages als folder events afdekken?
Nee. Je hebt aparte subscriptions nodig voor de messages resource en de mailFolders resource. Een messages subscription vuurt niet wanneer er een folder wordt aangemaakt, hernoemd, of verplaatst.