Live training: next batches coming soon. Join the waitlist:Waitlists open:Developer Training →Developer →Copilot Studio Training →Copilot Studio →

SharePoint REST API CRUD Operations: List Items and Folders (Tested Examples)

An operations manager asked me last month for a small page that adds tasks to a SharePoint list, moves them along, and files client paperwork into folders. No Power Apps license, no flow, just a few calls to SharePoint. That’s exactly what the REST API is for.

The trouble is that most samples online are SharePoint 2013 jQuery code pasted into a Script Editor web part. On a modern site that web part isn’t available, the verbose __metadata type is easy to get wrong, and a missing digest or ETag gives you a 403 or a 412 instead of a clear error.

In this tutorial, we’ll run all four sharepoint rest api crud operations on a real list: create, read, update with MERGE and IF-MATCH, and delete. Then we’ll create folders in a document library and in a list, rename a folder and delete it, with the exact requests and responses from my tenant.

Quick answer: Get a digest with POST /_api/contextinfo, then call /_api/web/lists/getbytitle('List')/items: POST to create, GET to read, POST with X-HTTP-Method: MERGE and IF-MATCH to update, and POST with X-HTTP-Method: DELETE to delete. For a folder, POST {"ServerRelativeUrl": "/sites/Site/Library/Folder"} to /_api/web/folders.

What You Need Before the First Request

I’m working on https://spguides.sharepoint.com/sites/SPGuides with a list called SPGDemo Tasks (Title, Assigned To, Status, Due Date) and a document library called SPGDemo Client Files. If you’re new to endpoints and OData, my SharePoint REST API tutorial covers the basics.

Every example below runs from the browser console on a page of the same site, so the call uses your signed-in session. Reads need nothing extra. Writes need a request digest, which you get with an empty POST to /_api/contextinfo:

const siteUrl = "https://spguides.sharepoint.com/sites/SPGuides";

const ctx = await fetch(siteUrl + "/_api/contextinfo", {
  method: "POST",
  headers: { "Accept": "application/json;odata=nometadata" }
});
const info = await ctx.json();
const digest = info.FormDigestValue;   // send it as X-RequestDigest on every write

The response carries the digest and how long it lasts. In my tenant it’s 1,800 seconds, so refresh it every 30 minutes in a long-running page:

{
  "FormDigestTimeoutSeconds": 1800,
  "FormDigestValue": "0x22C9A4AE5E...B633CEA407E85,24 Oct 2026 06:51:41 -0000",
  "LibraryVersion": "16.0.x",
  ...
}

Leave the digest out and SharePoint answers with a 403 and the message in my post on the security validation for this page is invalid. Calls that carry an OAuth bearer token, such as PnP PowerShell or an app, don’t need it.

OperationMethodEndpointExtra headersSuccess
Create itemPOST/itemsX-RequestDigest201 + item JSON
Read itemsGET/items, /items(id)none200
Update itemPOST/items(id)X-RequestDigest, X-HTTP-Method: MERGE, IF-MATCH204, no body
Delete itemPOST/items(id)X-RequestDigest, X-HTTP-Method: DELETE, IF-MATCH200, no body
Create folderPOST/_api/web/foldersX-RequestDigest201 + folder JSON

Create a List Item (POST)

To add an item, POST the column values to the list’s items collection. Use internal column names, not display names: my Status column is TaskStatus and Assigned To is AssignedToName. With odata=nometadata you don’t need a __metadata block at all:

const res = await fetch(siteUrl + "/_api/web/lists/getbytitle('SPGDemo Tasks')/items", {
  method: "POST",
  headers: {
    "Accept": "application/json;odata=nometadata",
    "Content-Type": "application/json;odata=nometadata",
    "X-RequestDigest": digest
  },
  body: JSON.stringify({
    Title: "Prepare Q4 budget review",
    AssignedToName: "Megan Bowen",
    TaskStatus: "In Progress",
    DueDate: "2026-11-13"
  })
});
console.log(res.status, res.headers.get("ETag"), await res.json());

SharePoint returns 201 Created, the new item’s ETag, and the full item. I ran it four times for Megan Bowen, Alex Wilber, Adele Vance and Lee Gu, and they got IDs 1 to 4:

201 "78178181-a659-46c5-bb18-63106ac2bdd5,1"
{
  "Id": 1,
  "Title": "Prepare Q4 budget review",
  "AssignedToName": "Megan Bowen",
  "TaskStatus": "In Progress",
  "DueDate": "2026-11-13T08:00:00Z",
  "Created": "2026-10-24T06:51:42Z",
  ...
}

