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.0is stable and supported for production.betahas 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/commonor/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.
| Delegated | Application | |
|---|---|---|
| Acts as | the signed-in user | the app itself, no user |
| Can reach | only what that user can reach | everything in the tenant that the permission covers |
| Example | Files.ReadWrite.All, delegated: the user’s own files and anything shared with them | Files.ReadWrite.All, application: every file in every OneDrive and SharePoint site in the company |
| Consent | often the user can consent; some scopes need an admin | always needs an admin |
| Used for | apps where a person is using it | background 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.
Consent
- 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
fileorfolderfacet (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:
eTagchanges on any change: content, name, metadata.cTagchanges 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}:/contentwith 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), thenPUTthe file in chunks (multiples of 320 KiB), each with aContent-Rangeheader. A dropped connection only loses one chunk. - Name clashes: pass
@microsoft.graph.conflictBehaviorasfail,replaceorrename(“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.$countand some advanced$filters on directory objects (users, groups) need the headerConsistencyLevel: eventualand$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
- The first call returns everything (paged through
@odata.nextLink), and the last page has an@odata.deltaLink. - Save the deltaLink.
- Later, call the deltaLink. You get only items that changed since then, plus a new deltaLink.
- Repeat.
Things to know:
- Deleted items come back with a
deletedfacet. Moved or renamed items come back with their newparentReferenceorname. - 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 Gonemeans 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,$selectand similar options to keep delta responses small. There are severalPreferheaders 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. dependsOnforces 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 aRetry-Afterheader (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:
- Honour
Retry-After. Wait at least that long. Retrying sooner makes it worse and can extend the penalty. - Add a little random spread on top of
Retry-After. If 50 requests were throttled in the same second, they all get the sameRetry-After. Without spread, they all wake up together and get throttled again. - No
Retry-After? Back off exponentially with jitter (1 s, 2 s, 4 s… plus randomness, with a cap). - 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.
- Reduce calls in the first place. Use
$select, delta instead of re-listing, caching withcTag/eTag, and don’t poll folders nobody is looking at. - Identify your traffic. Microsoft recommends a
User-AgentlikeISV|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
statusanderror.code, not onmessage, which can change. - Log the
request-id(also in therequest-idresponse 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”).404can also mean “exists, but you can’t see it”. SharePoint hides items from people without access.
Search
- Drive search:
GET /me/drive/root/search(q='plan'). Simple, only within one drive. - Microsoft Search:
POST /search/querysearches 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 forv1.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
fetchwith 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 debugging403s.
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.ReadandFiles.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
fetchadds 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 proxyIt treats
429always, and503from 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-Matchwith the eTag, and name clashes useconflictBehavior. Large files use upload sessions. -
Search. An AI assistant searches with Microsoft Search
/search/query, limited by a KQLpath:to the app’s folder. It re-checks every hit against live data, and falls back to drive search when that fails. -
Batching.
$batchis used wherever several small reads happen together.
Practice
- In Graph Explorer, list your OneDrive root with and without
$select. Compare response sizes. - Write a script that pages through a big folder with
@odata.nextLink. - Run a delta on your drive, save the deltaLink, rename a file, and call the deltaLink again. Then delete a file and see the
deletedfacet. - Upload a file, then try to overwrite it with a stale
If-MatcheTag and get a412. - Paste your Graph token into jwt.ms and read the
aud,scpandexpclaims. - Write a retry wrapper that honours
Retry-Afterwith spread, backs off with jitter, and has a deadline. Test it against a fake server that returns429.
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