Integrations
Microsoft Graph: 23 stille 200 OK-fouten, gerangschikt
Dag drie bij het koppelen van een ops-agent aan een Antwerps accountantskantoor van 52 mensen: Outlook gaf 200 OK terug en stuurde niets. De Graph SDK was het eens. De mailbox niet.

Dag drie bij het koppelen van een ops-agent aan de Outlook- en Teams-omgeving van een Antwerps accountantskantoor met 52 mensen. De pilottest was simpel. Een getagde klantmail doorsturen naar de verantwoordelijke partner, een samenvatting in een Teams-kanaal plaatsen, het tijdstip wegschrijven naar een SharePoint-lijst. De Graph SDK gaf 200 terug over de hele lijn. Niets kwam aan. De partner zat op kantoor, ververste Outlook en zei: "nog steeds niets." We bekeken de trace. Geen errors, geen retries, gewoon een schone, liegende 200 OK.
Dat is de ergste klasse bug in het hele Microsoft Graph-ecosysteem. Niet de 401's. Niet de rate limits. De 200's die het niet zijn.
We hebben elke stille fout opgeschreven die we tijdens de zes weken bouw tegenkwamen. Drieëntwintig stuks, gesorteerd op hoe vaak de officiële SDK succes meldt terwijl de daadwerkelijke operatie niets doet. Bouw je iets op Microsoft Graph dat verder gaat dan een demo, lees dan voorbij tier één voordat je live gaat.
Tier één: 200 OK, niets gebeurd
Dit zijn de ergste. De SDK-call geeft succes terug. Het audit log laat zien dat de call is doorgekomen. De mailbox, het kanaal of de agenda toont geen verandering.
1. sendMail naar een soft-deleted shared mailbox. De mailbox was offboarded maar niet definitief verwijderd. POST /users/{id}/sendMail geeft 202 Accepted terug. Bericht verdwijnt. Outlook-trace logt het als geleverd. Controleer de staat van de ontvanger met /users/{id}?$select=accountEnabled,assignedLicenses voordat je iets stuurt wat ertoe doet.
2. $batch met deelfouten. Je stuurt twaalf subrequests in één $batch. Drie ervan geven 403. De buitenste call geeft 200. De Graph SDK voor .NET pre-5.0 toont standaard alleen de buitenste status. Je moet zelf door responses[] lopen en elke status apart controleren.
3. chatMessage met gestripte HTML. Plaats een chatbericht met <style> of <script> tags. Graph strips ze stilletjes en geeft 201 Created terug. Stuurt je agent een style-blok mee om een status-badge te kleuren, dan wordt het bericht zonder badge gepost en ben je een middag bezig met RSC-rechten controleren.
4. Agenda-afspraak met één foute deelnemer. Maak een afspraak met vijf deelnemers. Eén heeft een ongeldig SMTP-adres. De afspraak wordt aangemaakt met vier deelnemers, geen error, geen waarschuwing. De partner die in de meeting hoorde te zitten, krijgt de uitnodiging nooit.
5. updateMailFolder met een null-cast. PATCH /me/mailFolders/{id} met {"displayName": null} geeft 200 terug. De mapnaam blijft hetzelfde. Vertrouw je op de response, dan denk je dat je hem hernoemd hebt.
6. Sites.Selected zonder per-site grant. Je app heeft Sites.Selected. Je hebt de per-site permission grant niet gedraaid. Een lijst lezen geeft 200 terug met een lege pagina. Niet 403. Leeg. Daar zijn we een ochtend op kwijt geweest. De Sites.Selected-documentatie van Microsoft verstopt de grant-stap onder een kop op derde niveau.
7. teamsAppInstallation in een dichtgezette tenant. Conditional Access blokkeert app-installaties vanaf niet-bedrijfs-IP's. De Graph-call vanuit je function app geeft 200 terug. Geen app geïnstalleerd. Check /teams/{id}/installedApps na elke install en reconcileer.
Tier twee: in dev werkte het, in productie zei het nee
Je dev-tenant heeft geen CA-policies, geen token protection, geen DLP. Productie heeft alle drie. Deze vier komen alleen in echte tenants boven water.
8. Application permission afgeschaald door CAE. Continuous Access Evaluation trekt je token midden in een sessie in. De SDK ververst automatisch. Het nieuwe token heeft minder scopes, omdat een admin tien minuten geleden het beleid heeft aangescherpt. Calls geven nu stilletjes ingeperkte resultaten terug.
9. ChannelMessage.Send versus resource-specific consent. Application permission ChannelMessage.Send bestaat. Hij geeft je niet wat je denkt. Je hebt RSC op team-niveau nodig voor de daadwerkelijke kanalen. Zonder RSC geeft de call 403. Met gedeeltelijke RSC accepteren sommige teams en geven andere 403. De SDK reconcileert niet over teams heen.
10. Multi-geo tenant, verkeerde endpoint. Tenant is multi-geo. Mailbox staat in EU North. Je app raakt graph.microsoft.com. Sommige queries routeren correct. Andere geven lege arrays terug. De fix is de X-Ms-Routing-Hint header round-trip respecteren. De meeste SDK's doen dat niet.
11. B2B-gastuitnodiging voor een meeting. Een B2B-gast uitnodigen voor een Teams-meeting vereist dat de gast eerst in de directory staat. De SDK maakt de gast niet aan. De uitnodiging wordt geplaatst. De gast ziet nooit een join-link.
Tier drie: throttling-theater
Microsoft Graph heeft minimaal vier verschillende throttling-regimes (per app, per tenant, per mailbox, per resource). Ze geven allemaal anders 429 terug.
12. 429 zonder Retry-After. Mailbox concurrency-limiet. 429 teruggegeven. Header mist. SDK retryt direct. Je krijgt dertig minuten rate-ban. Default naar exponential backoff met jitter zodra de header ontbreekt.
13. Delta token verloopt stilletjes. Delta queries op /me/messages geven je een @odata.deltaLink. Na 30 dagen (mailbox) of 7 dagen (chat) verloopt hij. De volgende request geeft 200 terug met een lege pagina en een nieuwe link, geen 410. Je denkt dat er niets is veranderd. Alles is veranderd.
14. $top capped op 999. Vraag $top=5000 aan. Graph geeft maximaal 999 items terug, zonder error. Paginatie laat de rest stilletjes weg tot je @odata.nextLink volgt.
15. ConsistencyLevel stilletjes vereist. Draai $count of $search zonder ConsistencyLevel: eventual in de header. Je krijgt 200 OK met een leeg value[]. Voeg de header toe en dezelfde query geeft 4.800 resultaten. Microsoft documenteert dit, maar de SDK forceert het niet.
Tier vier: webhooks en subscriptions
De helft van de bugs die we tijdens deze build tegen onszelf opgaven, ging over subscriptions. De webhook-lifecycle van Microsoft is meedogenloos en de SDK beschermt je niet.
Microsoft Graph chat-subscriptions verlopen na 60 minuten. Ja, zestig. Faalt je renewal-job één keer, dan mis je elk chatbericht tot de volgende handmatige reset.
16. Subscription-vervaltijden verschillen per resource. Mailbox messages: max 4.230 minuten. Chat messages: 60 minuten. Encrypted chat met payload: 4.230 minuten. Drive items: 41.760 minuten. De SDK accepteert elke expiration die je opgeeft, en Graph capt hem dan stilletjes. Vraag je 4.230 minuten op een chat-subscription, dan krijg je 60 en weet je het niet.
17. ClientState niet gevalideerd bij renewal. Je roteert clientState per kwartaal voor hash-based webhook-validatie. Je vernieuwt de subscription zonder hem bij te werken. De vernieuwing slaagt. De webhook stuurt nog steeds de oude state. Je verificatie faalt bij elke notificatie.
18. Validation token moet binnen 10 seconden terugkomen. Maak je een subscription aan, dan POST Graph een validatie-request naar je endpoint. Je moet het token in plain text echoën met status 200 binnen 10 seconden. Is je serverless cold start 12 seconden, dan faalt je subscription stilletjes bij aanmaken. De SDK verpakt de validatie-fout op een manier die op de call-site als succes leest.
19. Lifecycle notifications zijn opt-in. Zonder lifecycleNotificationUrl krijg je geen waarschuwing als een subscription op het punt staat te sneuvelen door een tenant policy-wijziging. Je komt er pas uren later achter, wanneer de notificaties stoppen. Zet hem altijd.
Tier vijf: leugens over de data-shape
De laatste groep gaat over de semantiek van velden. Deze bijten één keer en je vergeet ze nooit meer.
20. ImmutableId-mode bij verplaatsingen tussen mailboxen. Een item verplaatsen van mailbox A naar mailbox B met delegated permissions geeft 200 terug met een nieuwe ID. Zonder Prefer: IdType="ImmutableId" roteert die nieuwe ID bij elke read en is je referentietabel tegen lunchtijd waardeloos.
21. singleValueExtendedProperty wordt afgekapt. Custom properties boven 255 tekens worden stilletjes afgekapt. Geen waarschuwing. We liepen hier tegenaan toen we een JSON-blob met agent-metadata op een bericht opsloegen. Na de cut is de JSON invalide en crasht de agent bij het lezen.
22. Categorieën zijn case-sensitive, kleuren niet. Je zet de categorie "Invoice" op een bericht. Outlook toont hem als een nieuwe categorie, niet als degene waar jouw kleurregel op rekent, omdat iemand anders "invoice" in kleine letters eerder heeft aangemaakt. De Graph-response toont jouw versie. De Outlook-UI toont de canonieke. Geen error.
23. Timezone valt terug op UTC. Maak een afspraak zonder Prefer: outlook.timezone="Europe/Brussels". Graph slaat hem op in UTC. De Outlook-client toont hem correct, omdat de client omrekent. De Teams meeting-link, de iCal-export en de room calendar tonen allemaal UTC. Jouw klantmeeting van 09:00 staat om 11:00 op het scherm van de vergaderruimte.
Wat we uiteindelijk deden
Na twee weken "de SDK zei dat het werkte" hebben we een dunne wrapper geschreven die vier dingen doet bij elke Graph-call. Elegant is hij niet. Hij heeft elke stille fout hierboven minstens één keer in productie gevangen.
async function graph<T>(req: GraphRequest): Promise<T> {
const res = await client.api(req.path)
.header('ConsistencyLevel', 'eventual')
.header('Prefer', 'IdType="ImmutableId", outlook.timezone="Europe/Brussels"')
[req.method.toLowerCase()](req.body);
// 1. If it is a $batch, walk every sub-response
if (req.path === '/$batch' && res.responses) {
const failures = res.responses.filter(r => r.status >= 400);
if (failures.length) throw new BatchPartialError(failures);
}
// 2. If it is a write that matters, post-read to verify
if (req.verifyShape) {
await sleep(req.verifyDelayMs ?? 1500);
const check = await client.api(req.verifyShape.path).get();
if (!req.verifyShape.predicate(check)) {
throw new SilentFailureError(req.path, check);
}
}
// 3. Persist delta tokens with an explicit expiry stamp
if (res['@odata.deltaLink']) {
await store.saveDelta(req.cursorKey, {
link: res['@odata.deltaLink'],
expires: addDays(new Date(),
req.path.includes('/chats/') ? 7 : 28),
});
}
// 4. If we expected a subscription, read its real expiration back
if (res.subscriptionId && res.expirationDateTime) {
const real = await client.api(`/subscriptions/${res.subscriptionId}`).get();
if (real.expirationDateTime !== res.expirationDateTime) {
log.warn('subscription capped', { asked: res.expirationDateTime, got: real.expirationDateTime });
}
}
return res;
}
De post-read-verificatie kost mail- en agenda-operaties anderhalve seconde extra. Daar betaalden we graag voor. Stille fouten kostten het kantoor tijd die de agent juist hoorde te besparen.
Vertrouw de read, niet de write. Elke Microsoft Graph-mutatie die ertoe doet, hoort gevolgd te worden door een verificatie-read tegen de daadwerkelijke data-shape, niet tegen de response-envelope.
De Antwerpse postmortem
Na zes weken handelde de agent 340 klantmails per dag af, postte hij rond de 60 Teams-samenvattingen en logde hij elke actie naar een SharePoint-lijst. We hebben de wrapper om de drieëntwintig valkuilen hierboven heen herbouwd, plus een paar kleinere die we onderweg vonden. Stille fouten zakten tot onder één per duizend operaties. Wat resteert is voornamelijk tenant-side throttling die we als waarschuwing oppervlakken, niet als error.
Toen we de Teams- en Outlook-ops-agent bouwden voor het Antwerpse accountantskantoor, bleef het terugkerende probleem dat de officiële SDK van Microsoft de HTTP-envelope als waarheid behandelt. We hebben het opgelost met een wrapper die de werkelijke mailbox- en kanaalstaat opnieuw leest, plus een kleine CI-test die elke nacht alle valkuilen hierboven tegen een sandbox-tenant draait. Scope je een vergelijkbare build, dan is de goedkope audit van vijf minuten: grep door je codebase op .api( calls zonder follow-up read. Dat is je stille-fout-oppervlak. Onze aantekeningen over het bouwen van AI-agents tegen Graph zitten in die wrapper.
Kern
Vertrouw de read, niet de write. Elke Microsoft Graph-mutatie die ertoe doet, hoort gevolgd te worden door een verificatie-read tegen de daadwerkelijke data-shape.
FAQ
Waarom geeft Microsoft Graph 200 terug als de operatie is mislukt?
De HTTP-envelope houdt acceptatie van het request bij, niet de uitkomst stroomafwaarts. sendMail geeft 202 terug zodra het bericht in de queue staat, ook als de mailbox al offboarded is en het bericht voor levering wordt gedropt.
Is de Microsoft Graph .NET SDK beter dan ruwe HTTP?
Hetzelfde wire format met type-wrappers. Hij erft elke silent-200-valkuil die hier staat. De winst is type safety, geen gedragscorrectheid. Behandel zijn responses met dezelfde scepsis als een ruwe fetch.
Hoe lang kan een Graph webhook-subscription leven?
Hangt af van de resource. Chat messages 60 minuten, mailbox messages 4.230 minuten, drive items 41.760 minuten. Graph capt te grote requests stilletjes, dus de waarde die je terugleest kan lager zijn dan wat je opgaf.
Toont $batch deelfouten?
Ja, maar alleen binnen de responses array, niet in de buitenste status. De batch-call geeft 200 terug ook als elk subrequest is mislukt. Je moet door elke entry itereren en zijn status apart controleren.