Notice the due date. I sent 2026-11-13 and got back 08:00:00Z, because a date without a time is read in the site’s time zone (Pacific here) and returned in UTC.

Older samples use odata=verbose, which also works, but then the body must name the list’s entity type. That name escapes spaces, so SPGDemo Tasks becomes SP.Data.SPGDemo_x0020_TasksListItem. Look it up instead of guessing; I’ve used the same pattern in SPFx CRUD operations without a framework:

// odata=verbose needs the list's entity type in __metadata
// GET .../lists/getbytitle('SPGDemo Tasks')?$select=ListItemEntityTypeFullName
//   -> {"ListItemEntityTypeFullName":"SP.Data.SPGDemo_x0020_TasksListItem"}
await fetch(siteUrl + "/_api/web/lists/getbytitle('SPGDemo Tasks')/items", {
  method: "POST",
  headers: {
    "Accept": "application/json;odata=verbose",
    "Content-Type": "application/json;odata=verbose",
    "X-RequestDigest": digest
  },
  body: JSON.stringify({
    __metadata: { type: "SP.Data.SPGDemo_x0020_TasksListItem" },
    Title: "Book Q1 planning room", AssignedToName: "Lee Gu", TaskStatus: "Not Started", DueDate: "2026-12-10"
  })
});   // 201, item 5

Read List Items (GET)

Reading is a plain GET, and you can paste the URL straight into the browser address bar. Ask for one item by ID and pick the columns with $select:

GET https://spguides.sharepoint.com/sites/SPGuides/_api/web/lists/getbytitle('SPGDemo Tasks')/items(2)?$select=Id,Title,TaskStatus
Accept: application/json;odata=nometadata
ETag: "51f8ba0a-ef42-4a01-a4aa-453074a3a9ad,1"
{ "Id": 2, "Title": "Renew vendor contracts", "TaskStatus": "Not Started", "ID": 2 }

Keep that ETag header; the update step uses it. To find items, add $filter, $orderby and $top. The filter syntax is the same OData you’d use in the Power Automate OData filter query:

GET https://spguides.sharepoint.com/sites/SPGuides/_api/web/lists/getbytitle('SPGDemo Tasks')/items?$select=Id,Title,AssignedToName,TaskStatus&$filter=TaskStatus eq 'Not Started'&$orderby=DueDate&$top=10
Accept: application/json;odata=nometadata
{
  "value": [
    { "Id": 3, "Title": "Onboard new sales hires", "AssignedToName": "Adele Vance", "TaskStatus": "Not Started", "ID": 3 },
    { "Id": 5, "Title": "Book Q1 planning room", "AssignedToName": "Lee Gu", "TaskStatus": "Not Started", "ID": 5 }
  ]
}

Two limits trip people up. The items endpoint returns only the first 100 items unless you set $top, and $skip doesn’t work on list items, so page with the __next link ($skiptoken). I walk through that loop in REST API pagination for SharePoint list items.

Update a List Item (MERGE and IF-MATCH)

An update is a POST to the item with two extra headers. X-HTTP-Method: MERGE changes only the columns you send. IF-MATCH carries the ETag you read, so you don’t overwrite someone else’s newer edit:

const item = siteUrl + "/_api/web/lists/getbytitle('SPGDemo Tasks')/items(2)";
const etag = (await fetch(item, { headers: { "Accept": "application/json;odata=nometadata" } })).headers.get("ETag");

const res = await fetch(item, {
  method: "POST",
  headers: {
    "Accept": "application/json;odata=nometadata",
    "Content-Type": "application/json;odata=nometadata",
    "X-RequestDigest": digest,
    "X-HTTP-Method": "MERGE",
    "IF-MATCH": etag            // or "*" to overwrite whatever version is there
  },
  body: JSON.stringify({ TaskStatus: "In Progress", DueDate: "2026-11-24" })
});
console.log(res.status, res.headers.get("ETag"));
204 "51f8ba0a-ef42-4a01-a4aa-453074a3a9ad,2"

A 204 with an empty body means it worked, and the ETag version went from 1 to 2. Then I sent the same request again with the old ETag, the way a second browser tab would, and SharePoint refused it:

412
{"odata.error":{"code":"-1, Microsoft.SharePoint.Client.ClientServiceException","message":{"lang":"en-US",
"value":"The request ETag value '\"51f8ba0a-ef42-4a01-a4aa-453074a3a9ad,1\"' does not match the object's ETag value '\"51f8ba0a-ef42-4a01-a4aa-453074a3a9ad,2\"'."}}}

