SharePoint attachments — files uploaded directly onto a list item, not into a document library — are common in forms and ticketing-style lists. Here’s how to create, read, and delete them with plain JavaScript against the REST API, no PnPjs required.
In this post: Uploading an attachment · Reading attachments · Downloading the actual file content · Deleting an attachment · Uploading several files at once · The filename that silently breaks every example above · When would you actually use this? · Related reading
Uploading an attachment
function uploadAttachment(itemId, file) {
var reader = new FileReader();
reader.onload = function (e) {
var arrayBuffer = e.target.result;
fetch(`/sites/mysite/_api/web/lists/getbytitle('MyList')/items(${itemId})/AttachmentFiles/add(FileName='${file.name}')`, {
method: 'POST',
body: arrayBuffer,
headers: {
'Accept': 'application/json;odata=verbose',
'X-RequestDigest': document.getElementById('__REQUESTDIGEST').value,
'Content-Type': file.type
}
})
.then(response => response.json())
.then(data => console.log('File uploaded successfully', data))
.catch(error => console.error('Upload failed', error));
};
reader.readAsArrayBuffer(file);
}
The item already has to exist — attachments are added to an existing list item, not created alongside a new one in the same call.
Reading attachments
function getAttachments(itemId) {
fetch(`/sites/mysite/_api/web/lists/getbytitle('MyList')/items(${itemId})/AttachmentFiles`, {
method: 'GET',
headers: { 'Accept': 'application/json;odata=verbose' }
})
.then(response => response.json())
.then(data => console.log(data.d.results))
.catch(error => console.error('Error retrieving attachments', error));
}
Each entry in the returned array includes a ServerRelativeUrl — that’s the actual download link for the file, not just metadata about it.
Downloading the actual file content
The ServerRelativeUrl from the read example above is a link, not the file itself — getting the actual bytes (to preview it, re-upload it somewhere else, or hand it to the user as a download) needs a second request against that URL, read as a blob rather than JSON:
async function downloadAttachment(serverRelativeUrl, fileName) {
const response = await fetch(
`/sites/mysite/_api/web/getfilebyserverrelativeurl('${serverRelativeUrl}')/$value`,
{ headers: { 'Accept': 'application/json;odata=verbose' } }
);
const blob = await response.blob();
// Trigger a browser download
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
window.URL.revokeObjectURL(url);
}
One thing worth knowing before relying on this for anything beyond a simple download button: attachments don’t carry version history the way document library files do — there’s no equivalent of “get an older version” for a list item attachment. Replacing one means deleting the old one and adding a new one under the same file name, not overwriting in place, and once it’s deleted (not just replaced) there’s no built-in way to recover a specific prior version the way there is for a library document.
Deleting an attachment
function deleteAttachment(itemId, fileName) {
fetch(`/sites/mysite/_api/web/lists/getbytitle('MyList')/items(${itemId})/AttachmentFiles/getByFileName('${fileName}')`, {
method: 'DELETE',
headers: {
'Accept': 'application/json;odata=verbose',
'X-RequestDigest': document.getElementById('__REQUESTDIGEST').value
}
})
.then(() => console.log('Attachment deleted successfully'))
.catch(error => console.error('Error deleting attachment', error));
}
Uploading several files at once
A loop that awaits each upload one at a time works, but it’s not actually faster than doing them individually — each request still waits for the previous one to finish before starting. Running them concurrently with Promise.all is the real improvement, since the requests overlap instead of queueing:
function uploadAttachmentAsync(itemId, file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
fetch(`/sites/mysite/_api/web/lists/getbytitle('MyList')/items(${itemId})/AttachmentFiles/add(FileName='${file.name}')`, {
method: 'POST',
body: e.target.result,
headers: {
'Accept': 'application/json;odata=verbose',
'X-RequestDigest': document.getElementById('__REQUESTDIGEST').value,
'Content-Type': file.type
}
})
.then(response => response.json())
.then(resolve)
.catch(reject);
};
reader.readAsArrayBuffer(file);
});
}
async function uploadAllAttachments(itemId, files) {
const results = await Promise.all(
files.map(file => uploadAttachmentAsync(itemId, file))
);
console.log(`Uploaded ${results.length} files`);
return results;
}
Worth a caution: firing dozens of concurrent requests risks SharePoint’s throttling limits kicking in. For a handful of files (a form with 3-5 attachments) this is fine as-is; for genuinely large batches, cap the concurrency (process in chunks of 5-10 rather than all at once) instead of firing everything simultaneously.
The filename that silently breaks every example above
Every example so far drops file.name straight into the URL string: AttachmentFiles/add(FileName='${file.name}'). That works fine right up until someone uploads a file literally named O'Brien Resume.pdf or Client's Contract.docx — the apostrophe in the filename terminates the single-quoted OData string literal early, and the request fails with a syntax error that has nothing obviously to do with the actual filename. This isn’t specific to attachments; it’s the same OData string-literal rule covered in How to Escape Apostrophes in SharePoint REST Queries, but it shows up here specifically because a file name is exactly the kind of user-supplied string likely to contain one.
The fix is the standard OData escape — double any literal single quote in the filename before it goes into the URL string:
function escapeODataString(value) {
return value.replace(/'/g, "''");
}
// Use it wherever file.name goes into a URL literal:
const safeFileName = escapeODataString(file.name);
fetch(`/sites/mysite/_api/web/lists/getbytitle('MyList')/items(${itemId})/AttachmentFiles/add(FileName='${safeFileName}')`, {
// ...same as the upload example above
});
Every code sample in this post that interpolates file.name or a filename variable directly into a URL string — upload, delete, and the bulk-upload loop — needs this same escaping, not just the first one. It’s easy to fix it in one place and assume it’s handled everywhere, when it actually needs to happen at every point a raw filename reaches a URL.
A loop that awaits each upload one at a time isn’t actually faster than doing them individually — Promise.all is what makes the requests genuinely overlap.
When would you actually use this?
- You’re building a custom form (a ticketing system, an intake form) where attachments belong to a specific list item rather than a shared document library.
- You need file operations working in a plain JavaScript environment without pulling in PnPjs for just this one feature.
- A migration or integration script needs to move attachments between list items or environments programmatically.
Related reading
- Methods on How to Add Attachments to SharePoint List — more approaches to the same problem, including non-REST options.
- SharePoint Attachments Operations with PnP.js in React — the same operations via PnPjs instead of raw REST, if that fits your project better.
- How to Escape Apostrophes in SharePoint REST Queries — the general form of the filename-escaping issue covered above, for anywhere else OData string literals show up.
That covers create, read, delete, and doing it in bulk. What’s your setup using this for? Comment below.
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


