Plain fetch() or XMLHttpRequest work fine for hitting SharePoint’s REST API — the part that’s actually SharePoint-specific isn’t the HTTP call itself, it’s the headers: an Accept header SharePoint’s OData implementation expects, and a request digest token required on anything that isn’t a GET.
In this post: A GET request: fetching list items · A POST request, and the request digest · The same pattern inside a modern SPFx web part · Updating an item with MERGE · Handling a 429 instead of just failing · When would you actually use this? · Related reading
A GET request: fetching list items
function getListItems() {
const siteUrl = _spPageContextInfo.webAbsoluteUrl;
const endpoint = `${siteUrl}/_api/web/lists/getbytitle('Tasks')/items`;
fetch(endpoint, {
method: "GET",
headers: {
"Accept": "application/json;odata=nometadata"
}
})
.then(response => {
if (!response.ok) { throw new Error(`Request failed: ${response.status}`); }
return response.json();
})
.then(data => console.log(data.value))
.catch(error => console.error(error));
}
The Accept: application/json;odata=nometadata header is what makes SharePoint return plain JSON instead of its older, verbose OData format wrapping every field in metadata. GET requests are otherwise unremarkable — no digest, no special auth handling beyond whatever cookie/token already authenticates the page.
A POST request, and the request digest
This is the part that trips people up coming from a generic REST API background: any request that changes data (POST, MERGE, DELETE) needs an X-RequestDigest header, or SharePoint rejects it outright with a 403. The digest is a short-lived security token already embedded in every SharePoint page — you don’t request it separately, you just read it off the page:
function createListItem() {
const siteUrl = _spPageContextInfo.webAbsoluteUrl;
const endpoint = `${siteUrl}/_api/web/lists/getbytitle('Tasks')/items`;
const digest = document.getElementById("__REQUESTDIGEST").value;
fetch(endpoint, {
method: "POST",
headers: {
"Accept": "application/json;odata=nometadata",
"Content-Type": "application/json;odata=verbose",
"X-RequestDigest": digest
},
body: JSON.stringify({
"__metadata": { "type": "SP.Data.TasksListItem" },
"Title": "New task from a POST request"
})
})
.then(response => {
if (!response.ok) { throw new Error(`Request failed: ${response.status}`); }
return response.json();
})
.then(data => console.log("Created:", data))
.catch(error => console.error(error));
}
Two things worth flagging: the __metadata.type value has to match the list’s actual entity type name (SP.Data.<ListName>ListItem, with some naming quirks for lists that have spaces or start with a number) — get this wrong and the request fails with a metadata error, not an obviously-labeled one. And the digest embedded in the page has a limited lifetime (typically 30 minutes) — for a long-running SPA that doesn’t reload the page, re-fetch a fresh digest via a call to /_api/contextinfo rather than assuming the original one is still valid.
The same pattern inside a modern SPFx web part
Everything above assumes a classic page: _spPageContextInfo and the __REQUESTDIGEST DOM element are both classic-page globals that don’t exist inside a modern SPFx web part. Reaching for plain fetch() inside SPFx and hitting a wall on either of those is the tell that it’s the wrong tool there — SPHttpClient from the SPFx context is the equivalent, and it removes the manual digest handling entirely:
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
// GET
this.context.spHttpClient.get(
`${this.context.pageContext.web.absoluteUrl}/_api/web/lists/getbytitle('Tasks')/items`,
SPHttpClient.configurations.v1
).then((response: SPHttpClientResponse) => response.json())
.then((data) => console.log(data.value));
// POST -- no manual digest lookup, SPHttpClient handles it
this.context.spHttpClient.post(
`${this.context.pageContext.web.absoluteUrl}/_api/web/lists/getbytitle('Tasks')/items`,
SPHttpClient.configurations.v1,
{
headers: { 'Content-type': 'application/json;odata=verbose' },
body: JSON.stringify({
'__metadata': { type: 'SP.Data.TasksListItem' },
Title: 'New task from a POST request'
})
}
).then((response: SPHttpClientResponse) => {
if (!response.ok) { console.error('Request failed:', response.statusText); }
});
Same underlying REST endpoint and payload shape as the raw-fetch() version above — SPHttpClient is a thin wrapper, not a different API. What it actually buys you: automatic digest handling (no more re-fetching from /_api/contextinfo when it expires) and authentication that’s already scoped correctly to the SPFx context, so there’s no cookie/page-context dependency to worry about at all.
Updating an item with MERGE
Updating an existing item uses the same POST setup with two additions: a `MERGE` value in the `X-HTTP-Method` header (SharePoint’s REST API doesn’t accept a literal `PATCH` verb directly, it overrides POST via this header instead) and an `IF-MATCH` header to handle concurrent-edit conflicts:
function updateListItem(itemId, newTitle) {
const siteUrl = _spPageContextInfo.webAbsoluteUrl;
const endpoint = `${siteUrl}/_api/web/lists/getbytitle('Tasks')/items(${itemId})`;
const digest = document.getElementById("__REQUESTDIGEST").value;
fetch(endpoint, {
method: "POST",
headers: {
"Accept": "application/json;odata=nometadata",
"Content-Type": "application/json;odata=verbose",
"X-RequestDigest": digest,
"X-HTTP-Method": "MERGE",
"IF-MATCH": "*"
},
body: JSON.stringify({ "Title": newTitle })
}).then(response => {
if (!response.ok) { throw new Error(`Update failed: ${response.status}`); }
console.log("Updated");
});
}
`IF-MATCH: “*”` tells SharePoint to overwrite regardless of whether the item changed since it was last read — worth replacing the `*` with the item’s actual `__metadata.etag` value (captured from a prior GET) when the update genuinely needs to fail rather than silently overwrite someone else’s more recent edit.
Handling a 429 instead of just failing
None of the examples above handle throttling, and at real volume (a bulk update loop, a busy tenant) SharePoint will eventually return a 429 instead of a normal response. The response includes a Retry-After header telling you exactly how many seconds to wait — SharePoint’s default is 120 seconds, but always read the actual header value rather than hardcoding that number, since it can vary:
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status !== 429) {
return response;
}
if (attempt === maxRetries) {
throw new Error("Still throttled after max retries");
}
const retryAfterSeconds = parseInt(response.headers.get("Retry-After") || "120", 10);
console.warn(`Throttled. Waiting ${retryAfterSeconds}s before retry ${attempt + 1}/${maxRetries}`);
await new Promise(resolve => setTimeout(resolve, retryAfterSeconds * 1000));
}
}
Ignoring the 429 and retrying immediately (or not retrying at all) is the wrong move either way — immediate retries make the throttling worse, since SharePoint is already telling you to back off. Worth testing this path deliberately rather than hoping it works the first time it matters in production: appending ?Test429=true to a request URL forces SharePoint to return a simulated 429 with a real Retry-After header, which is the actual documented way to verify retry logic without waiting for genuine throttling to happen.
Any request that changes data needs an X-RequestDigest header, or SharePoint rejects it with a 403 — this is the part a generic REST tutorial won’t tell you.
When would you actually use this?
- You’re writing plain JavaScript inside a SharePoint page (a script editor web part, a classic page) and don’t want to pull in PnPjs for a single request.
- You’re debugging why a POST/PATCH request keeps failing with a 403 — a missing or expired
X-RequestDigestis the most common cause. - You need to understand what a library like PnPjs is actually doing under the hood, since it wraps exactly this pattern.
Related reading
- SharePoint REST API File Uploads Using JavaScript and PnPjs — the same digest-and-headers pattern applied to file uploads specifically, including chunked uploads for large files.
- Exploring SharePoint REST API Endpoints — a reference list of the endpoints you’ll actually call once you’re past the basic GET/POST pattern here.
- CSOM, JSOM and REST API in SharePoint: Quick Overview — if you’re deciding whether REST is even the right API for a given project versus CSOM or JSOM.
That covers the core pattern. Let me know in the comments if your setup needs something different.
App Catalog Authentication Automation Backup Compliance Content Type CSS Flows Google Javascript Limitations List Metadata MFA Microsoft Node NodeJs O365 OneDrive Permissions PnP PnPJS Policy PowerApps Power Automate PowerAutomate PowerPlatform PowerShell React ReactJs Rest API Rest Endpoint Security Send an HTTP Request to SharePoint SharePoint SharePoint List SharePoint Modern SharePoint Online SPFX SPO Sync Tags Teams Termstore Versioning


