Calling SharePoint REST like a pro
Most SharePoint REST pain comes from three things the docs gloss over: which of the two clients (and OData worlds) you're in, the headers that actually matter, and how to write data safely. Get those right and the sharp edges mostly disappear.
Většina bolesti se SharePoint REST pramení ze tří věcí, které dokumentace obchází: ve kterém ze dvou klientů (a OData světů) jste, které hlavičky doopravdy rozhodují, a jak bezpečně zapisovat data. Zvládněte je a ostré hrany z velké části zmizí.
SharePoint's REST API is powerful, well-documented in the happy path — and full of sharp edges the docs don't mention. This guide is the map we wish we'd had: which client to use, which headers actually matter, how to write data without surprises, and how to diagnose the inevitable 400.
1. Two clients, two OData worlds
| Context | Client | Auth | OData default |
|---|---|---|---|
| SPFx web part / extension | SPHttpClient |
handled for you (including request digest) | 4.0, minimalmetadata |
| Browser console, bookmarklet, external script | fetch with cookies |
session cookie + X-RequestDigest for writes |
whatever you send |
Two consequences people trip over:
SPHttpClientsilently addsOData-Version: 4.0. Legacy endpoints that only speak OData 3 (search,/Files/add) fail until you override it — see the header table below.- Payload style must match the OData mode: modern calls use
'@odata.type': '#SP.List'; the old__metadata: { type: 'SP.List' }exists only inodata=verbose— and underSPHttpClienteven a fully verbose request with it 400s, because of the addedOData-Version: 4.0. Drop__metadata; for ordinary writes the type comes from the URL, and where the verbose form is really needed, an emptyodata-versionheader putsSPHttpClientinto OData v3 mode.
Getting a request digest outside SPFx:
const ctx = await (await fetch(`${webUrl}/_api/contextinfo`, {
method: 'POST',
headers: { 'Accept': 'application/json;odata=nometadata' }
})).json();
// ctx.FormDigestValue → send as X-RequestDigest on every write; refresh before it expires
2. The headers that matter
Hard-won pairings — when a call fails with 400/406/500 and you don't know why, check this table first:
| Call | Headers that work | If you get it wrong |
|---|---|---|
GET …/items, most reads |
Accept: application/json;odata=nometadata |
— (this one's forgiving) |
POST …/items |
Accept: application/json — or ;odata=nometadata, both return 201 |
Nothing breaks, but the response shape moves: with OData-Version: 4.0 (every SPHttpClient call) SharePoint ignores odata=nometadata and even odata=verbose in Accept and answers odata.metadata=minimal (no d wrapper, extra @odata.* keys); ask for ;odata.metadata=none if you want none. An older version of this row claimed a 406 — a live test on 2026-09-24 did not reproduce it. POST …/lists and …/fields were not re-measured |
Anything /_api/search/* |
odata-version: 3.0 + Accept: application/json;odata=nometadata |
500 UnknownError, search only |
POST …/Files/add(…) |
Accept: application/json;odata=verbose + OData-Version: 3.0 |
406 "ACCEPT header missing or invalid" |
Body contains __metadata |
Drop it and send plain JSON — verbose headers work only without OData v4: a bare fetch, or SPHttpClient with odata-version: '' |
400 "property '__metadata' does not exist" / "JSON Light … not supported" |
3. Writing data
Create — POST to the items collection; prefer resolving the list by URL, not title:
await spHttpClient.post(
`${webUrl}/_api/web/GetList(@u)/items?@u='${listUrl}'`,
SPHttpClient.configurations.v1,
{
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ Title: 'Hello', EventDate: new Date(input).toISOString() })
});
Update — POST with method-override headers (works everywhere, including old servers):
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-HTTP-Method': 'MERGE',
'IF-MATCH': '*' // or a concrete etag for optimistic concurrency
}
Delete — same shape with X-HTTP-Method: DELETE. Consider POST …/items(id)/recycle() instead — recoverable beats gone.
Field-value traps to know before your first write: DateTime wants full ISO · lookup/person fields are written via <Name>Id · Choice fields validate nothing · apostrophes in OData literals double.
Non-negotiable: check res.ok on every write and read the body on failure. SharePoint's error messages usually name the real problem — a silently swallowed 400 becomes "the feature randomly doesn't work" three weeks later:
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Save failed (HTTP ${res.status}): ${body.slice(0, 300)}`);
}
4. Reading well
$selectonly what you render; add$expandfor lookup and person columns,$expand=File&$select=File/Lengthfor file size and version.- Past a few thousand items, page with
odata.nextLink—$skipis silently ignored on items — and make sure your$filterleads with an indexed column. - Text filters on
/_api/web/siteusersare case-sensitive (substringof('megan', Title)won't find "Megan") — test the endpoint you rely on rather than assuming one behaviour for all of them. For user-facing search-as-you-type, fetch a candidate set and filter client-side, or use SP Search.
5. Creating lists and fields over REST
Provisioning-style calls have their own micro-rules:
Field types go through the base type +
FieldTypeKind— some specific OData types simply don't exist:{ '@odata.type': '#SP.Field', FieldTypeKind: 8, Title: 'IsActive' } // Boolean — '#SP.FieldBoolean' is NOT a thing { '@odata.type': '#SP.Field', FieldTypeKind: 9, Title: 'Amount' } // Number — '#SP.FieldNumber' neither { '@odata.type': '#SP.FieldText', FieldTypeKind: 2, Title: 'Code' } // this one existsDon't set
Indexed: trueat creation time — SharePoint may answer 500 (index limits); create the field first, index it as a separate step.Prefer try-create-and-tolerate-exists over check-then-create — existence probes cost an extra round trip and some of them return 406 through
SPHttpClientanyway.
6. When it fails anyway — the ten-minute diagnosis
- Read the response body. Not just the status. The body names the field, the type, the missing header.
- 406? Wrong
Acceptfor that endpoint family — see the table in §2. - 400 on a write? A/B-test it: same call as a harmless no-op (a MERGE with one known-good field) in plain mode vs verbose mode — with the
OData-Versionheader your real client sends, or a barefetchlets the verbose arm pass and tells you the opposite of what your app will see. One minute, and you know whether it's the wire format or your payload. - 500 only on search? It's the
odata-versionheader. It's always the header. - Works for you, empty for users? Same query returning rows to one account and zero (HTTP 200!) to another is usually item-level security (
ReadSecurity) or permissions — check which account you're testing with before blaming cache. - Don't trust adjacent evidence. "The admin portal shows the data" or "it works in the browser address bar" does not prove your call path — different client, different headers, different auth. Reproduce with the failing client before concluding anything.
Every claim above earned its place by breaking something first. Corrections and additions welcome — see CONTRIBUTING.