← Blog

Integrations

Microsoft Graph calendar quirks: 15 silent failure modes

A recruitment agency in Zoetermeer wires up a booking agent. Two weeks in, candidates in Curaçao show up an hour late. These are the fifteen quirks that explained it.

Jacob Molkenboer· Founder · A Brand New Company· 9 Oct 2025· 9 min
Brass world-time clock and compass on ivory paper, manila tag with green ribbon, red wax seal on cream telegram.

It's 11pm on a Tuesday in Zoetermeer. A 24-person recruitment agency just finished day three of a Microsoft Graph calendar-booking agent that hands out interview slots over Outlook. The phone rings. A candidate in Willemstad has shown up an hour late for a Teams call. The hiring manager swears the slot was set for 14:00 local. The agent's own logs say 14:00 too. The invite the candidate received? 14:00 Amsterdam time, no Curaçao conversion, no warning anywhere in the response payload.

This wasn't the only one. Over the next ten days we logged fifteen distinct Microsoft Graph, Exchange Online, and Outlook 2026 quirks. Some silently dropped Teams links on multi-tenant guest invites. Some returned 202 Accepted and quietly mangled timezones. None of them threw an error any layer of the stack would have noticed.

This is the cheatsheet, ranked by how often each one cost us hours of debug. If you're shipping a booking agent against Graph in 2026, save it.

The two failure modes that matter

Calendar agents fail in two ways the user notices: the meeting link is missing, or the meeting is at the wrong time. Everything else (a missed attendee, a wrong subject, a botched recurrence) gets caught by a human before it goes out. Teams link drops embarrass the agent. Timezone drops make the candidate cancel.

Both happen with a 202 Accepted on the wire. Neither shows up in basic logging. Both are tenant-policy dependent, which means they pass in your dev tenant and fail in the client's production tenant.

We sorted the fifteen quirks into three tiers: silent Teams-link drops, silent timezone drops, and operational tripwires that show up later. Each entry names the symptom, the cause, and what we changed.

Tier one: silent Teams-link drops on multi-tenant invites

These are the ones that cost the rollout three days. Every one of them returns a clean 202 with the event id and no Teams link in the response.

1. Organizer policy missing anonymous-join

The booking mailbox lives in tenant A. The guest is in tenant B. If the organizer's Teams meeting policy blocks anonymous join for cross-tenant attendees who haven't been B2B-invited, Graph returns the event without onlineMeeting.joinUrl. No error. No warning. Just an empty onlineMeeting object.

Fix: read onlineMeeting after create. If joinUrl is null on isOnlineMeeting: true, PATCH isOnlineMeeting off, surface a fallback line in the confirmation, and alert ops.

2. onlineMeetingProvider defaulting to unknown

POST without onlineMeetingProvider and Graph picks from tenant policy. On tenants with both Teams and the Skype-legacy object still attached to the mailbox, it occasionally lands on the unset value, and the response gives you back a meeting with no provider attached. Always send "onlineMeetingProvider": "teamsForBusiness" explicitly.

3. Delegated vs application permission mismatch

Calendars.ReadWrite on application permissions does not cover OnlineMeetings.ReadWrite.All. If your agent runs application-permissioned (most do), the event gets created but the embedded Teams meeting silently fails to provision and your joinUrl is empty. Add OnlineMeetings.ReadWrite.All and grant admin consent in the tenant.

4. Resource mailbox with AutoAccept

When the booking mailbox is a resource calendar (common at agencies, the hiring manager owns it), AutomateProcessing = AutoAccept strips isOnlineMeeting: true on accept. The mailbox accepts the slot, returns 202, and the meeting on the resource calendar has no Teams link. Use a user mailbox for the agent, or disable automatic processing on the resource and run accept logic in-agent.

5. Multi-tenant guest invited by UPN instead of SMTP

If you address a B2B guest by their home-tenant UPN (alice@partner.onmicrosoft.com) instead of their SMTP (alice@partner.com), the invite goes out and the event posts, but the Teams join URL is not routed to the guest's joined identity. They get the calendar entry. The link 401s when they click it. Always resolve to SMTP before POST.

Warning

An empty onlineMeeting.joinUrl after a 202 is never a transient. It means the meeting was never provisioned. Don't retry the create. Repair the event with a PATCH or send the link out-of-band.

Tier two: 202 Accepted with a quietly lost timezone

This is the Curaçao story. Five quirks, all of which look fine in the response.

6. start.timeZone as a Windows name on an IANA-mode mailbox

start.timeZone: "W. Europe Standard Time" works on most mailboxes. On a mailbox whose owner set regional preferences to IANA mode (rare but real, and the default in some tenants provisioned after 2025), Graph converts to UTC and drops the named zone from the stored event. The invite arrives with no timezone hint. Outlook on the candidate's end renders it in their local time, which only works if their local is the same.

Use "timeZone": "Europe/Amsterdam". IANA names are accepted in all modes.

7. Curaçao is America/Curacao, not Atlantic Standard Time

Curaçao uses Atlantic Standard Time year-round, no DST. The IANA name is America/Curacao. The Windows-style label "Atlantic Standard Time" maps to Halifax, which does observe DST. Send a meeting to a Curaçao candidate using "Atlantic Standard Time" between March and November and you're off by an hour. The IANA time zone database is the canonical list. Use the IANA name on every booking that touches a Caribbean or non-DST locale.

8. Prefer: outlook.timezone dropped on POST