That 412 is your concurrency check. Re-read the item, show the user the newer values, and try again. If you really want last-write-wins, send IF-MATCH: *; leaving the header out also overwrites.

SPGDemo Tasks list after the REST API calls with the West Region folder and Renew vendor contracts set to In Progress

Here’s the list after the create and update calls. Renew vendor contracts now shows In Progress and 11/24/2026, and the West Region folder comes from a later section.

Delete a List Item (DELETE)

Deleting uses the same pattern with X-HTTP-Method: DELETE. I removed item 4, the Order team lunch task:

const res = await fetch(siteUrl + "/_api/web/lists/getbytitle('SPGDemo Tasks')/items(4)", {
  method: "POST",
  headers: {
    "Accept": "application/json;odata=nometadata",
    "X-RequestDigest": digest,
    "X-HTTP-Method": "DELETE",
    "IF-MATCH": "*"
  }
});
console.log(res.status);   // 200, empty body

A later GET for the same ID confirms it’s gone:

GET .../items(4)  ->  404
{"odata.error":{"code":"-2130575338, System.ArgumentException","message":{"lang":"en-US","value":"Item does not exist. It may have been deleted by another user."}}}

One thing surprised a client of mine: this DELETE is permanent. Item 4 never showed up in the site recycle bin. If users might want it back, call recycle() instead, which moves it to the recycle bin and returns its recycle bin ID:

POST .../_api/web/lists/getbytitle('SPGDemo Tasks')/items(4)/recycle()
X-RequestDigest: <digest>

Create a Folder in a SharePoint Document Library With the REST API

This is the request most people search for. POST the folder’s server-relative path to /_api/web/folders. If you just need a folder by hand, see how to create a folder in SharePoint from the browser instead.

const res = await fetch(siteUrl + "/_api/web/folders", {
  method: "POST",
  headers: {
    "Accept": "application/json;odata=nometadata",
    "Content-Type": "application/json;odata=nometadata",
    "X-RequestDigest": digest
  },
  body: JSON.stringify({ ServerRelativeUrl: "/sites/SPGuides/SPGDemo Client Files/2026 Contracts" })
});
console.log(res.status, await res.json());
201
{
  "Exists": true,
  "ItemCount": 0,
  "Name": "2026 Contracts",
  "ServerRelativeUrl": "/sites/SPGuides/SPGDemo Client Files/2026 Contracts",
  "TimeCreated": "2026-10-24T06:52:15Z",
  "UniqueId": "03a7866b-4f79-4fbe-9f2c-ba036960be0b",
  ...
}

Watch out here: I sent the same request a second time and got 201 again with the same TimeCreated. SharePoint didn’t create a duplicate and didn’t throw an error either, so don’t treat 201 as “new folder”.

You’ll also see the older folders/add form in many posts. It still works and is handy when you already have the library’s root folder:

POST .../_api/web/lists/getbytitle('SPGDemo Client Files')/rootfolder/folders/add('Invoices')
Accept: application/json;odata=nometadata
X-RequestDigest: <digest>

-> 200 { "Name": "Invoices", "ServerRelativeUrl": "/sites/SPGuides/SPGDemo Client Files/Invoices", ... }

To check first, call Exists on the path. Both answers come back as 200, so read the value, not the status code. The PnP way is in check if a folder exists in SharePoint Online.

GET .../_api/web/GetFolderByServerRelativeUrl('/sites/SPGuides/SPGDemo Client Files/Invoices')/Exists
-> 200 {"value":true}

GET .../_api/web/GetFolderByServerRelativeUrl('/sites/SPGuides/SPGDemo Client Files/Receipts')/Exists
-> 200 {"value":false}
SPGDemo Client Files library with the 2026 Vendor Contracts and Invoices folders created by the REST API

Both folders appear in the library. The first one already has its new name from the rename step below.

Create a Folder in a SharePoint List

Lists can hold folders too, but only when Make “New Folder” command available? is set to Yes in List settings > Advanced settings. Before you add folders everywhere, read my notes on SharePoint folder structure best practices.

SharePoint list Advanced settings with Make New Folder command available set to Yes

The documented way to create a list folder is AddValidateUpdateItemUsingPath with UnderlyingObjectType set to 1. The Title value becomes the folder name:

