Area: Microsoft 365 API
Related: PostgreSQL, graphile-worker, WebSocket, Blob and Object URLs, SharePoint Framework (SPFx)

What is it?

Microsoft Graph is one REST API for almost everything in Microsoft 365: users, groups, mail, calendars, OneDrive and SharePoint files, Teams chats, meetings and transcripts, and Microsoft Search. One base URL, one way to authenticate, one style of JSON.

GET https://graph.microsoft.com/v1.0/me
GET https://graph.microsoft.com/v1.0/me/drive/root/children
GET https://graph.microsoft.com/v1.0/me/events?$top=10
GET https://graph.microsoft.com/v1.0/sites/{siteId}/drives

Before Graph, each product had its own API (Outlook REST, SharePoint REST, Azure AD Graph, and so on). Graph is the single front door to all of them.

  • v1.0 is stable and supported for production.
  • beta has newer features and can change or break without notice. Don’t build anything important on it.

Under the hood, Graph routes your request to the real service: Exchange for mail, SharePoint for files (OneDrive for Business is SharePoint too), Entra ID for users. That’s why different parts of Graph behave a bit differently, especially when throttling.

Authentication: the part that takes longest to understand

Every request carries an access token from Microsoft Entra ID (formerly Azure AD), sent as Authorization: Bearer <token>. Getting the token right is most of the work.

App registrations

