Integrations
Microsoft Graph-agenda: 15 stille quirks in je boekingsagent
Een uitzendbureau in Zoetermeer zet een boekingsagent live. Twee weken later komt een kandidaat op Curaçao een uur te laat opdagen. Dit zijn de vijftien quirks die het verklaarden.

Het is dinsdagavond 23:00 in Zoetermeer. Een uitzendbureau van 24 mensen sluit dag drie af van een Microsoft Graph agendaboekingsagent die interviewslots uitdeelt via Outlook. De telefoon gaat. Een kandidaat in Willemstad is een uur te laat opgedoken voor een Teams-call. De hiring manager zweert dat de slot om 14:00 lokale tijd stond. De eigen logs van de agent zeggen ook 14:00. De invite die de kandidaat ontving? 14:00 Amsterdamse tijd, geen Curaçao-conversie, geen waarschuwing ergens in de response-payload.
Dit was niet de enige. In de tien dagen erna logden we vijftien aparte quirks in Microsoft Graph, Exchange Online en Outlook 2026. Sommige droppen stilletjes Teams-links bij multi-tenant gast-invites. Sommige geven netjes een 202 Accepted terug en mangleden ondertussen de tijdzone. Geen enkele gooide een error waar enige laag van de stack iets mee zou doen.
Dit is de cheatsheet, gerangschikt op hoe vaak elke quirk ons uren debug heeft gekost. Bouw je in 2026 een boekingsagent tegen Graph, bewaar 'm.
De twee faalmodi die ertoe doen
Agenda-agents falen op twee manieren die de gebruiker opmerkt: de meetinglink ontbreekt, of de meeting staat op de verkeerde tijd. Al het andere (een gemiste deelnemer, een fout onderwerp, een verprutste herhaling) wordt door een mens opgevangen voordat het de deur uitgaat. Wegvallende Teams-links zetten de agent voor schut. Wegvallende tijdzones laten de kandidaat afhaken.
Allebei gebeuren ze met een 202 Accepted op de lijn. Geen van beide duikt op in standaardlogging. Allebei zijn ze tenant-policy-afhankelijk, wat betekent dat ze in jouw dev-tenant slagen en in de productie-tenant van de klant falen.
We sorteerden de vijftien quirks in drie tiers: stille drops van Teams-links, stille drops van tijdzones, en operationele struikeldraden die later opduiken. Elke entry noemt het symptoom, de oorzaak, en wat we veranderden.
Tier één: stille Teams-link drops bij multi-tenant invites
Dit zijn degene die de rollout drie dagen kostten. Allemaal geven ze keurig een 202 terug met het event-id en geen Teams-link in de response.
1. Organizer policy zonder anonieme join
De boekingsmailbox staat in tenant A. De gast zit in tenant B. Als de Teams meeting policy van de organizer anonieme join blokkeert voor cross-tenant deelnemers die geen B2B-uitnodiging hebben gehad, geeft Graph het event terug zonder onlineMeeting.joinUrl. Geen error. Geen waarschuwing. Alleen een leeg onlineMeeting-object.
Fix: lees onlineMeeting uit na de create. Is joinUrl null bij isOnlineMeeting: true, PATCH dan isOnlineMeeting uit, zet een fallback-regel in de bevestiging, en alarmeer ops.
2. onlineMeetingProvider valt terug op unknown
POST zonder onlineMeetingProvider en Graph kiest op basis van tenant policy. Op tenants met zowel Teams als het Skype-legacy object nog gekoppeld aan de mailbox, landt 'ie soms op de niet-ingestelde waarde, en geeft de response je een meeting terug zonder provider eraan vast. Stuur altijd expliciet "onlineMeetingProvider": "teamsForBusiness" mee.
3. Delegated versus application permission mismatch
Calendars.ReadWrite op application permissions dekt OnlineMeetings.ReadWrite.All niet af. Draait je agent application-permissioned (de meeste doen dat), dan wordt het event aangemaakt, maar faalt de embedded Teams-meeting stilletjes in het provisioning-proces en blijft je joinUrl leeg. Voeg OnlineMeetings.ReadWrite.All toe en geef admin consent in de tenant.
4. Resource mailbox met AutoAccept
Is de boekingsmailbox een resource-agenda (gebruikelijk bij agencies, de hiring manager is eigenaar), dan stript AutomateProcessing = AutoAccept isOnlineMeeting: true bij het accepteren. De mailbox accepteert de slot, geeft 202 terug, en de meeting op de resource-agenda heeft geen Teams-link. Gebruik een user-mailbox voor de agent, of zet automatische verwerking uit op de resource en draai de acceptlogica binnen de agent.
5. Multi-tenant gast uitgenodigd via UPN in plaats van SMTP
Adresseer je een B2B-gast via diens home-tenant UPN (alice@partner.onmicrosoft.com) in plaats van de SMTP (alice@partner.com), dan gaat de invite de deur uit en wordt het event geboekt, maar wordt de Teams join-URL niet gerouteerd naar de joined identity van de gast. Ze krijgen de agenda-item. De link geeft 401 als ze klikken. Resolve altijd naar SMTP voordat je POST.
Een lege onlineMeeting.joinUrl na een 202 is nooit een tijdelijke storing. Het betekent dat de meeting nooit is geprovisioneerd. Probeer de create niet opnieuw. Repareer het event met een PATCH of stuur de link out-of-band.
Tier twee: 202 Accepted met een stilletjes verloren tijdzone
Dit is het Curaçao-verhaal. Vijf quirks, allemaal zien ze er prima uit in de response.
6. start.timeZone als Windows-naam op een IANA-mode mailbox
start.timeZone: "W. Europe Standard Time" werkt op de meeste mailboxes. Op een mailbox waarvan de eigenaar de regionale voorkeuren op IANA-mode heeft staan (zeldzaam maar reëel, en de default in sommige tenants die na 2025 zijn geprovisioneerd), converteert Graph naar UTC en dropt de benoemde zone uit het opgeslagen event. De invite arriveert zonder tijdzonehint. Outlook bij de kandidaat rendert 'm in lokale tijd, wat alleen klopt als die lokale tijd dezelfde is.
Gebruik "timeZone": "Europe/Amsterdam". IANA-namen worden in alle modes geaccepteerd.
7. Curaçao is America/Curacao, niet Atlantic Standard Time
Curaçao gebruikt het hele jaar door Atlantic Standard Time, geen zomertijd. De IANA-naam is America/Curacao. Het Windows-label "Atlantic Standard Time" mapt naar Halifax, dat wel DST observeert. Stuur een meeting naar een Curaçao-kandidaat via "Atlantic Standard Time" tussen maart en november en je zit een uur ernaast. De IANA time zone database is de canonieke lijst. Gebruik de IANA-naam op elke booking die een Caribische of non-DST locatie raakt.
8. Prefer: outlook.timezone wordt op POST genegeerd
De header Prefer: outlook.timezone="Europe/Amsterdam" werkt op GET. Op POST en PATCH negeert Graph 'm. Het opgeslagen event gebruikt wat er in de body staat. Vertrouw niet op de Prefer-header om een ontbrekende timeZone in de request body af te vangen. Valideer in de request-laag zelf.
9. findMeetingTimes geeft suggesties terug in UTC
/me/findMeetingTimes geeft voorgestelde slots terug in UTC, ongeacht welke tijdzone-voorkeur je meegeeft. Pipet je agent deze direct door naar een POST event zonder herprojectie naar de zone van de deelnemer, dan zit elke voorgestelde slot er met de lokale UTC-delta naast. Converteer voor weergave, converteer opnieuw voor write.
10. 202 Accepted op /events betekent niet dat het event canoniek is
Graph geeft 202 terug bij eventcreatie zodra de write in de queue staat tegen de mailbox. Het event-id is definitief. De opgeslagen tijdzone is dat niet, tot de queue is uitgelopen. We hebben gezien dat de gequeuede write een default mailbox-tijdzone over de request body heen legt binnen 100ms op een drukke tenant. Lees na write, voordat je de bevestigingsmail stuurt. De canonieke richtlijn van Microsoft staat in de dateTimeTimeZone reference.
// after create, before sending the confirmation:
const stored = await graph.api(`/users/${mbx}/events/${id}`).get()
if (stored.start.timeZone !== req.start.timeZone) {
await alertOps('tz_drift', { id, sent: req.start.timeZone, stored: stored.start.timeZone })
await graph.api(`/users/${mbx}/events/${id}`).patch({ start: req.start, end: req.end })
}
if (req.isOnlineMeeting && !stored.onlineMeeting?.joinUrl) {
await alertOps('teams_missing', { id })
// fall back to a manually-attached link, do not retry the create
}
Behandel elke 202 als belofte, niet als bonnetje. Lees het event terug, vergelijk de opgeslagen tijdzone en joinUrl met wat je verstuurde, en alarmeer op mismatch voordat je de kandidaat de bevestiging stuurt.
Tier drie: operationele struikeldraden
Deze breken niet de eerste invite. Ze breken de honderdste, of de auditlog zes weken later.
11. transactionId is optioneel maar verplicht
Zonder transactionId in de POST body maakt een netwerk-retry een duplicaat-event aan. De 202 ziet er hetzelfde uit. Nu heeft de kandidaat twee invites en de agent twee ids. Genereer een UUID per logische boekingspoging en zet 'm op elke retry mee.
12. Throttling op 10.000 requests per 10 minuten per app per mailbox
Microsoft documenteert de limieten in de Graph throttling reference. Een boekingsagent in batchmodus (honderd kandidaten omboeken nadat de hiring manager schuift) loopt hier tegenaan bij een tenant-burst. De 429 draagt Retry-After mee. Respecteer 'm. Niet pollen, niet ophopen.
13. Subscription levensduur stopt bij 4230 minuten voor /events
Subscribet je agent op /users/{id}/events om te reageren op kandidaat-omboekingen, dan verloopt de subscription na 70,5 uur. Vernieuw op een 24-uurs cron en mis je één tick, dan krijg je geen webhooks meer. Vernieuw op intervallen van 60 uur met een watchdog die een nieuwe subscription opent zodra een renewal een 410 teruggeeft.
14. changeKey versus etag drift op series occurrences
PATCH een occurrence van een herhalende meeting met een verouderde @odata.etag en je krijgt 412 Precondition Failed. Gebruik in plaats daarvan changeKey en je slaagt soms stilletjes tegen de series master, waardoor je elke toekomstige occurrence verprutst. Pin etag, niet changeKey, bij elke modify tegen een herhalend event.
15. Modern Auth tokens met verkeerde scope
Basic Auth op EWS, IMAP en POP heeft Microsoft in 2025 uitgefaseerd. De meeste teams weten dat. De val in 2026 zit in de OAuth-scopes op de vervanger: een token uitgegeven voor Mail.Read dekt /calendar/events niet, ook al staat dezelfde mailbox achter allebei. Scope elk token op de kleinste set die werkt, audit in de tokenrequest-laag, en hergebruik nooit een mailtoken in een agenda-codepad.
De vijf-minuten audit voor je eigen agent
Heb je al een agendaboekingsagent in productie staan, doe dan deze vijf dingen voor de standup van morgen:
- Grep je codebase op
"timeZone": "W. Europe. Vervang elke Windows TZ-naam door het IANA-equivalent. - Voeg een read-after-write stap toe in je event-create pad. Vergelijk
start.timeZoneenonlineMeeting.joinUrlmet wat je verstuurde. - Zet
transactionIdop elke POST. Random UUID, opgeslagen naast je boekingsrecord. - Bevestig dat de app-registratie
OnlineMeetings.ReadWrite.Allheeft, niet alleenCalendars.ReadWrite. - Draai
Get-CalendarProcessingop de boekingsmailbox. StaatAutomateProcessingopAutoAccept, beslis dan of je naar een user-mailbox overstapt.
Toen we de boekingsagent bouwden voor het uitzendbureau in Zoetermeer, kostte de Curaçao-tijdzonebug ons een hele dag om te traceren, omdat de response-payload er schoon uitzag. We losten het op door elke 202 als voorlopig te behandelen en een harde read-after-write toe te voegen voordat de bevestigingsmail naar de kandidaat ging. Dat patroon is nu standaard op elke AI-agent die we tegen Graph uitrollen.
Het kleinste wat je vandaag kan doen: open één event dat je agent vorige week heeft aangemaakt, haal 'm terug van Graph, en vergelijk de opgeslagen start.timeZone met wat je agent op moment van create logde. Verschillen ze, dan heb je quirk nummer tien en zit je één alertregel verwijderd van nooit meer verrast worden.
Kern
Elke 202 van Graph op een event-create is een belofte, geen bonnetje. Lees het event terug, controleer de tijdzone en joinUrl, en alarmeer op mismatch voordat je de bevestiging stuurt.
FAQ
Geeft Microsoft Graph ooit een error als de Teams-link niet wordt geprovisioneerd?
Nee. Een 202 Accepted met een lege onlineMeeting.joinUrl is het enige signaal. Lees het event terug na create en behandel een lege joinUrl bij isOnlineMeeting:true als harde fout, niet als tijdelijke storing.
Welke IANA-tijdzone gebruik ik voor Curaçao?
America/Curacao. Het hele jaar door Atlantic Standard Time, geen DST. Het Windows-label Atlantic Standard Time mapt naar Halifax, dat wel DST observeert, dus levert het in de Europese zomermaanden foute uitkomsten op.
Hoe maak ik event-creatie idempotent tegen netwerk-retries?
Zet transactionId op een stabiele UUID per logische booking. Graph geeft op een retry het bestaande event terug in plaats van een duplicaat aan te maken. Sla de UUID op naast je boekingsrecord zodat retries 'm hergebruiken.
Heb ik OnlineMeetings.ReadWrite.All nodig als ik al Calendars.ReadWrite heb?
Ja, op application permissions. Zonder die scope faalt Teams meeting-provisioning stilletjes en komt joinUrl leeg terug, ook al stond isOnlineMeeting op true. Voeg 'm toe en geef admin consent in de tenant.
Hoe vaak moet ik een /events subscription vernieuwen?
Graph zet de maximale levensduur op 4230 minuten, ongeveer 70,5 uur. Vernieuw elke 60 uur met een watchdog die een nieuwe subscription opent zodra een renewal 410 teruggeeft, zodat een gemiste cron-tick je webhooks niet stilzet.