Creating Multiple SharePoint List Items Using SharePoint REST API

Creating many list items in one call means the same SharePoint REST `$batch` endpoint covered from the PnPjs side in SharePoint Data Handling with PnP Batching Queries — worth reading there for the wire-format details and the real 100-request cap, both of which apply here too. This post stays on the raw `fetch`-based approach specifically, for a page or script that doesn’t have PnPjs available, plus the parts a create-focused batch needs that an update-focused one doesn’t: getting new item IDs back, and getting the entity type right instead of guessing it.

In this post: The batch request shape · Sending it with fetch · Getting the new item IDs back · Stop guessing the entity type · Authentication: a real correction · Related reading


The batch request shape

Each item to create becomes its own part inside a `multipart/mixed` body, separated by a shared boundary string:

--batch_xyz123
Content-Type: application/http
Content-Transfer-Encoding: binary

POST https://yourtenant.sharepoint.com/sites/yoursite/_api/web/lists/getbytitle('YourList')/items
Accept: application/json;odata=nometadata
Content-Type: application/json;odata=verbose

{
    "__metadata": { "type": "SP.Data.YourListItem" },
    "Title": "Item 1",
    "Field1": "Value1"
}

--batch_xyz123--

Worth using `odata=nometadata` on the `Accept` header for the response specifically — verbose is fine for a one-off test where readable output matters, but nometadata is the recommended choice for anything production-facing, since it drops a meaningful amount of payload bloat verbose includes by default, which matters more than it sounds like once a batch is carrying dozens of items’ worth of responses in one call. The request body’s `__metadata` block is a separate concern from the response format and still needs the item’s actual entity type, covered below.


Sending it with fetch
async function createMultipleItems(siteUrl, listName, items, requestDigest) {
    const batchBoundary = "batch_" + new Date().getTime();
    const parts = items.map(item => (
        `--${batchBoundary}\r\n` +
        `Content-Type: application/http\r\n` +
        `Content-Transfer-Encoding: binary\r\n\r\n` +
        `POST ${siteUrl}/_api/web/lists/getbytitle('${listName}')/items HTTP/1.1\r\n` +
        `Accept: application/json;odata=nometadata\r\n` +
        `Content-Type: application/json;odata=verbose\r\n\r\n` +
        `${JSON.stringify(item)}\r\n`
    )).join("");
    const batchBody = parts + `--${batchBoundary}--`;

    const response = await fetch(`${siteUrl}/_api/$batch`, {
        method: "POST",
        headers: {
            "Content-Type": `multipart/mixed; boundary="${batchBoundary}"`,
            "Accept": "application/json;odata=nometadata",
            "X-RequestDigest": requestDigest
        },
        body: batchBody
    });

    if (!response.ok) {
        throw new Error(`Batch request failed: ${response.status} ${response.statusText}`);
    }
    return response.text();
}

Worth flagging directly since the original example omitted it: a `POST` against SharePoint’s REST API needs an `X-RequestDigest` header with a valid form digest value, or the request fails with a 403 before it ever reaches the batch logic — worth fetching via `/_api/contextinfo` ahead of the batch call if the page itself doesn’t already have one available (e.g., `_spPageContextInfo.formDigestValue` inside a SharePoint-hosted page).


A batch create response doesn’t hand back new item IDs as neatly as a single POST does — they’re buried inside a raw multipart text body that needs its own parsing, not a plain response.json() call.

Getting the new item IDs back

Worth a direct correction to the original’s `response.json()` call: a `multipart/mixed` batch response isn’t a single JSON object — it’s the same multipart text format as the request, with each sub-response as its own part. Reading `.text()` and splitting on the response boundary is the real pattern, not calling `.json()` directly on the whole body:

const raw = await createMultipleItems(siteUrl, listName, items, digest);
const responseBoundaryMatch = raw.match(/--batchresponse_[a-z0-9-]+/i);
const parts = raw.split(responseBoundaryMatch[0]).filter(p => p.includes("HTTP/1.1"));

const createdIds = parts.map(part => {
    const jsonStart = part.indexOf("{");
    if (jsonStart === -1) return null;
    const body = JSON.parse(part.slice(jsonStart));
    return body.Id ?? body.d?.Id ?? null;
}).filter(id => id !== null);

console.log("Created item IDs:", createdIds);

The exact JSON shape (`body.Id` vs. the older `body.d.Id`) depends on whether the response came back as nometadata or verbose — worth confirming against the actual response in a test call rather than assuming one shape, since the two formats genuinely nest the item differently. Worth checking each part’s own HTTP status line too, not just assuming success because the outer batch request itself returned 200 — a batch can come back 200 overall while individual parts inside it failed, with the failure only visible in that part’s own `HTTP/1.1 4xx` or `5xx` status line, not reflected in the outer response code at all.


Stop guessing the entity type

`”SP.Data.YourListItem”` in the `__metadata` block is a placeholder, not a naming pattern to reproduce by hand — guessing it wrong is a common, real source of a batch failing on every single item with an unhelpful error. The actual value is list-specific and derived from the list’s internal name (spaces removed, list-specific casing), and the reliable way to get it right is to look it up rather than guess:

const listInfo = await fetch(
    `${siteUrl}/_api/web/lists/getbytitle('${listName}')?$select=ListItemEntityTypeFullName`,
    { headers: { "Accept": "application/json;odata=nometadata" } }
).then(r => r.json());

const entityType = listInfo.ListItemEntityTypeFullName; // Use this, not a guessed string

Worth doing this lookup once and reusing the value across a whole batch run, rather than re-fetching it per item — it doesn’t change between calls in the same session.


Authentication: a real correction

Worth correcting directly: ADAL (Azure AD Authentication Library) is not a current option — Microsoft ended all support and security fixes for it back in mid-2023, and as of February 2025, tenants relying on ADAL for sign-in stopped being able to authenticate with it at all. MSAL (Microsoft Authentication Library) is the only current, supported client-side library for acquiring the token this kind of call needs. For a script running inside a SharePoint page itself (an SPFx web part, for instance), the page’s own ambient authentication and `X-RequestDigest` typically cover this without a separate library at all — MSAL specifically matters for a script or app running outside that context, calling in from elsewhere.



The raw `fetch`-based approach here is worth knowing for environments where PnPjs genuinely isn’t an option, but it’s also a fair amount more code to get right — digest handling, response parsing, entity-type lookup — for the same underlying operation PnPjs handles in a few lines. Worth defaulting to the library where it’s available, and reaching for this only when it isn’t, rather than hand-rolling the multipart plumbing on a project that could just add the dependency instead.

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

Leave a Comment

Your email address will not be published. Required fields are marked *