Integrations
Microsoft Graph API gotchas: 23 silent failures, ranked
Day three of wiring an ops agent into a 52-seat Antwerp accountancy: Outlook said 200 OK and sent nothing. The Graph SDK agreed. The mailbox didn't.

Day three of wiring an ops agent into the Outlook and Teams setup of a 52-person accountancy in Antwerp. The pilot test was simple. Forward a tagged client email to the partner-in-charge, drop a summary line into a Teams channel, log the timestamp to a SharePoint list. The Graph SDK returned 200 across the board. Nothing arrived. The partner was in the office, refreshing Outlook, telling us "still nothing." We watched the trace. No errors, no retries, just a clean, lying 200 OK.
That is the worst class of bug in the entire Microsoft Graph ecosystem. Not the 401s. Not the rate limits. The 200s that aren't.
We wrote down every silent failure we hit over the six-week build. Twenty-three of them, ordered by how often the official SDK reports success while the actual operation does nothing. If you are building anything against Microsoft Graph beyond a demo, read past tier one before you ship.
Tier one: 200 OK, nothing happened
These are the worst. The SDK call returns successfully. The audit log shows the call went through. The mailbox, channel, or calendar shows no change.
1. sendMail to a soft-deleted shared mailbox. The mailbox was offboarded but not purged. POST /users/{id}/sendMail returns 202 Accepted. Message vanishes. Outlook trace logs flag it as delivered. Check the recipient state with /users/{id}?$select=accountEnabled,assignedLicenses before sending anything that matters.
2. $batch with partial failures. You send twelve sub-requests in one $batch. Three of them 403. The outer call returns 200. The Graph SDK for .NET pre-5.0 only surfaces the outer status by default. You have to walk responses[] and check every status individually.
3. chatMessage with stripped HTML. Post a chat message containing <style> or <script> tags. Graph strips them silently and returns 201 Created. If your agent embeds a style block to color a status badge, the message posts without the badge and you spend an afternoon checking RSC permissions.
4. Calendar event with one bad attendee. Create an event with five attendees. One has a malformed SMTP. The event is created with four attendees, no error, no warning. The partner who was meant to be in the meeting never gets the invite.
5. updateMailFolder with a null cast. PATCH /me/mailFolders/{id} with {"displayName": null} returns 200. The folder name is unchanged. If you trusted the response, you think you renamed it.
6. Sites.Selected without per-site grant. Your app has Sites.Selected. You have not run the per-site permission grant. Reading a list returns 200 with an empty page. Not 403. Empty. We lost a morning to this. Microsoft's Sites.Selected docs bury the grant step under a third-level heading.
7. teamsAppInstallation in a frozen tenant. Conditional Access blocks app installs from non-corporate IPs. The Graph call from your function app returns 200. No app installed. Check /teams/{id}/installedApps after every install and reconcile.
Tier two: it worked in dev, then prod said no
Your dev tenant has no CA policies, no token protection, no DLP. Production has all three. These four only show up in real tenants.
8. Application permission downgraded by CAE. Continuous Access Evaluation revokes your token mid-session. The SDK auto-refreshes. The new token has fewer scopes because an admin tightened policy ten minutes ago. Calls now silently return scoped-down results.
9. ChannelMessage.Send vs resource-specific consent. Application permission ChannelMessage.Send exists. It does not grant what you think. You need RSC at the team level for the actual channels. Without RSC, the call returns 403. With partial RSC, some teams accept and others 403. The SDK does not reconcile across teams.
10. Multi-geo tenant, wrong endpoint. Tenant is multi-geo. Mailbox lives in EU North. Your app hits graph.microsoft.com. Some queries route correctly. Some return empty arrays. The fix is to honor the X-Ms-Routing-Hint header round-trip. Most SDKs do not.
11. B2B guest meeting invite. Inviting a B2B guest to a Teams meeting requires the guest to exist in the directory first. The SDK does not create the guest. The invite posts. The guest never sees a join link.
Tier three: throttling theater
Microsoft Graph has at least four distinct throttling regimes (per-app, per-tenant, per-mailbox, per-resource). They all return 429 differently.
12. 429 without Retry-After. Mailbox concurrency limit. 429 returned. Header missing. SDK retries immediately. You get rate-banned for thirty minutes. Default to exponential backoff with jitter when the header is absent.
13. Delta token silent expiry. Delta queries on /me/messages give you a @odata.deltaLink. After 30 days (mailbox) or 7 days (chat), it expires. The next request returns 200 with an empty page and a new link, not a 410. You think nothing changed. Everything changed.
14. $top capped at 999. Ask for $top=5000. Graph returns up to 999 items, no error. Pagination silently omits the rest until you walk @odata.nextLink.
15. ConsistencyLevel silently required. Run $count or $search without ConsistencyLevel: eventual in the header. You get 200 OK with an empty value[]. Add the header and the same query returns 4,800 results. Microsoft documents this, but the SDK does not enforce it.
Tier four: webhooks and subscriptions
Half the bugs we filed against ourselves on this build were about subscriptions. Microsoft's webhook lifecycle is unforgiving and the SDK does not protect you.
Microsoft Graph chat subscriptions expire after 60 minutes. Yes, sixty. If your renewal job fails once, you miss every chat message until the next manual reset.
16. Subscription expiration limits vary by resource. Mailbox messages: max 4,230 minutes. Chat messages: 60 minutes. Encrypted chat with payload: 4,230 minutes. Drive items: 41,760 minutes. The SDK accepts whatever expiration you set, then Graph silently caps it. If you ask for 4,230 minutes on a chat subscription, you get 60 and you do not know it.
17. ClientState not validated on renewal. You rotate clientState quarterly for hash-based webhook validation. You renew the subscription without updating it. The renewal succeeds. The webhook still sends the old state. Your verification fails on every notification.
18. Validation token must return in 10 seconds. When you create a subscription, Graph POSTs a validation request to your endpoint. You must echo the token back in plain text with a 200 status within 10 seconds. If your serverless cold start is 12 seconds, your subscription quietly fails to create. The SDK wraps the validation error in a way that reads like success at the call site.
19. Lifecycle notifications are opt-in. Without lifecycleNotificationUrl, you do not get warned when a subscription is about to die from a tenant policy change. You only find out hours later when notifications stop. Always set it.
Tier five: data shape lies
The last group is about field semantics. These bite once and you remember forever.
20. ImmutableId mode for cross-mailbox moves. Moving an item from mailbox A to mailbox B with delegated permissions returns 200 with a new ID. Without Prefer: IdType="ImmutableId", the new ID rotates on every read and your reference table is junk by lunch.
21. singleValueExtendedProperty truncation. Custom properties over 255 characters get silently truncated. No warning. We hit this storing a JSON blob of agent metadata on a message. After the cut, the JSON is invalid and the agent crashes on read.
22. Categories are case-sensitive, colors are not. You set the category "Invoice" on a message. Outlook shows it as a new category, not the one your color rule expects, because someone else created "invoice" lowercase first. The Graph response shows your version. The Outlook UI shows the canonical one. No error.
23. Timezone defaults to UTC. Create an event without specifying Prefer: outlook.timezone="Europe/Brussels". Graph stores it in UTC. The Outlook client displays it correctly because the client converts. The Teams meeting link, the iCal export, and the room calendar all show UTC. Your 09:00 client meeting is on the room screen at 11:00.
What we actually did
After two weeks of "the SDK said it worked," we wrote a thin wrapper that does four things on every Graph call. It is not elegant. It has caught every silent failure above in production at least once.
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;
}
The post-read verification adds 1.5 seconds to mail and calendar operations. We were happy to pay it. Silent failures were costing the firm time the agent was meant to save.
Trust the read, not the write. Every Microsoft Graph mutation that matters should be followed by a verification read against the actual data shape, not the response envelope.
The Antwerp postmortem
Six weeks in, the agent was handling 340 client emails a day, posting around 60 Teams summaries, and logging every action to a SharePoint list. We rebuilt the wrapper around the twenty-three gotchas above and a few smaller ones we found on the way. Silent failures dropped to under one per thousand operations. The remaining ones are mostly tenant-side throttling we surface as warnings, not errors.
When we built the Teams and Outlook ops agent for the Antwerp accountancy, the thing we kept running into was that Microsoft's official SDK treats the HTTP envelope as truth. We solved it with a wrapper that re-reads the actual mailbox and channel state, and a small CI test that runs every gotcha above against a sandbox tenant nightly. If you are scoping a similar build, the cheap five-minute audit is to grep your codebase for .api( calls without a follow-up read. That is your silent-failure surface. Our notes on building AI agents against Graph live in that wrapper.
Key takeaway
Trust the read, not the write. Every Microsoft Graph mutation that matters should be followed by a verification read against the actual data shape.
FAQ
Why does Microsoft Graph return 200 when the operation failed?
The HTTP envelope tracks request acceptance, not downstream outcome. sendMail returns 202 when the message is queued, even if the mailbox is offboarded and the message gets dropped before delivery.
Is the Microsoft Graph .NET SDK better than raw HTTP?
Same wire format with type wrappers. It inherits every silent-200 gotcha listed here. The benefit is type safety, not behavioral correctness. Treat its responses with the same skepticism as raw fetch.
How long can a Graph webhook subscription live?
Resource-dependent. Chat messages 60 minutes, mailbox messages 4,230 minutes, drive items 41,760 minutes. Graph silently caps oversized requests so the value you read back may be less than what you asked for.
Does $batch surface partial failures?
Yes, but only inside the responses array, not the outer status. The batch call returns 200 even if every sub-request failed. You must iterate every entry and check its status individually.