# Working with files in CRM fields

**Difficulty:** medium | **Scopes:** crm | **Stack:** cURL / JavaScript / PHP

Read, download and write files in user fields of the "File" type on deals, leads, contacts, companies and smart processes.

## What you need

- A Vibecode API key with the `crm` scope
- Node.js 18 or newer for the JavaScript examples

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. Download the file from the address returned in the record field.
2. Write the file as a pair of a name and Base64-encoded content.
3. Remove what is no longer needed, leaving only the files that must stay in the field.

Before that you need to know two things about the field — its type and its exact name. Both are covered in the sections below, before the steps.

## Two types of file fields

Bitrix24 has two different types of file fields, and they work differently.

- **"File"** — the field is not linked to Drive. The content is stored in the field itself. This page covers this type.
- **"File (Drive)"** — the field references a Drive object. These files are handled through the `/v1/files/*` endpoints, see [Working with Drive files](/docs/recipes/disk-files).

The field type is visible in the response of [`GET /v1/{entity}/fields`](/docs/entities/deals/fields).

## Where it applies

The mechanics are the same for all the CRM entities listed below.

File download routes:

`GET /v1/deals/:id/files/:field/:fileId/download`

`GET /v1/leads/:id/files/:field/:fileId/download`

`GET /v1/contacts/:id/files/:field/:fileId/download`

`GET /v1/companies/:id/files/:field/:fileId/download`

`GET /v1/quotes/:id/files/:field/:fileId/download`

`GET /v1/invoices/:id/files/:field/:fileId/download`

`GET /v1/items/:entityTypeId/:id/files/:field/:fileId/download`

- [Deals](/docs/entities/deals) — `GET / PATCH /v1/deals/:id`
- [Leads](/docs/entities/leads) — `GET / PATCH /v1/leads/:id`
- [Contacts](/docs/entities/contacts) — `GET / PATCH /v1/contacts/:id`
- [Companies](/docs/entities/companies) — `GET / PATCH /v1/companies/:id`
- [Quotes](/docs/entities/quotes) — `GET / PATCH /v1/quotes/:id`
- [Invoices](/docs/entities/invoices) — `GET / PATCH /v1/invoices/:id`
- [Smart processes](/docs/entities/items) — `GET / PATCH /v1/items/:entityTypeId/:id`

## Field name

