# 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 `disk` scope
- 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

1. Find the storage and its root folder — the whole Drive tree starts there.
2. Look at the folder content, separating subfolders and files.
3. Create a folder for the task, nested if needed.
4. Upload a file into that folder.
5. Download the file back.
6. 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`](/docs/entities/storages/list). 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

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" \
  "$VIBE_URL/v1/storages?filter[entityType]=common"
```

### JavaScript

```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
```

```json
{
  "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`](/docs/entities/folders/list) 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

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" \
  "$VIBE_URL/v1/folders?parentId=19"
```

### JavaScript

```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')
```

```json
{
  "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`](/docs/entities/files/list). 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`](/docs/entities/folders/create) 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

```bash
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

```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')
```

```json
{
  "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`](/docs/entities/folders/update) with the single `name` field. Moving to another parent folder is a separate operation, [`POST /v1/folders/:id/moveto`](/docs/entities/folders/moveto).

## Step 4. Uploading a file

[`POST /v1/files/upload`](/docs/entities/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

```bash
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

```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()
```

```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`.

```json
{
  "success": false,
  "error": {
    "code": "MISSING_PARAMS",
    "message": "filename and content (base64) are required."
  }
}
```

## Step 5. Downloading a file

[`GET /v1/files/:id/download`](/docs/entities/files/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

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" \
  -o report.txt \
  "$VIBE_URL/v1/files/9423/download"
```

### JavaScript

```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`](/docs/entities/files/update) with the `name` field. Deleting a file and deleting a folder are [`DELETE /v1/files/:id`](/docs/entities/files/delete) and [`DELETE /v1/folders/:id`](/docs/entities/folders/delete), both operations return `204` with an empty body.

### cURL

```bash
curl -s -X DELETE -H "X-Api-Key: $VIBE_API_KEY" \
  "$VIBE_URL/v1/files/9423"
```

### JavaScript

```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`](/docs/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

```bash
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

```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.

```json
{
  "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`](/docs/entities/folders/moveto) and [`POST /v1/files/:id/moveto`](/docs/entities/files/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

```javascript
// 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.

## See also

- [Storages](/docs/entities/storages)
- [Folders](/docs/entities/folders)
- [Files](/docs/entities/files)
- [Working with files in CRM fields](/docs/recipes/crm-files)
- [Batch requests](/docs/batch)
- [Error codes](/docs/errors)