The Prefer: outlook.timezone="Europe/Amsterdam" header works on GET. On POST and PATCH, Graph ignores it. The stored event uses whatever's in the body. Don't rely on the Prefer header to backstop a missing timeZone in the request body. Validate at the request layer instead.

9. findMeetingTimes returns suggestions in UTC

/me/findMeetingTimes returns suggested slots in UTC regardless of any timezone preference you pass. If your agent pipes these straight into a POST event without re-projecting into the attendee's zone, every suggested slot is offset by the local UTC delta. Convert before display, convert again before write.

10. 202 Accepted on /events doesn't mean the event is canonical

Graph returns 202 on event creation when the write is queued against the mailbox. The event id is final. The stored timezone is not, until the queue settles. We have seen the queued write apply a default mailbox timezone over the request body inside 100ms on a busy tenant. Read after write before you send the confirmation email. Microsoft's canonical guidance is in the 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
}
Takeaway

Treat every 202 as a promise, not a receipt. Read the event back, compare the stored timezone and joinUrl to what you sent, and alert on mismatch before you send the candidate the confirm.

Tier three: operational tripwires

These don't break the first invite. They break the hundredth, or the audit log six weeks later.

11. transactionId is optional but mandatory

Without transactionId on the POST body, a network retry creates a duplicate event. The 202 looks the same. Now the candidate has two invites and the agent has two ids. Generate a UUID per logical booking attempt and set it on every retry.

12. Throttling at 10,000 requests per 10 minutes per app per mailbox

Microsoft documents the limits in the Graph throttling reference. A booking agent in batch mode (rescheduling a hundred candidates after a reschedule from the hiring manager) hits this on tenant burst. The 429 carries Retry-After. Honour it. Don't poll, don't bunch.

13. Subscription lifetime caps at 4230 minutes for /events

If your agent subscribes to /users/{id}/events to react to candidate reschedules, the subscription expires after 70.5 hours. Renew on a 24-hour cron and miss one tick, and you stop getting webhooks. Renew on 60-hour intervals with a watchdog that opens a fresh subscription if a renewal 410s.

14. changeKey vs etag drift on series occurrences

PATCH an occurrence of a recurring meeting using a stale @odata.etag and you get 412 Precondition Failed. Use changeKey instead and you sometimes succeed silently against the series master, mangling every future occurrence. Pin etag, not changeKey, on every modify against a recurring event.

15. Modern Auth tokens scoped wrong

Basic Auth on EWS, IMAP, and POP was retired by Microsoft through 2025. Most teams know. The trap in 2026 is the OAuth scopes on the replacement: a token issued for Mail.Read does not cover /calendar/events even though the same mailbox holds both. Scope every token to the smallest set that works, audit at the token request layer, and never reuse a mail token in a calendar code path.

The five-minute audit for your own agent

If you have a calendar-booking agent already in production, do these five things before standup tomorrow:

  1. Grep your codebase for "timeZone": "W. Europe. Replace every Windows TZ name with the IANA equivalent.
  2. Add a read-after-write step in your event create path. Compare start.timeZone and onlineMeeting.joinUrl against what you sent.
  3. Add transactionId to every POST. Random UUID, persisted alongside your booking record.
  4. Confirm the app registration has OnlineMeetings.ReadWrite.All granted, not just Calendars.ReadWrite.
  5. Run Get-CalendarProcessing on the booking mailbox. If AutomateProcessing is AutoAccept, decide whether to switch to a user mailbox.

When we built the booking agent for the Zoetermeer recruitment agency, the Curaçao timezone bug took us a full day to track down because the response payload looked clean. We solved it by treating every 202 as provisional and adding a hard read-after-write before sending the candidate the confirmation email. That pattern is now standard on every AI agent we ship against Graph.

The smallest thing you can do today: open one event your agent created last week, fetch it back from Graph, and compare the stored start.timeZone to what your agent logged at create time. If they differ, you have quirk number ten and you're one alert rule away from never being surprised by it again.

Key takeaway

Every Graph 202 on an event create is a promise, not a receipt. Read the event back, check the timezone and joinUrl, and alert on mismatch before you send the confirm.

FAQ

Does Microsoft Graph ever return an error when the Teams link fails to provision?

No. A 202 Accepted with an empty onlineMeeting.joinUrl is the only signal. Read the event back after create and treat empty joinUrl on isOnlineMeeting:true as a hard failure, not a transient.

What IANA timezone do I use for Curaçao?

America/Curacao. Atlantic Standard Time year-round, no DST. The Windows label Atlantic Standard Time maps to Halifax which does observe DST, so it gives wrong results in the Europe summer months.

How do I make event creation idempotent against network retries?

Set transactionId to a stable UUID per logical booking. Graph returns the existing event on retry instead of creating a duplicate. Persist the UUID alongside your booking record so retries reuse it.

Do I need OnlineMeetings.ReadWrite.All if I already have Calendars.ReadWrite?

Yes, on application permissions. Without it, Teams meeting provisioning fails silently and joinUrl comes back empty even when isOnlineMeeting was true. Add it and grant admin consent in the tenant.

How often should I renew an /events subscription?

Graph caps the lifetime at 4230 minutes, around 70.5 hours. Renew every 60 hours with a watchdog that opens a fresh subscription if a renewal returns 410, so a missed cron tick does not stop your webhooks.

ai agentsautomationintegrationsworkflowarchitecturetooling

Building something?

Start a project