For AI agents: markdown of this page — /docs-content-en/recipes/disk-files.md documentation index — /llms.txt
Working with Drive files
Difficulty: beginner | Scopes: disk | Stack: cURL / JavaScript
Arrange documents on Bitrix24 Drive: find the storage you need, build a folder tree inside it, upload a file and download it back. The same sequence works for scheduled report exports and for receiving attachments from an external system.
What you need
- A Vibecode API key with the
diskscope - Node.js 18 or newer
In all examples $VIBE_URL is the base address https://vibecode.bitrix24.com, and $VIBE_API_KEY is your API key.
How the solution works
- Find the storage and its root folder — the whole Drive tree starts there.
- Look at the folder content, separating subfolders and files.
- Create a folder for the task, nested if needed.
- Upload a file into that folder.
- Download the file back.
- Rename, delete what is no longer needed and assemble an overview of several folders in a single batch request.
The step examples show individual calls. The ready-to-run script is in the "Full code" section.
Step 1. The storage and its root folder
The list of storages is returned by GET /v1/storages. The storage type is set by the entityType field: user — an employee's personal drive, group — a workgroup drive, common — the Bitrix24 account's common drive. A filter on that field immediately narrows the list to the type you need.
The root folder of a storage is given by the rootFolderId field. It is the only entry point into the tree: all further folder and file requests start from a folder identifier, not from a storage identifier.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" \
"$VIBE_URL/v1/storages?filter[entityType]=common"
JavaScript
const res = await fetch(`${VIBE_URL}/v1/storages?filter[entityType]=common`, {
headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data } = await res.json()
const rootFolderId = data[0].rootFolderId
{
"success": true,
"data": [
{
"id": 11,
"name": "Common drive",
"code": null,
"module": "disk",
"entityType": "common",
"entityId": "shared_files_s1",
"rootFolderId": 19
}
],
"meta": { "total": 1, "hasMore": false }
}
The entityId field of the common drive is a non-numeric string, so there is no need to cast it to a number.
Step 2. Folder content
GET /v1/folders returns the content of the folder given in parentId. The parameter is required: without it the request returns 400 MISSING_REQUIRED_PARAMS.
The output is mixed — it contains both subfolders and files. The type field tells them apart: folder or file.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" \
"$VIBE_URL/v1/folders?parentId=19"
JavaScript
const res = await fetch(`${VIBE_URL}/v1/folders?parentId=${rootFolderId}`, {
headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data } = await res.json()
const folders = data.filter(item => item.type === 'folder')
const files = data.filter(item => item.type === 'file')
{
"success": true,
"data": [
{
"id": 611,
"name": "Contracts",
"code": null,
"storageId": 11,
"type": "folder",
"realObjectId": 611,
"parentId": 19,
"deletedType": 0,
"createdAt": "2026-06-08T14:36:45.000Z",
"updatedAt": "2026-07-10T15:03:12.000Z",
"deletedAt": null,
"createdBy": 1,
"updatedBy": 1,
"deletedBy": null,
"detailUrl": "https://your-portal.bitrix24.com/docs/path/Contracts"
}
],
"meta": { "total": 13, "hasMore": false }
}
The same folder content is returned by GET /v1/files?folderId=19. There the parent folder is called folderId, and that parameter is required too. File records there also carry the file size in size, the fileId identifier and the downloadUrl link.
Step 3. A folder for the task
POST /v1/folders creates a folder. The body needs two fields: parentId and name. The response returns the created folder, and its id becomes the parentId for the next level — that is how any tree is assembled.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/folders" \
-d '{ "parentId": 19, "name": "Client documents" }'
JavaScript
async function createFolder(parentId, name) {
const res = await fetch(`${VIBE_URL}/v1/folders`, {
method: 'POST',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ parentId, name }),
})
const { data } = await res.json()
return data
}
const clients = await createFolder(rootFolderId, 'Client documents')
const month = await createFolder(clients.id, '2026-07')
{
"success": true,
"data": {
"id": 9415,
"name": "Client documents",
"code": null,
"storageId": 11,
"type": "folder",
"realObjectId": 9415,
"parentId": 19,
"deletedType": 0,
"createdAt": "2026-07-21T08:55:35.000Z",
"updatedAt": "2026-07-21T08:55:35.000Z",
"deletedAt": null,
"createdBy": 1,
"updatedBy": 1,
"deletedBy": null,
"detailUrl": "https://your-portal.bitrix24.com/docs/path/Client documents"
}
}
A folder is renamed by PATCH /v1/folders/:id with the single name field. Moving to another parent folder is a separate operation, POST /v1/folders/:id/moveto.
Step 4. Uploading a file
POST /v1/files/upload puts a file into a folder. The content is passed as a Base64-encoded string in the content field, the name with the extension in filename, the destination folder in folderId. Instead of folderId you can pass storageId, then the file lands in the storage root.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/files/upload" \
-d '{
"folderId": 9415,
"filename": "report.txt",
"content": "UmVwb3J0IGZvciBKdWx5Cg=="
}'
JavaScript
import { readFile } from 'node:fs/promises'
const content = (await readFile('report.txt')).toString('base64')
const res = await fetch(`${VIBE_URL}/v1/files/upload`, {
method: 'POST',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ folderId: clients.id, filename: 'report.txt', content }),
})
const { data: file } = await res.json()
{
"success": true,
"data": {
"id": 9423,
"name": "report.txt",
"code": null,
"storageId": 11,
"type": "file",
"folderId": 9415,
"deletedType": 0,
"globalContentVersion": 1,
"fileId": 36245,
"size": 16,
"createdAt": "2026-07-21T08:58:53.000Z",
"updatedAt": "2026-07-21T08:58:53.000Z",
"deletedAt": null,
"createdBy": 1,
"updatedBy": 1,
"deletedBy": null,
"downloadUrl": "https://your-portal.bitrix24.com/rest/1/WEBHOOK/download/?token=...",
"detailUrl": "https://your-portal.bitrix24.com/docs/file/Client documents/report.txt"
}
}
A successful upload returns 201. The size field in the response is the size of the source file in bytes, not the length of the Base64 string. Comparing size with the size of the sent file confirms that the content arrived in full. If the body has no filename or content, and also if neither folderId nor storageId is given, the request returns 400 MISSING_PARAMS.
{
"success": false,
"error": {
"code": "MISSING_PARAMS",
"message": "filename and content (base64) are required."
}
}
Step 5. Downloading a file
GET /v1/files/:id/download returns the file content directly: the response body is the file itself, and the file name is returned in the Content-Disposition header. The key is passed in the same X-Api-Key header as in all other calls, so no separate authorization for downloading is needed.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" \
-o report.txt \
"$VIBE_URL/v1/files/9423/download"
JavaScript
import { writeFile } from 'node:fs/promises'
const res = await fetch(`${VIBE_URL}/v1/files/${file.id}/download`, {
headers: { 'X-Api-Key': VIBE_API_KEY },
})
await writeFile('report.txt', Buffer.from(await res.arrayBuffer()))
Step 6. Renaming and deleting
A file is renamed by PATCH /v1/files/:id with the name field. Deleting a file and deleting a folder are DELETE /v1/files/:id and DELETE /v1/folders/:id, both operations return 204 with an empty body.
cURL
curl -s -X DELETE -H "X-Api-Key: $VIBE_API_KEY" \
"$VIBE_URL/v1/files/9423"
JavaScript
await fetch(`${VIBE_URL}/v1/files/${file.id}`, {
method: 'DELETE',
headers: { 'X-Api-Key': VIBE_API_KEY },
})
How to check that an object has actually been deleted is covered in the "Limitations" section.
Step 7. An overview of several folders in one request
POST /v1/batch combines up to 50 reads into one request. Every call gets its own id, and the response is keyed by that same id in data.results. A separate data.totals map gives the number of objects in every folder, and data.summary reports how many calls succeeded and how many failed.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/batch" \
-d '{
"calls": [
{ "id": "storage", "entity": "storages", "action": "get", "entityId": 11 },
{ "id": "contracts", "entity": "folders", "action": "list", "params": { "parentId": 611 } },
{ "id": "clients", "entity": "folders", "action": "list", "params": { "parentId": 9415 } }
]
}'
JavaScript
const res = await fetch(`${VIBE_URL}/v1/batch`, {
method: 'POST',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
calls: [
{ id: 'storage', entity: 'storages', action: 'get', entityId: 11 },
{ id: 'contracts', entity: 'folders', action: 'list', params: { parentId: 611 } },
{ id: 'clients', entity: 'folders', action: 'list', params: { parentId: clients.id } },
],
}),
})
const { data } = await res.json()
console.log(data.totals) // { contracts: 10, clients: 1 }
Every call puts its result into data.results under its own id. In the example below the arrays are shortened to a single record.
{
"success": true,
"data": {
"results": {
"storage": {
"id": 11,
"name": "Common drive",
"code": null,
"module": "disk",
"entityType": "common",
"entityId": "shared_files_s1",
"rootFolderId": 19
},
"contracts": [
{
"id": 613,
"name": "2026-06",
"code": null,
"storageId": 11,
"type": "folder",
"realObjectId": 613,
"parentId": 611,
"deletedType": 0,
"createdAt": "2026-06-08T14:36:47.000Z",
"updatedAt": "2026-06-08T14:36:47.000Z",
"deletedAt": null,
"createdBy": 1,
"updatedBy": 1,
"deletedBy": null,
"detailUrl": "https://your-portal.bitrix24.com/docs/path/Contracts/2026-06"
}
],
"clients": [
{
"id": 9417,
"name": "2026-07",
"code": null,
"storageId": 11,
"type": "folder",
"realObjectId": 9417,
"parentId": 9415,
"deletedType": 0,
"createdAt": "2026-07-21T08:55:50.000Z",
"updatedAt": "2026-07-21T08:55:50.000Z",
"deletedAt": null,
"createdBy": 1,
"updatedBy": 1,
"deletedBy": null,
"detailUrl": "https://your-portal.bitrix24.com/docs/path/Client documents/2026-07"
}
]
},
"totals": { "contracts": 10, "clients": 1 },
"errors": {},
"summary": { "total": 3, "succeeded": 3, "failed": 0 },
"meta": {
"contracts": { "total": 10, "returned": 10, "hasMore": false, "truncated": false },
"clients": { "total": 1, "returned": 1, "hasMore": false, "truncated": false }
}
}
}
Reading a single record needs entityId at the top level of the call, not inside params. A get call with the identifier in params returns MISSING_ENTITY_ID, and the whole batch returns INVALID_REQUEST if not a single call passed validation.
Limitations
Deleting moves an object to the recycle bin instead of erasing it. DELETE returns 204, but a GET by the same identifier keeps returning 200. A deleted object gets deletedAt and deletedBy filled in, and deletedType becomes 3 for what was deleted directly and 4 for a nested object that went away together with its parent folder. Checking whether an object is deleted by looking for a 404 will never work — check deletedType instead, or the absence of the record in the folder content list. A repeated DELETE by the same identifier also returns 204. There is no restore-from-recycle-bin operation in the API — an object can be brought back from the Bitrix24 interface.
Folder and file lists are mixed. Both GET /v1/folders?parentId=… and GET /v1/files?folderId=… return the whole folder content — subfolders and files together. Tell the entries apart by the type field.
The parent folder is required. GET /v1/folders without parentId and GET /v1/files without folderId return 400 MISSING_REQUIRED_PARAMS. Walking the tree always starts from the rootFolderId of a storage. A request by a non-existent identifier returns 404 ENTITY_NOT_FOUND.
PATCH changes the name only. For both a folder and a file PATCH accepts the name field. Moving to another folder is done by the separate operations POST /v1/folders/:id/moveto and POST /v1/files/:id/moveto.
Storages are read-only. A personal drive is created together with an employee, a workgroup drive together with a group, and a Bitrix24 account has one common drive. There are no operations to create or change storages.
The size of an uploaded file is limited by the request body. Base64 encoding increases the size by about a third, and the upload request body is limited to 70 MB — that is a file of roughly up to 50 MB. A large file takes longer to upload, so raise the response timeout for such a request and upload large files one by one rather than in a batch. Bitrix24 additionally applies its own limit on the size of a Drive file, and its response is passed through unchanged.
Full code
// drive-upload.js — a folder for the task and a report uploaded into it
import { readFile } from 'node:fs/promises'
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.com'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('Environment variable VIBE_API_KEY is not set')
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
async function api(path, init = {}) {
const res = await fetch(`${VIBE_URL}${path}`, { headers, ...init })
const body = await res.json()
if (!body.success) throw new Error(body.error?.message ?? `error ${res.status}`)
return body.data
}
async function findRootFolderId() {
const storages = await api('/v1/storages?filter[entityType]=common')
if (!storages.length) throw new Error('common drive not found')
return storages[0].rootFolderId
}
async function ensureFolder(parentId, name) {
const children = await api(`/v1/folders?parentId=${parentId}`)
// Checking deletedType is essential: a deleted folder stays reachable by GET and
// sits in the recycle bin. Without this check the script would find it by name
// and quietly put the reports into the recycle bin, where nobody collects them.
const existing = children.find(
item => item.type === 'folder' && item.name === name && !item.deletedType,
)
if (existing) return existing
return api('/v1/folders', {
method: 'POST',
body: JSON.stringify({ parentId, name }),
})
}
async function upload(folderId, filename, filePath) {
const content = (await readFile(filePath)).toString('base64')
return api('/v1/files/upload', {
method: 'POST',
body: JSON.stringify({ folderId, filename, content }),
})
}
async function removeExisting(folderId, name) {
const children = await api(`/v1/files?folderId=${folderId}`)
const stale = children.filter(item => item.type === 'file' && item.name === name && !item.deletedType)
for (const item of stale) {
// DELETE answers 204 with an empty body — it must not be parsed as JSON,
// so only the status is checked, not the { success, data } envelope.
const res = await fetch(`${VIBE_URL}/v1/files/${item.id}`, { method: 'DELETE', headers })
if (!res.ok) throw new Error(`failed to delete ${item.name}: ${res.status}`)
console.log(`deleted the previous ${item.name} (id=${item.id})`)
}
}
async function main() {
const rootFolderId = await findRootFolderId()
const month = new Date().toISOString().slice(0, 7)
const clients = await ensureFolder(rootFolderId, 'Client documents')
const target = await ensureFolder(clients.id, month)
// Drive allows several files with the same name in one folder, so the previous
// file with that name is deleted before the upload — otherwise a daily run
// leaves thirty identically named reports there over a month.
await removeExisting(target.id, 'report.txt')
const file = await upload(target.id, 'report.txt', './report.txt')
console.log(`Folder: ${clients.name}/${target.name}`)
console.log(`File: ${file.name}, ${file.size} bytes, id=${file.id}`)
}
main().catch(error => {
console.error(error.message)
process.exitCode = 1
})
The ensureFolder function first looks for a folder with that name in the parent folder's content and creates a new one only if it is not there. That way a repeated run of the script puts files into the already created tree instead of building it again.
For the files themselves idempotency is provided by removeExisting: before the upload it deletes the previous files with the same name from the folder, so a daily run leaves one current report instead of thirty identically named ones. If you need the history, drop that call and add the date to the file name.