The exact name of a file field is returned by `GET /v1/{entity}/fields` — the spelling differs between entities, see [User fields (UF)](/docs/entity-api#user-fields-uf). Whether the field accepts multiple values is reported in the list of field definitions, in the `multiple` field with the value `Y` or `N`: `GET /v1/userfields/:entity` for deals, leads, contacts, companies and quotes, `GET /v1/items/:entityTypeId/userfields` for smart processes and invoices.

## How the field looks in the response

[`GET /v1/deals/:id`](/docs/entities/deals/get) returns the file field inside the `{ success, data }` envelope — the field itself sits in `data`, not at the root of the response.

```json
{
  "ufCrm_1a2b3c": [
    {
      "id": 35845,
      "url": "https://portal.bitrix24.com/bitrix/services/main/ajax.php?action=crm.controller.item.getFile&entityTypeId=2&id=100&fieldName=UF_CRM_1A2B3C&fileId=35845",
      "urlMachine": "https://vibecode.bitrix24.com/v1/deals/100/files/ufCrm_1a2b3c/35845/download"
    }
  ]
}
```

Each object contains two addresses for different purposes.

- **`url`** — for opening in a browser. Requires an active user session.
- **`urlMachine`** — a Vibecode API download URL. Pass `X-Api-Key` with the `crm` scope. The URL itself carries no Bitrix24 credential.

The step examples show individual calls and rely on variables declared earlier in the scenario.

## Step 1. Download the file

Take `urlMachine` from the response and send a `GET` to it. The response contains the binary content of the file.

### cURL

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" -o result.pdf "URL_MACHINE_FROM_RESPONSE"
```

### JavaScript

```javascript
import { writeFile } from 'node:fs/promises'

const res = await fetch(urlMachine, { headers: { 'X-Api-Key': VIBE_API_KEY } })
await writeFile('result.pdf', Buffer.from(await res.arrayBuffer()))
```

### PHP

```php
<?php
$context = stream_context_create(['http' => ['header' => 'X-Api-Key: ' . getenv('VIBE_API_KEY')]]);
file_put_contents('result.pdf', file_get_contents($urlMachine, false, $context));
```

The URL alone does not grant access. The Vibe key and the file membership in the named record and field are checked on each download.

## Step 2. Write the file

**A write replaces the whole content of the field.** If the field already holds documents and you send a single new pair, the previous files are deleted. To add a file to the existing ones, list their identifiers together with the new pair — as shown at the end of this step.

A file is passed as a pair of a name and content in base64 format.

A single-value field accepts one pair.

```json
{ "ufCrm_1a2b3c": ["contract.pdf", "BASE64_CONTENT"] }
```

A multiple field accepts an array of pairs.

```json
{ "ufCrm_1a2b3c": [["contract.pdf", "BASE64_CONTENT"], ["act.pdf", "BASE64_CONTENT"]] }
```

The examples below show a multiple field. For a single-value field pass one pair without the outer array.

### cURL

```bash
curl -s -X PATCH -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/deals/100" \
  -d '{"ufCrm_1a2b3c": [["contract.pdf", "BASE64_CONTENT"]]}'
```

### JavaScript

```javascript
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
const base64Content = Buffer.from(await readFile('contract.pdf')).toString('base64')

await fetch(`${VIBE_URL}/v1/deals/100`, {
  method: 'PATCH',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ ufCrm_1a2b3c: [['contract.pdf', base64Content]] }),
})
```

### PHP

```php
<?php
$vibeUrl = getenv('VIBE_URL');
$vibeApiKey = getenv('VIBE_API_KEY');