const res = await fetch(siteUrl + "/_api/web/lists/getbytitle('SPGDemo Tasks')/AddValidateUpdateItemUsingPath", {
  method: "POST",
  headers: {
    "Accept": "application/json;odata=nometadata",
    "Content-Type": "application/json;odata=nometadata",
    "X-RequestDigest": digest
  },
  body: JSON.stringify({
    listItemCreateInfo: {
      FolderPath: { DecodedUrl: siteUrl + "/Lists/SPGDemo Tasks" },
      UnderlyingObjectType: 1          // 1 = folder, 0 = item
    },
    formValues: [ { FieldName: "Title", FieldValue: "West Region" } ],
    bNewDocumentUpdate: false
  })
});
200
{"value":[
  {"ErrorCode":0,"ErrorMessage":null,"FieldName":"Title","FieldValue":"West Region","HasException":false,"ItemId":0},
  {"ErrorCode":0,"ErrorMessage":null,"FieldName":"Id","FieldValue":"6","HasException":false,"ItemId":0}
]}

A 200 with HasException: false on every field means success; the folder got ID 6. Check HasException and ErrorMessage in your code, because a validation failure still returns 200.

To add an item inside that folder, send the same call with the folder’s path and UnderlyingObjectType 0. Values go in as text, the way the list form would validate them, so the date uses the site’s format:

body: JSON.stringify({
  listItemCreateInfo: {
    FolderPath: { DecodedUrl: siteUrl + "/Lists/SPGDemo Tasks/West Region" },
    UnderlyingObjectType: 0
  },
  formValues: [
    { FieldName: "Title", FieldValue: "Visit Seattle client office" },
    { FieldName: "AssignedToName", FieldValue: "Megan Bowen" },
    { FieldName: "TaskStatus", FieldValue: "Not Started" },
    { FieldName: "DueDate", FieldValue: "11/18/2026" }
  ],
  bNewDocumentUpdate: false
})
// -> 200, Id 7, FileRef "/sites/SPGuides/Lists/SPGDemo Tasks/West Region/7_.000"
West Region folder in the SPGDemo Tasks list showing the Visit Seattle client office item

One side effect: a plain GET on /items returns items inside folders as well, plus the folder itself as an item. Filter on FSObjType eq 0 when you only want items.

Rename or Delete a Folder

There’s no rename method on the folder itself. You update its list item through ListItemAllFields and change FileLeafRef, the same field a rename with REST in Power Automate uses:

const folderItem = siteUrl +
  "/_api/web/GetFolderByServerRelativeUrl('/sites/SPGuides/SPGDemo Client Files/2026 Contracts')/ListItemAllFields";

const res = await fetch(folderItem, {
  method: "POST",
  headers: {
    "Accept": "application/json;odata=nometadata",
    "Content-Type": "application/json;odata=nometadata",
    "X-RequestDigest": digest,
    "X-HTTP-Method": "MERGE",
    "IF-MATCH": "*"
  },
  body: JSON.stringify({ FileLeafRef: "2026 Vendor Contracts", Title: "2026 Vendor Contracts" })
});
console.log(res.status);   // 204

It returned 204 and the folder became 2026 Vendor Contracts. With odata=verbose you’d also pass the item type, which for my library is SP.Data.SPGDemo_x0020_Client_x0020_FilesItem.

To remove a folder and keep a way back, use recycle(). I created an Old Drafts folder and recycled it:

POST .../_api/web/GetFolderByServerRelativeUrl('/sites/SPGuides/SPGDemo Client Files/Old Drafts')/recycle()
Accept: application/json;odata=nometadata
X-RequestDigest: <digest>

-> 200 {"value":"8f22b904-6d8d-4bac-9d0b-25d167a5be16"}   // the recycle bin item ID
SPGuides site recycle bin showing the Old Drafts folder recycled by the REST API

For a hard delete, use the DELETE pattern on the folder. Everything inside the folder goes with it:

POST .../_api/web/GetFolderByServerRelativeUrl('/sites/SPGuides/SPGDemo Client Files/Old Drafts')
X-RequestDigest: <digest>
X-HTTP-Method: DELETE
IF-MATCH: *

Run the Same CRUD Calls From PnP PowerShell

For admin scripts, I don’t build digests at all. Invoke-PnPSPRestMethod calls the same endpoints with your PnP connection’s token, accepts a URL relative to the site, and returns parsed JSON. If the cmdlets aren’t on your machine yet, install PnP PowerShell first and connect:

Connect-PnPOnline -Url "https://spguides.sharepoint.com/sites/SPGuides" -ClientId "<your-app-id>" -Interactive

