# 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 in the field only the files that must stay there.

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 is about it.
- **«File (Drive)»** — the field references a Drive object. Such 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.

- [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 name of a file field looks like `ufCrm_<code>`. The exact name and the multiple flag are returned by `GET /v1/{entity}/fields`.

## 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://portal.bitrix24.com/rest/1/WEBHOOK/crm.controller.item.getFile/?token=..."
    }
  ]
}
```

Each object contains two addresses for different purposes.

- **`url`** — for opening in a browser. Requires an active user session.
- **`urlMachine`** — for programmatic download. Authorization is included in the address itself, so treat it like a key: keep it out of logs, do not pass it to third parties and do not store it in tasks or tickets.

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 -o result.pdf "URL_MACHINE_FROM_RESPONSE"
```

### JavaScript

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

const res = await fetch(urlMachine)
await writeFile('result.pdf', Buffer.from(await res.arrayBuffer()))
```

### PHP

```php
<?php
file_put_contents('result.pdf', file_get_contents($urlMachine));
```

For a webhook key the authorization is built into the address and works right away. For an OAuth application key the address is valid for a limited time. Download the file right after reading the record, and if some time has passed, read the record again and take a fresh `urlMachine`.

## 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 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 multiple flag is returned by `GET /v1/{entity}/fields`. The examples below show a multiple field. For a single 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://portal.bitrix24.com/rest/1/WEBHOOK/crm.controller.item.getFile/?token=..."
      }
    ]
  }
}
```

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 the field completely.

```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 request body is limited to 1 MB, and base64 increases the size by about a third, so the practical limit for one file is around 750 KB. For larger files use the «File (Drive)» field and upload to Drive.

**Refusals of 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.

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 > 750 * 1024) {
  throw new Error(`${basename(FILE_PATH)} is over 750 KB — 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')]],
  }),
})

// The account answers with 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)