$ch = curl_init("$vibeUrl/v1/deals/100");
curl_setopt_array($ch, [
  CURLOPT_CUSTOMREQUEST => 'PATCH',
  CURLOPT_HTTPHEADER => ["X-Api-Key: $vibeApiKey", 'Content-Type: application/json'],
  CURLOPT_POSTFIELDS => json_encode([
    'ufCrm_1a2b3c' => [['contract.pdf', $base64Content]],
  ]),
  CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($ch);
curl_close($ch);
```

The response returns the whole record, and the file field in it is already the new set. Check the result of the write against it.

```json
{
  "success": true,
  "data": {
    "id": 100,
    "ufCrm_1a2b3c": [
      {
        "id": 35849,
        "url": "https://portal.bitrix24.com/bitrix/services/main/ajax.php?action=crm.controller.item.getFile&entityTypeId=2&id=100&fieldName=UF_CRM_1A2B3C&fileId=35849",
        "urlMachine": "https://vibecode.bitrix24.com/v1/deals/100/files/ufCrm_1a2b3c/35849/download"
      }
    ]
  }
}
```

A file over the limit is rejected by Bitrix24:

```json
{
  "success": false,
  "error": {
    "code": "BITRIX_ERROR",
    "message": "Maximum file size exceeded"
  }
}
```

To keep the already attached files and add a new one, pass the identifiers of the existing files together with the new pair.

```json
{ "ufCrm_1a2b3c": [{ "id": 35845 }, ["new.pdf", "BASE64_CONTENT"]] }
```

## Step 3. Delete a file

Pass only the files that must remain in the field. An empty array clears a single-value field. On a multiple field an empty array leaves the previous set untouched — list the identifiers of the files that stay, as shown at the end of step 2.

```json
{ "ufCrm_1a2b3c": [] }
```

### cURL

```bash
curl -s -X PATCH -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/deals/100" \
  -d '{"ufCrm_1a2b3c": []}'
```

### JavaScript

```javascript
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.com'
const VIBE_API_KEY = process.env.VIBE_API_KEY

await fetch(`${VIBE_URL}/v1/deals/100`, {
  method: 'PATCH',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ ufCrm_1a2b3c: [] }),
})
```

## Limitations

**Size of a single file.** The body of a create or update request is limited to 40 MiB, and base64 increases the size by about a third, so the practical limit for one file is just under 30 MiB. A body over the limit is rejected with the `PAYLOAD_TOO_LARGE` code.

Bitrix24 itself accepts more, so this ceiling is ours. Mind the clock too: the call to Bitrix24 is capped at 60 seconds with no retry, so a file right at the edge may fail with `BITRIX_TIMEOUT` on a slow Bitrix24 account — leave some headroom. For files of tens of megabytes use the "File (Drive)" field and upload to Drive.

**Refusals in this scenario.** `ENTITY_NOT_FOUND` — there is no record with that identifier, check the identifier and the entity type. `READONLY_FIELD` — the field is not writable, check it in the response of `GET /v1/{entity}/fields`. `BITRIX_ERROR` — Bitrix24 rejected the value, most often the file is over the limit: make the file smaller or move it to Drive. `DOWNLOAD_FAILED` on download — Bitrix24 did not return the file, or returned an unsafe download address that Vibecode did not follow. If a self-hosted Bitrix24 runs over HTTP, every download fails this way: set up HTTPS on it.

The full list of error codes — [Errors](/docs/errors).

## Full code

The script adds a file to the field without losing what is already there: it reads the record, takes the identifiers of the attached files and sends them back together with the new pair. This is the only runnable artifact of the page — the step examples above show individual calls.

```javascript
// attach.mjs — add a file to a "File" field of a CRM record, keeping the previous ones
// The .mjs extension is required: the script uses top-level await, and Node reads
// a .js file without "type": "module" as CommonJS and fails while parsing it.
import { readFile } from 'node:fs/promises'
import { basename } from 'node:path'

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 ENTITY = 'deals'            // deals | leads | contacts | companies | quotes | invoices
const ENTITY_ID = 100
const FIELD = 'ufCrm_1a2b3c'      // field name from GET /v1/{entity}/fields
const FILE_PATH = './contract.pdf'

const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }

async function apiCall(url, init = {}) {
  const res = await fetch(url, init)
  const body = await res.json().catch(() => null)
  if (!body?.success) throw new Error(body?.error?.message ?? `request rejected (${res.status})`)
  return body.data
}

// A write replaces the whole content of the field, so the previous files have to be
// listed by their identifiers. Without this step they disappear silently.
const record = await apiCall(`${VIBE_URL}/v1/${ENTITY}/${ENTITY_ID}`, { headers })
const existing = Array.isArray(record[FIELD]) ? record[FIELD] : []
const keep = existing.map(file => ({ id: file.id }))

const bytes = await readFile(FILE_PATH)
if (bytes.length > 25 * 1024 * 1024) {
  throw new Error(`${basename(FILE_PATH)} is over 25 MiB — use a "File (Drive)" field`)
}

const updated = await apiCall(`${VIBE_URL}/v1/${ENTITY}/${ENTITY_ID}`, {
  method: 'PATCH',
  headers,
  body: JSON.stringify({
    [FIELD]: [...keep, [basename(FILE_PATH), bytes.toString('base64')]],
  }),
})

// Bitrix24 returns success even for a write that changed nothing, so the
// number of files is checked against the response instead of being assumed.
const after = Array.isArray(updated[FIELD]) ? updated[FIELD].length : 0
console.log(`was ${existing.length}, now ${after}`)
```

The script is idempotent by count but not by content: a repeat run adds a second copy of the same file. If you run it on a schedule, compare the file name with the already attached ones and skip the matches.

## See also

- [Working with Drive files](/docs/recipes/disk-files)
- [Filtering syntax](/docs/filtering)
- [Entity API](/docs/entity-api)
