SharePoint Patterns
A working reference for building on SharePoint Online — the REST/OData API surface, list-item CRUD with the right headers, querying and expanding lookup/person fields, surviving the 5,000-item list view threshold with column indexing, throttling and $batch, unique permissions and role assignments, document-library file handling (including chunked large-file upload), and the Power Automate SharePoint connector patterns that pull it all together.
Three ways in — pick the right one
Almost everything in SharePoint is reachable three ways, and choosing well saves you most of the pain. Reach for the first-party SharePoint connector actions (Get items, Create item, Create file…) for the common 80%; drop to Send an HTTP request to SharePoint when the connector can’t express what you need; and prefer Microsoft Graph for cross-service work and lighter throttling.
| Approach | Best for | Auth & cost |
|---|---|---|
| SharePoint connector actions | Standard list/library CRUD inside Power Automate / Power Apps | Standard connector — handles auth for you |
| Send an HTTP request to SharePoint | Anything the connector misses — permissions, folders, batch, admin REST | Standard — runs SharePoint REST as the flow owner, digest handled |
| Microsoft Graph (/sites, /drives) | Cross-service, delta sync, app-only, high volume | Premium HTTP / Graph — fewer resource units, less throttling |
Graph is cheaper at scale
Microsoft’s own guidance is to choose Graph over CSOM and REST when possible — Graph consumes fewer resource units for the same work, so it throttles later. Keep SharePoint REST for the things Graph still can’t do (some field types, classic permissions, certain admin endpoints).
Internal names ≠ display names
Every column has a display name (what you see) and an internal name (what the API uses). Renaming a column changes only the display name; the internal name is frozen at creation, and spaces become _x0020_ (e.g. “Start Date” → Start_x0020_Date). Always query and write by internal name.
The REST/OData surface
The SharePoint REST service lives under _api/ on every site. List data hangs off _api/web. Address a list by title, by GUID, or by its server-relative URL — title is readable but breaks if the list is renamed, so GUID is the stable choice for production.
GET https://contoso.sharepoint.com/sites/Ops/_api/web/lists/getbytitle('Orders')/items
GET https://contoso.sharepoint.com/sites/Ops/_api/web/lists(guid'2b1c…')/items
GET https://contoso.sharepoint.com/sites/Ops/_api/web/GetList('/sites/Ops/Lists/Orders')/items
Accept: application/json;odata=nometadataJSON light — control how much metadata you get back
The Accept header decides how chatty the response is. Use the lightest level your code needs — nometadata is far easier to parse, verbose is required when you write items because the entity type lives in the metadata.
| Accept header | Returns | Use when |
|---|---|---|
| application/json;odata=nometadata | Bare fields, no __metadata / type | Reading — simplest to parse in Parse JSON |
| application/json;odata=minimalmetadata | Fields + a little type info | Default; a balance of size and detail |
| application/json;odata=verbose | Fields wrapped in d / __metadata / results | Legacy samples; required by some write payloads |
verbose wraps everything
With odata=verbose, a collection arrives as d.results and a single item as d. With nometadata the same data is a plain top-level value array. Match your Parse JSON schema to the header you actually send.
OData querying: $select, $filter, $expand, paging
Push shaping into the query string so the server returns only the rows and columns you need. Selecting two columns instead of forty is the single cheapest performance win, and a filter on an indexed column is what keeps a large list usable (see Large lists below).
| Option | Purpose | Example |
|---|---|---|
| $select | Return only these columns | $select=Title,Status,Amount |
| $filter | Server-side row filter | $filter=Status eq 'Open' |
| $expand | Project fields from a lookup/person column | $expand=AssignedTo&$select=AssignedTo/Title |
| $orderby | Sort (asc/desc) | $orderby=Modified desc |
| $top | Page size (first N rows) | $top=100 |
| $skiptoken | Continuation cursor for the next page | Paged=TRUE&p_ID=5000 |
| $count | Item count (/items/$count) | GET …/items/$count |
$skip does not work on list items
OData $skip is ignored for SharePoint list items — paging is cursor-based. Use $top for the page size and follow the odata.nextLink / __next URL (which carries $skiptoken=Paged=TRUE&p_ID=…) to get the next page. Don’t try to compute offsets.
| Operator / fn | Meaning | Example |
|---|---|---|
| eq / ne | Equals / not equals | $filter=Status eq 'Open' |
| gt ge lt le | Numeric / date comparison | $filter=Amount ge 1000 |
| and / or | Combine conditions | $filter=Status eq 'Open' and Amount gt 1000 |
| startswith | Prefix match on text | $filter=startswith(Title,'INV') |
| substringof | Substring match (note arg order) | $filter=substringof('urgent',Title) |
| Lookup/Id | Filter a lookup by its numeric Id | $filter=CategoryId eq 3 |
| Lookup/Field | Filter via an expanded lookup field | $filter=Country/Title eq 'New Zealand' |
| Date literal | Quoted ISO 8601 | $filter=Modified gt '2026-06-01T00:00:00Z' |
Expand a lookup or person column
GET …/_api/web/lists/getbytitle('Orders')/items
?$select=Title,Status,AssignedTo/Title,AssignedTo/EMail,Category/Title
&$expand=AssignedTo,Category
&$top=100
Accept: application/json;odata=nometadataLookups cost you a join
Each lookup, person, or workflow-status column in a query counts toward the list view lookup threshold of 12 joins per query (queries over 8 get risky on big lists). Expand only the lookups you actually display, and prefer filtering on the raw …Id column where you can.
Creating, updating & deleting items
Reads are plain GETs. Every write is a `POST` — you signal update vs delete through the X-HTTP-Method header, not the verb. Two things trip people up: the request-digest token and the item’s entity type name.
| Header | When | Value |
|---|---|---|
| X-RequestDigest | Every POST (unless using OAuth / the connector) | Form digest from /_api/contextinfo |
| X-HTTP-Method | Update or delete | MERGE (partial update), PUT (replace), or DELETE |
| IF-MATCH | Update / delete of a list item or file | The item’s ETag, or "*" to force-overwrite |
| Content-Type | POSTs with a body | application/json;odata=verbose (or nometadata) |
| Accept | Requests returning metadata | application/json;odata=nometadata |
Get the form digest first (raw REST only)
POST https://contoso.sharepoint.com/sites/Ops/_api/contextinfo
Accept: application/json;odata=nometadata
// → body.FormDigestValue → send as X-RequestDigest on subsequent writesCreate an item
POST …/_api/web/lists/getbytitle('Orders')/items
Accept: application/json;odata=verbose
Content-Type: application/json;odata=verbose
X-RequestDigest: <form digest>
{
"__metadata": { "type": "SP.Data.OrdersListItem" },
"Title": "INV-1043",
"Status": "Open",
"Amount": 1299.5
}Find the exact entity type at runtime
Don’t guess the type name. Request ListItemEntityTypeFullName on the list (via $select) to get the precise value — spaces and odd characters in the list name get encoded (e.g. SP.Data.Order_x0020_QueueListItem). With odata=nometadata you can usually omit __metadata entirely on writes.
Update (MERGE) and delete
POST …/_api/web/lists/getbytitle('Orders')/items(42)
X-HTTP-Method: MERGE
IF-MATCH: "*"
Content-Type: application/json;odata=verbose
X-RequestDigest: <form digest>
{ "__metadata": { "type": "SP.Data.OrdersListItem" }, "Status": "Closed" }
POST …/_api/web/lists/getbytitle('Orders')/items(42)
X-HTTP-Method: DELETE
IF-MATCH: "*"
X-RequestDigest: <form digest>ETags = optimistic concurrency
Pass the item’s ETag in IF-MATCH and SharePoint rejects the write with 412 Precondition Failed if someone changed the row since you read it. Use IF-MATCH: "*" only when last-write-wins is genuinely acceptable. MERGE keeps untouched fields; PUT resets anything you don’t send to its default.
Writing the awkward field types
Plain text, number, choice, and date columns take their literal value. The columns that cause support tickets are person, lookup, multi-value, and managed-metadata — each wants a particular shape.
| Column type | Field to set | Value shape |
|---|---|---|
| Person/Group (single) | AssignedToId | The numeric site-user Id — e.g. 14 |
| Person/Group (multi) | AssignedToId | { "results": [14, 27] } |
| Lookup (single) | CategoryId | The target item’s Id — e.g. 3 |
| Lookup (multi) | CategoryId | { "results": [3, 8] } |
| Choice (multi) | Tags | { "results": ["A", "B"] } |
| Yes/No | IsActive | true / false (boolean, not string) |
| Hyperlink | Link | { "__metadata": { "type": "SP.FieldUrlValue" }, "Url": "https://…", "Description": "Doc" } |
| Managed metadata | hidden note field | TermGuid + label via the taxonomy note field |
Person and lookup columns write by Id
You set a person column by posting to <InternalName>Id with the user’s site user Id (not their email or claims string). Resolve the Id first with …/_api/web/siteusers or, in Power Automate, the Resolve person action. Multi-value person, lookup, and choice columns all take a { "results": [ … ] } wrapper.
Managed metadata is two fields
A taxonomy column stores its value in a hidden note field. Setting it cleanly over REST is fiddly (TermGuid + WssId); if you can, use the Update item connector action or CSOM/PnP, which handle the note-field plumbing for you.
Large lists & the 5,000-item threshold
SharePoint Online enforces a list view threshold of 5,000 items — the most items a single database operation may touch at once. Data above 5,000 is fine and fully stored; what breaks is any *view, sort, or filter* that would have to scan more than 5,000 rows. The fix is column indexing, applied before you cross the line.
It’s the view, not the data
A 200,000-item list works perfectly — you just can’t ask the server to evaluate an unindexed column across all of it. “The number of items in this list exceeds the list view threshold (5,000)” means *narrow the query*, not *delete data*.
Index the columns you filter and sort on
An indexed column lets the server jump straight to matching rows. The golden rule: a view over the threshold works only if its first filter is on an indexed column and that filter returns ≤ 5,000 rows. Index proactively — you can add an index only while the list is under 20,000 items, and a list may hold at most 20 indexes.
| Column type | Indexable? |
|---|---|
| Single line of text | Yes |
| Choice (single value) | Yes |
| Number / Currency | Yes |
| Date and Time | Yes |
| Yes/No | Yes |
| Person or Group (single) | Yes |
| Lookup (single) | Yes |
| Managed Metadata (single) | Yes |
| Multiple lines of text | No |
| Choice / Lookup / Person / MMD (multi-value) | No |
| Calculated, Hyperlink/Picture | No |
Index Title, Created, Modified, Created By, Modified By
These five let you piece together a usable default view on almost any large list. SharePoint Online also auto-indexes some columns now, but it won’t index lists already over 20,000 items and may not pick the column your users actually filter on — plan indexes yourself rather than relying on it.
Page through a big list
GET …/_api/web/lists/getbytitle('BigList')/items
?$select=Title,Status&$filter=Status eq 'Open' // Status indexed
&$top=2000
Accept: application/json;odata=nometadata
// response → "odata.nextLink": "…/items?$skiptoken=Paged=TRUE&p_ID=2000&…"
// GET that nextLink for the next page; stop when it is absent.Power Automate Get items: turn Pagination ON
A known limitation: Get items with a Filter Query on a list over 5,000 items returns *nothing* if no match falls in the first 5,000 rows — unless you enable Pagination (action → Settings) and raise the threshold. Combine that with a filter on an indexed column. Other limits worth knowing: a list/library over 100,000 items can’t have inheritance broken at the list/folder level, and the supported ceiling is 50,000 unique permission scopes (recommended ≤ 5,000).
Throttling: 429, 503 & Retry-After
SharePoint Online throttles to protect the service. Applications get HTTP 429 (Too Many Requests) or 503 (Server Too Busy), each with a Retry-After header telling you how many seconds to wait. Honouring it is the fastest route out — throttled requests still count against your quota, so blind retries dig the hole deeper.
HTTP/1.1 429 Too Many Requests
Retry-After: 31
RateLimit-Limit: 1200
RateLimit-Remaining: 0
RateLimit-Reset: 31- Honour
Retry-Afterexactly — it is SharePoint telling you the minimum safe delay. The HTTP action’s Default retry policy already respects it. - Watch the preview
RateLimit-Remainingheader and slow down *before* you hit zero, rather than waiting for the 429. - Reduce concurrency and avoid request spikes — cap Apply to each parallelism; high fan-out multiplies throttling.
- Prefer Microsoft Graph over CSOM/REST — it costs fewer resource units, so it throttles later.
- Use delta queries with a token to scan content (1 resource unit) instead of re-reading whole lists.
Decorate your traffic
SharePoint prioritises well-decorated traffic. App-only and custom callers should send a registered App ID and a User-Agent in Microsoft’s naming convention so the service can identify (and favour) you. Undecorated traffic — no App ID, no compliant User-Agent — is throttled first.
User-Agent: ISV|CompanyName|AppName/Version // building a product for others
User-Agent: NONISV|CompanyName|AppName/Version // internal line-of-business appSearch has its own ceilings
Independently of the general limits, SharePoint search throttles app-only Sites.Read.All queries at 25 req/s, delegated user search at 10 req/s per user (aggregated across all apps), and people-search at 25 req/s org-wide. Cache results and consolidate queries rather than firing many small ones.
$batch — combine requests
The SharePoint Online REST service (and SharePoint 2016+) supports the OData $batch endpoint: pack many reads — and writes, grouped in change sets — into one HTTP call. Fewer round-trips means less latency and fewer throttling hits, since each sub-request is still costed individually but you pay one network trip.
POST https://contoso.sharepoint.com/sites/Ops/_api/$batch
Content-Type: multipart/mixed; boundary="batch_a1"
X-RequestDigest: <form digest>
--batch_a1
Content-Type: application/http
GET /sites/Ops/_api/web/lists/getbytitle('Orders')/items?$top=1 HTTP/1.1
Accept: application/json;odata=nometadata
--batch_a1--Batch is plumbing — let a library do it
Hand-rolling multipart change sets is error-prone. PnP.js, PnP PowerShell, and most SDKs expose a batch API that builds the body for you. In Power Automate, the Send an HTTP request to SharePoint action can POST a $batch body, but for bulk item writes a well-tuned Apply to each with concurrency control is usually simpler to maintain.
Permissions: inheritance, roles & unique scopes
Sites, lists, folders, and items are all securable objects. By default each inherits permissions from its parent. To give an object its own permissions you break inheritance, which copies the parent’s assignments down and then lets you add or remove them independently.
The model: role definitions + role assignments
You never grant a *right* to a user directly. A role definition (a.k.a. permission level) is a named bundle of rights; a role assignment binds a principal (user or group) to a role definition on a particular scope. Manage access by adding and removing assignments — and by managing groups, not individuals.
| Level | Grants |
|---|---|
| Full Control | Everything, including managing permissions |
| Design | Add/edit/delete lists; approve; customise pages |
| Edit | Add/edit/delete list items and lists |
| Contribute | Add/edit/delete items; cannot manage lists |
| Read | View pages and items; download |
| View Only | View pages/items; no download |
| Limited Access | System-assigned to reach a specific shared item; never grant by hand |
Break inheritance & assign a role over REST
// 1. Break inheritance — keep existing assignments, reset child scopes
POST …/_api/web/lists/getbytitle('Orders')/breakroleinheritance(copyRoleAssignments=true,clearSubscopes=true)
X-RequestDigest: <form digest>
// 2. Look up the role definition id for "Contribute"
GET …/_api/web/roledefinitions/getbyname('Contribute') // → Id, e.g. 1073741827
// 3. Grant principal 18 (a SharePoint group) Contribute on the list
POST …/_api/web/lists/getbytitle('Orders')/roleassignments/addroleassignment(principalid=18,roledefid=1073741827)
X-RequestDigest: <form digest>breakroleinheritance(copyRoleAssignments, clearSubscopes)— passcopyRoleAssignments=falseto start from an empty ACL (then add yourself back),clearSubscopes=trueto discard any unique permissions already set below.resetroleinheritance— re-inherit from the parent and drop the object’s unique permissions.- Resolve principal Ids from
…/_api/web/sitegroups/getbyname('…')or…/_api/web/siteusers; resolve role-definition Ids from…/roledefinitions. - Remove access with
roleassignments/removeroleassignment(principalid=…,roledefid=…).
Unique permissions don’t scale — budget them
Every broken-inheritance scope is a unique security scope. A list supports at most 50,000 unique scopes, and performance degrades well before that — Microsoft recommends keeping it under 5,000. Item-level permissions on a big list are a classic performance trap: put items in a folder and secure the *folder*, or redesign so groups (not per-item ACLs) carry access. Lists/folders over 100,000 items can’t have inheritance broken at all.
Document libraries & files
A document library is just a list whose items are files (base template 101). So everything above applies — plus a file/folder REST surface for the binary side. Reach a folder by its server-relative URL, and a file by its server-relative URL or through its parent folder’s Files collection.
GET …/_api/web/GetFolderByServerRelativeUrl('/sites/Ops/Shared Documents')/Files
GET …/_api/web/GetFileByServerRelativeUrl('/sites/Ops/Shared Documents/q3.pdf')
POST …/_api/web/GetFolderByServerRelativeUrl('/sites/Ops/Shared Documents')/Files/Add(url='q3.pdf',overwrite=true)
X-RequestDigest: <form digest>
<binary file content in the body>Set metadata on an uploaded file
A file is also a list item. To set its columns, reach it as a list item via ListItemAllFields and MERGE the fields — exactly like a normal item update.
POST …/_api/web/GetFileByServerRelativeUrl('/sites/Ops/Shared Documents/q3.pdf')/ListItemAllFields
X-HTTP-Method: MERGE
IF-MATCH: "*"
Content-Type: application/json;odata=verbose
X-RequestDigest: <form digest>
{ "__metadata": { "type": "SP.Data.Shared_x0020_DocumentsItem" },
"Title": "Q3 report", "Department": "Finance" }Large files — upload in chunks
The simple Files/Add body is capped (~2 MB on the classic Content path; the connector’s Create file can’t chunk at all). For anything over ~10 MB, upload the file as a sequence of slices with one upload session, identified by a GUID: StartUpload on the first chunk, ContinueUpload for the middle, FinishUpload to commit. REST supports files up to 2 GB this way.
// create an empty file first, then:
POST …/GetFileByServerRelativeUrl('/sites/Ops/Shared Documents/big.zip')/StartUpload(uploadId=guid'4f2…')
POST …/GetFileByServerRelativeUrl('…/big.zip')/ContinueUpload(uploadId=guid'4f2…',fileOffset=10485760)
POST …/GetFileByServerRelativeUrl('…/big.zip')/FinishUpload(uploadId=guid'4f2…',fileOffset=20971520)
// each call body = that slice of bytes; the response returns the new offsetChunk rules of thumb
Use ~10 MB chunks, send them in order (no parallel slices), and add a retry: an interrupted upload locks the file for ~15 minutes and unfinished files are purged after 6–24 hours. Check files out before a metadata update and check them back in — CheckOut() then CheckIn(comment='…',checkintype=0) (0 = major version).
Power Automate connector patterns
Most flows never need raw REST — the SharePoint connector covers it. The trick is filtering on the server (not pulling everything and filtering in the flow) and knowing the connector’s sharp edges.
Get items — filter on the server with a Filter Query
Status eq 'Open'
Status eq 'Open' and Amount gt 1000
startswith(Title, 'INV')
Country/Title eq 'New Zealand' // lookup column → child field
Start_x0020_Date gt '@{formatDateTime(utcNow(),'yyyy-MM-dd')}'
// Order By: Modified desc Top Count: 5000 Pagination: On (Settings)- Set Top Count and turn on Pagination (Settings) for lists over 5,000 — otherwise a Filter Query that matches nothing in the first 5,000 rows returns empty.
- Filter on an indexed column; use Limit Columns by View so you only pull the fields you need.
- Use a trigger condition on “When an item is created or modified” to stop re-runs (e.g. only fire when a status equals a value) and avoid infinite loops when the flow writes back.
- For person columns, get the Id with the Resolve person action before Create/Update item.
- Cap Apply to each concurrency when each iteration calls SharePoint — it is the easiest way to throttle yourself.
When the connector can’t — Send an HTTP request to SharePoint
This standard (non-premium) action runs any SharePoint REST call as the flow’s connection, handling the digest and auth for you. Use it for permissions, folders, batch, or admin endpoints the connector doesn’t expose. Add Accept: application/json;odata=nometadata to get easily-parsed JSON light back.
// single object
body('Send_an_HTTP_request_to_SharePoint')['Id']
// a collection → loop body(...)['value'], then per row:
items('Apply_to_each')['Title']Connector vs generic HTTP vs Graph
Send an HTTP request to SharePoint is standard and SharePoint-only. The generic HTTP action is premium and can reach any endpoint. For cross-service or high-volume work, the HTTP with Microsoft Entra ID / Graph route throttles less. The connector’s Get item / Create item / Update item don’t support *Limit Columns by View*; only Get items does.
Field notes & gotchas
- Index before 5,000, not after. You can only add an index while the list is under 20,000 items, and a view over threshold needs its first filter on an indexed column. Plan indexes the day you create the list.
- Always use internal names in
$filter,$select,$orderby, and Filter Query — display names lie after a rename, and spaces are_x0020_. - Person/lookup columns write by Id, not email or claims. Resolve the Id first; multi-value fields take a
{ "results": [...] }wrapper. - Every write is a POST with
X-HTTP-MethodMERGE/PUT/DELETE — and needs a request digest unless you’re using the connector or OAuth. - Match your Parse JSON to your Accept header —
nometadatagives a flatvaluearray;verbosenests underd.results. - Unique permissions are a scaling cost. Secure folders or use groups; never put per-item ACLs on a large list.
- Honour Retry-After and decorate your traffic — undecorated, spiky callers get throttled first; prefer Graph and delta queries at volume.
Every cheat table here is searchable
Use the filter box on the tables above to jump to an operator, header, field type, or permission level fast. This guide is generated from one structured dataset, so the same reference can also back the FlowLibs MCP server for AI assistants.