Your app is registered in Entra ID and gets a client ID. In the registration you list the permissions (scopes) it may ask for, and the redirect URLs it can sign people in to.

  • Single-tenant apps only work for your own organisation.
  • Multi-tenant apps (authority https://login.microsoftonline.com/common or /organizations) can be used by any organisation. When a new organisation’s user signs in for the first time, Entra creates a copy of your app in their tenant (an “enterprise application” or “service principal”). This is how SaaS products on Microsoft 365 work.

Delegated vs application permissions

This is the most important distinction in Graph.

DelegatedApplication
Acts asthe signed-in userthe app itself, no user
Can reachonly what that user can reacheverything in the tenant that the permission covers
ExampleFiles.ReadWrite.All, delegated: the user’s own files and anything shared with themFiles.ReadWrite.All, application: every file in every OneDrive and SharePoint site in the company
Consentoften the user can consent; some scopes need an adminalways needs an admin
Used forapps where a person is using itbackground services, daemons, migrations

The key rule for delegated access: what you can actually do = the scopes in the token ∩ what the user is allowed to do. Files.ReadWrite.All delegated doesn’t let a user read a file they can’t open in SharePoint. The “All” means “all files this user can reach”, not every file.

Application permissions are powerful and scary for a security team. Many enterprises refuse to grant them to third-party apps. Staying delegated-only makes an app far easier to approve, at the cost of never being able to do work when no user is around: no background crawls, no nightly jobs that touch files.

  • User consent. The user sees “This app wants to: read your files…” and clicks Accept.
  • Admin consent. Some scopes (and anything in tenants that have locked down user consent) need a tenant admin to approve. Admins can approve for the whole organisation at once.
  • Incremental consent. You don’t have to ask for everything at sign-in. Ask for the basics first (User.Read, file access), and request extra scopes (calendar, sites, transcripts) the first time the user uses a feature that needs them. Asking for less up front means fewer people bounce off a scary consent screen.

Microsoft also has managed consent policies: in many tenants, users can only consent to “low risk” permissions. A scope like Sites.ReadWrite.All may be blocked for user consent while Sites.Manage.All is not. Always test in a locked-down tenant, not just your own developer tenant where you’re the admin.

Tokens

  • An access token is a JWT with an audience (aud). A Graph token has Graph as its audience. A token for SharePoint’s own REST API is a different token. One token can’t serve two resources.
  • Access tokens last about an hour, sometimes up to 90 minutes. Refresh tokens get new ones without asking the user again.
  • In the browser, MSAL.js handles all of this: sign-in popups or redirects, caching, silent renewal, and asking for more scopes.
  • On a server, the on-behalf-of (OBO) flow swaps a token your API received for a Graph token for the same user. Teams SSO uses this: Teams gives your tab a token for your app, your server trades it for a Graph token.
  • Never log tokens. Never send them to the browser if they were issued to your server.

Files: drives and driveItems

OneDrive and SharePoint files are modelled the same way:

site (SharePoint)                  user
└── drive (a document library)     └── drive (their OneDrive)
    └── driveItem (folder)             └── driveItem ...
        └── driveItem (file)
  • A drive is a document library. Each SharePoint site has one or more. Each user has one OneDrive.
  • A driveItem is a file or folder. You can tell which by the file or folder facet (property) on it.
  • Each item has an id that’s stable across renames and moves within the drive. Store driveId + itemId, not paths. Paths change whenever someone renames a folder.

Addressing items

GET /drives/{driveId}/items/{itemId}
GET /drives/{driveId}/items/{itemId}/children
GET /me/drive/root:/Notes/Plan.md              ← by path (note the colons)
GET /me/drive/root:/Notes/Plan.md:/content     ← path, then a sub-resource

The root:/path: syntax switches from “ids” to “a path relative to here”. The trailing : ends the path. Paths must be URL-encoded. File names with #, % or ? break naive code.

eTag and cTag

Every driveItem has two version tags:

  • eTag changes on any change: content, name, metadata.
  • cTag changes only when the content changes.

Use cTag to answer “do I need to re-download the bytes?”. Use eTag for safe writes (next section).

Downloading

GET /drives/{driveId}/items/{itemId}/content

Graph answers with a 302 redirect to a short-lived, pre-authenticated URL on a SharePoint server. Most HTTP clients follow it automatically. The same URL is also on items as @microsoft.graph.downloadUrl. It needs no token and expires after a short while. Don’t store it, and don’t log it, because anyone with the URL can download the file until it expires.

Uploading

  • Small files: PUT /drives/{driveId}/items/{parentId}:/{name}:/content with the bytes as the body. Microsoft documents this for files up to 250 MB, but large single requests fail more often. Many apps switch to sessions far below that.
  • Large files: create an upload session (createUploadSession), then PUT the file in chunks (multiples of 320 KiB), each with a Content-Range header. A dropped connection only loses one chunk.
  • Name clashes: pass @microsoft.graph.conflictBehavior as fail, replace or rename (“Plan 1.md”).

Optimistic concurrency with If-Match

Two people (or two servers) edit the same file. Without care, the last write silently wins.

PUT /drives/{d}/items/{i}/content
If-Match: "{etag-you-read}"

If the file changed since you read it, Graph answers 412 Precondition Failed and doesn’t write. You then re-read, merge or ask the user, and try again. It’s the same idea as a version column in a database.

OData query parameters

Graph uses OData conventions:

GET /me/drive/root/children?$select=id,name,size,lastModifiedDateTime&$top=200
GET /users?$filter=startswith(displayName,'Jo')&$orderby=displayName
GET /me/drive/items/{id}?$expand=children($select=id,name)
  • $select: ask only for the fields you need. Responses get much smaller and often faster. Use it on every call.
  • $filter, $orderby: support varies a lot by endpoint. Many driveItem properties can’t be filtered.
  • $expand: include related objects in one call.
  • $top: page size. There’s a maximum per endpoint.
  • $count and some advanced $filters on directory objects (users, groups) need the header ConsistencyLevel: eventual and $count=true.

Paging

Large results come in pages. The response includes @odata.nextLink:

let url: string | undefined = "/me/drive/root/children?$top=200";
const items = [];
while (url) {
  const page = await graphGet(url);
  items.push(...page.value);
  url = page["@odata.nextLink"]; // a full URL; use it exactly as given
}

Treat nextLink as opaque. Don’t parse it or build your own. Folders with thousands of items mean many pages, and listing big folders over and over is one of the fastest ways to get throttled.

Delta queries: tracking changes without re-listing

Instead of listing every folder again to see what changed, ask for changes since last time:

GET /drives/{driveId}/root/delta
  1. The first call returns everything (paged through @odata.nextLink), and the last page has an @odata.deltaLink.
  2. Save the deltaLink.
  3. Later, call the deltaLink. You get only items that changed since then, plus a new deltaLink.
  4. Repeat.

Things to know:

  • Deleted items come back with a deleted facet. Moved or renamed items come back with their new parentReference or name.
  • The same item can appear more than once in one delta run. Keep the last one.
  • Items don’t always come with their full path. You may have to reconstruct the tree from parentReference.id.
  • 410 Gone means the delta token expired or the service needs you to start over. Do a fresh full delta (or a full resync).
  • On OneDrive for Business and SharePoint, delta works on the drive root, not on any folder you like. You get changes for the whole library and filter to the part you care about.
  • Use Prefer: deltaExcludeParent=true, $select and similar options to keep delta responses small. There are several Prefer headers for delta and for hidden items.
  • To start from “now” without downloading everything first, call delta with ?token=latest. You get a deltaLink with no items.

Change notifications (webhooks)

Graph can also POST to your server when something changes (a subscription). It needs a public HTTPS endpoint, has to answer a validation handshake, and subscriptions expire and must be renewed. For drives, the notification only says “something changed in this drive”. You still call delta to find out what. Many apps skip webhooks and simply poll delta on a timer, which is simpler and good enough when you only care about drives users are actively looking at.

Batching

POST /$batch sends up to 20 requests in one HTTP call:

{
  "requests": [
    { "id": "1", "method": "GET", "url": "/me" },
    { "id": "2", "method": "GET", "url": "/me/drive/items/abc?$select=id,name" },
    { "id": "3", "method": "GET", "url": "/me/drive/items/def", "dependsOn": ["2"] }
  ]
}
  • It saves round trips, which matters on slow networks.
  • It does not save throttling budget. Each request in the batch counts on its own, and each can individually return 429.
  • Responses can come back in any order. Match by id.
  • dependsOn forces an order when one request needs another to finish first.

Throttling: the part that bites in production

Graph and especially SharePoint protect themselves by throttling: when an app or user sends too much, requests fail with:

  • 429 Too Many Requests, usually with a Retry-After header (seconds)
  • 503 Service Unavailable, which SharePoint sometimes uses for throttling as well

SharePoint counts resource units per app per tenant and per user, over time windows. Some calls cost more than others (listing big folders, search, permission reads). A tenant’s budget grows with its licence count, but it isn’t unlimited. A busy app in a small tenant can run out.

How to behave:

  1. Honour Retry-After. Wait at least that long. Retrying sooner makes it worse and can extend the penalty.
  2. Add a little random spread on top of Retry-After. If 50 requests were throttled in the same second, they all get the same Retry-After. Without spread, they all wake up together and get throttled again.
  3. No Retry-After? Back off exponentially with jitter (1 s, 2 s, 4 s… plus randomness, with a cap).
  4. Set a total time budget. Chained retries can exceed the timeout of whatever sits in front of your server (proxies often cut requests at around 100 s), turning a throttle into a confusing gateway error. Stop retrying and report the error when the budget would run out.
  5. Reduce calls in the first place. Use $select, delta instead of re-listing, caching with cTag/eTag, and don’t poll folders nobody is looking at.
  6. Identify your traffic. Microsoft recommends a User-Agent like ISV|YourCompany|YourApp/1.0. Well-identified traffic gets better treatment when SharePoint has to choose.

Errors

Error responses look like this:

{
  "error": {
    "code": "itemNotFound",
    "message": "The resource could not be found.",
    "innerError": { "request-id": "a1b2...", "date": "2026-10-20T01:02:03" }
  }
}
  • Branch on status and error.code, not on message, which can change.
  • Log the request-id (also in the request-id response header). Microsoft support asks for it.
  • 401 = bad or expired token. 403 = valid token, but missing scope or no access to that item. Tell them apart, because the fixes are different (refresh vs ask for consent vs “you don’t have access”).
  • 404 can also mean “exists, but you can’t see it”. SharePoint hides items from people without access.
  • Drive search: GET /me/drive/root/search(q='plan'). Simple, only within one drive.
  • Microsoft Search: POST /search/query searches across SharePoint, OneDrive, mail, messages and more, using KQL (Keyword Query Language). For example, path:"https://contoso.sharepoint.com/sites/team/Shared Documents/Notes" limits results to one folder.
  • Search uses an index that lags behind real changes, from seconds to minutes or more. A file you just created may not be found yet. Re-check results against live data when it matters.

Common gotchas

  • Store ids, not paths. Paths change on rename.
  • Eventual consistency. Right after creating or moving something, a listing or search may not show it yet.
  • Special characters in names. Encode paths. SharePoint also forbids some characters and names, and has a total path length limit (around 400 characters).
  • Token audience. A Graph token isn’t valid for SharePoint REST, and the other way round.
  • Delegated “All” scopes still respect the user’s own access.
  • Consent differs per tenant. Test in a tenant that blocks user consent.
  • downloadUrls are secrets until they expire.
  • Big folders + polling = throttling. Use delta.
  • Some features exist only in beta. Wait for v1.0, or isolate the beta call so it can fail gracefully.

Tools

  • Graph Explorer (developer.microsoft.com/graph/graph-explorer): try any call with your own account, see the permissions each call needs, and copy code snippets.
  • Microsoft Graph SDKs exist for JS, .NET, Python, Java and Go. They handle paging, retries and batching. Many teams still use plain fetch with a thin wrapper to control retries and bundle size.
  • jwt.ms: paste an access token to see its audience, scopes (scp) and expiry. Indispensable for debugging 403s.

A real-world example

A SaaS app that stores each customer’s documents as files in their own OneDrive or SharePoint, and keeps only metadata in its own database:

  • Delegated only. The app registration has no application permissions at all. Every Graph call runs as the signed-in user, with their token, so SharePoint itself decides who can see what. That made the app much easier for enterprise security teams to approve. The cost: no background crawls, since the server can only touch files while a user’s token is available.

  • Incremental consent. Sign-in asks only for User.Read and Files.ReadWrite.All. Sites, calendars, people search and meeting transcripts are requested the first time a feature needs them. Each server route demands exactly the scopes it uses.

  • A thin client of its own. A small wrapper around fetch adds typed errors and a retry policy like this:

    maxRetries      3
    baseDelay       1 s, doubling
    maxDelay        30 s
    jitter          250 ms
    Retry-After     honoured, plus 0 to 10% random spread
    deadline        optional total budget, so retries never outlast the proxy
    

    It treats 429 always, and 503 from SharePoint, as throttling.

  • Delta polling. A server polls one drive delta per actively used library every 15 seconds, using a connected user’s token. It backs off when throttled, starts over on 410, and only tells browsers “something changed”, with no names or paths. Each browser then runs its own delta with its own token.

  • Safe saves. Writes use If-Match with the eTag, and name clashes use conflictBehavior. Large files use upload sessions.

  • Search. An AI assistant searches with Microsoft Search /search/query, limited by a KQL path: to the app’s folder. It re-checks every hit against live data, and falls back to drive search when that fails.

  • Batching. $batch is used wherever several small reads happen together.

Practice

  1. In Graph Explorer, list your OneDrive root with and without $select. Compare response sizes.
  2. Write a script that pages through a big folder with @odata.nextLink.
  3. Run a delta on your drive, save the deltaLink, rename a file, and call the deltaLink again. Then delete a file and see the deleted facet.
  4. Upload a file, then try to overwrite it with a stale If-Match eTag and get a 412.
  5. Paste your Graph token into jwt.ms and read the aud, scp and exp claims.
  6. Write a retry wrapper that honours Retry-After with spread, backs off with jitter, and has a deadline. Test it against a fake server that returns 429.

Sources worth reading

  • Microsoft Learn: Overview of Microsoft Graph, Microsoft Graph permissions reference
  • Microsoft Learn: Working with files in Microsoft Graph (driveItem, upload sessions, delta)
  • Microsoft Learn: Microsoft Graph throttling guidance, and Avoid getting throttled or blocked in SharePoint Online
  • Microsoft Learn: Use query parameters to customise responses, Combine multiple requests in one HTTP call using JSON batching
  • Microsoft Learn: Microsoft identity platform and OAuth 2.0 On-Behalf-Of flow