This script creates a task, updates it with MERGE and reads it back:

# Create, update and read a list item with the SharePoint REST API from PnP PowerShell
$items = "/_api/web/lists/getbytitle('SPGDemo Tasks')/items"

# CREATE (POST)
$new = Invoke-PnPSPRestMethod -Method Post -Url $items -Content @{
    Title          = "Plan holiday coverage"
    AssignedToName = "Lee Gu"
    TaskStatus     = "Not Started"
    DueDate        = "2026-12-15"
}
"Created item ID: $($new.Id)"

# UPDATE (MERGE) - only the columns you send change
$body = @{ TaskStatus = "In Progress" }
Invoke-PnPSPRestMethod -Method Merge -Url "$items($($new.Id))" -Content $body | Out-Null

# READ (GET)
Invoke-PnPSPRestMethod -Url "$items($($new.Id))?`$select=Id,Title,AssignedToName,TaskStatus" |
    Select-Object Id, Title, AssignedToName, TaskStatus | Format-List
PowerShell 7 console running Invoke-PnPSPRestMethod to create item 9 and update its status to In Progress

And this one deletes it again and lists what’s left, including the West Region folder and the item inside it:

# DELETE the item created above, then list what is left
$items = "/_api/web/lists/getbytitle('SPGDemo Tasks')/items"
$id = (Invoke-PnPSPRestMethod -Url "${items}?`$filter=Title eq 'Plan holiday coverage'&`$select=Id").value[0].Id

Invoke-PnPSPRestMethod -Method Delete -Url "$items($id)" | Out-Null
"Deleted item ID: $id"

(Invoke-PnPSPRestMethod -Url "${items}?`$select=Id,Title,TaskStatus&`$orderby=Id").value |
    Format-Table Id, Title, TaskStatus -AutoSize
PowerShell 7 console deleting item 9 with Invoke-PnPSPRestMethod and listing the remaining SPGDemo Tasks items

In PowerShell 7, write ${items}? rather than $items?, because the question mark is a legal variable-name character there. If you’d rather skip REST, the PnP PowerShell list item CRUD cmdlets do the same jobs.

Where I Use These Calls on Real Projects

Common Errors and How to Fix Them

Status and messageCauseFix
403 The security validation for this page is invalidNo X-RequestDigest, an expired one, or one from another sitePOST to /_api/contextinfo on the same site and resend
400 A type named ‘SP.Data…’ could not be resolvedWrong __metadata type with odata=verboseRead ListItemEntityTypeFullName, or switch to odata=nometadata
400 The property ‘X’ does not existDisplay name instead of internal nameGet internal names from /fields?$select=Title,InternalName
412 The request ETag value … does not matchSomeone changed the item after you read itRe-read the item and retry, or send IF-MATCH: *
404 Item does not existWrong ID, or the item was deletedCheck the ID; remember DELETE skips the recycle bin
Folder call returns 201 but nothing new appearsThe folder already existedCheck Exists first if a duplicate means a mistake
Names with % or # failThese endpoints don’t support those charactersUse the ResourcePath APIs, such as GetFolderByServerRelativePath(decodedurl=...)

If you’re scripting more SharePoint content, these step-by-step guides help next:

Frequently Asked Questions

What are CRUD operations in the SharePoint REST API?

Create, read, update and delete. You POST to /items to create, GET /items or /items(id) to read, POST with X-HTTP-Method: MERGE to update and POST with X-HTTP-Method: DELETE to delete. Writes from a browser session also need an X-RequestDigest.

How do I create a folder in SharePoint using the REST API?

POST {"ServerRelativeUrl": "/sites/SPGuides/Shared Documents/New Folder"} to /_api/web/folders with an X-RequestDigest header. A 201 response returns the folder. Microsoft’s folders and files REST reference also covers rename and delete.

How do I get the X-RequestDigest value in SharePoint?

Send an empty POST to https://yoursite/_api/contextinfo and read FormDigestValue from the response. It’s valid for 1,800 seconds by default, so request a new one before it expires.

What does IF-MATCH do in a SharePoint REST update?

It carries the item’s ETag. If someone changed the item since you read it, SharePoint returns 412 instead of overwriting their edit. Send * to overwrite any version, as the Microsoft Learn list item REST reference describes.

Does the REST API DELETE request send items to the recycle bin?

No. In my test, an item deleted with X-HTTP-Method: DELETE didn’t appear in the site recycle bin. Call /items(id)/recycle() or GetFolderByServerRelativeUrl(...)/recycle() when you want it to be recoverable.