# Работа с файлами в полях CRM

**Сложность:** средний | **Скоупы:** crm | **Стек:** cURL / JavaScript / PHP

Читаем, скачиваем и записываем файлы в пользовательских полях типа «Файл» у сделок, лидов, контактов, компаний и смарт-процессов.

## Что понадобится

- API-ключ Вайбкод со скоупом `crm`
- Node.js 18 или новее для примеров на JavaScript

Во всех примерах `$VIBE_URL` — базовый адрес `https://vibecode.bitrix24.tech`, `$VIBE_API_KEY` — ваш API-ключ.

## Как устроено решение

1. Скачиваем файл по адресу, который приходит в поле записи.
2. Записываем файл парой из имени и содержимого в кодировке Base64.
3. Удаляем лишнее, оставляя в поле только те файлы, что должны там остаться.

Перед этим нужно знать две вещи о поле — его тип и точное имя. Обе разобраны в разделах ниже, до шагов.

## Два типа файловых полей

В Битрикс24 есть два разных типа файловых полей, и работают они по-разному.

- **«Файл»** — поле не связано с Диском. Содержимое хранится в самом поле. Эта страница про него.
- **«Файл (диск)»** — поле ссылается на объект Диска. С такими файлами работают через эндпоинты `/v1/files/*`, см. [Работа с файлами Диска](/docs/recipes/disk-files).

Тип поля виден в ответе [`GET /v1/{entity}/fields`](/docs/entities/deals/fields).

## Где применимо

Механика одинакова для всех перечисленных сущностей CRM.

- [Сделки](/docs/entities/deals) — `GET / PATCH /v1/deals/:id`
- [Лиды](/docs/entities/leads) — `GET / PATCH /v1/leads/:id`
- [Контакты](/docs/entities/contacts) — `GET / PATCH /v1/contacts/:id`
- [Компании](/docs/entities/companies) — `GET / PATCH /v1/companies/:id`
- [Предложения](/docs/entities/quotes) — `GET / PATCH /v1/quotes/:id`
- [Счета](/docs/entities/invoices) — `GET / PATCH /v1/invoices/:id`
- [Смарт-процессы](/docs/entities/items) — `GET / PATCH /v1/items/:entityTypeId/:id`

## Имя поля

Точное имя файлового поля возвращает `GET /v1/{entity}/fields` — написание различается по сущностям, см. [Пользовательские поля (UF)](/docs/entity-api#пользовательские-поля-uf). Признак множественности приходит в списке определений полей, в поле `multiple` со значением `Y` или `N`: `GET /v1/userfields/:entity` для сделок, лидов, контактов, компаний и предложений, `GET /v1/items/:entityTypeId/userfields` для смарт-процессов и счетов.

## Как поле выглядит в ответе

[`GET /v1/deals/:id`](/docs/entities/deals/get) возвращает файловое поле внутри конверта `{ success, data }` — само поле лежит в `data`, а не в корне ответа.

```json
{
  "ufCrm_1a2b3c": [
    {
      "id": 35845,
      "url": "https://portal.bitrix24.ru/bitrix/services/main/ajax.php?action=crm.controller.item.getFile&entityTypeId=2&id=100&fieldName=UF_CRM_1A2B3C&fileId=35845",
      "urlMachine": "https://portal.bitrix24.ru/rest/1/WEBHOOK/crm.controller.item.getFile/?token=..."
    }
  ]
}
```

В каждом объекте два адреса для разных задач.

- **`url`** — для открытия в браузере. Требует активной сессии пользователя.
- **`urlMachine`** — для программного скачивания. Авторизация включена в сам адрес, поэтому обращайтесь с ним как с ключом: не пишите в журналы, не передавайте третьим лицам и не сохраняйте в задачах и тикетах.

Примеры шагов показывают отдельные вызовы и опираются на переменные, объявленные выше по сценарию.

## Шаг 1. Скачать файл

Возьмите `urlMachine` из ответа и выполните по нему `GET`. В ответе придёт бинарное содержимое файла.

### 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));
```

Для ключа-вебхука авторизация вшита в адрес и работает сразу. Для ключа OAuth-приложения адрес действует ограниченное время. Скачивайте файл сразу после чтения записи, а если прошло время, перечитайте запись и возьмите свежий `urlMachine`.

## Шаг 2. Записать файл

**Запись заменяет содержимое поля целиком.** Если в поле уже лежат документы, а вы передали одну новую пару, прежние файлы будут удалены. Чтобы добавить файл к существующим, перечислите их идентификаторы вместе с новой парой — как показано в конце этого шага.

Файл передаётся парой из имени и содержимого в формате base64.

Поле с одним значением принимает одну пару.

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

Множественное поле принимает массив пар.

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

Примеры ниже показывают множественное поле. Для поля с одним значением передайте одну пару без внешнего массива.

### 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.tech'
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);
```

Ответ возвращает запись целиком, и файловое поле в нём — уже новый набор. По нему и сверяйте результат записи.

```json
{
  "success": true,
  "data": {
    "id": 100,
    "ufCrm_1a2b3c": [
      {
        "id": 35849,
        "url": "https://portal.bitrix24.ru/bitrix/services/main/ajax.php?action=crm.controller.item.getFile&entityTypeId=2&id=100&fieldName=UF_CRM_1A2B3C&fileId=35849",
        "urlMachine": "https://portal.bitrix24.ru/rest/1/WEBHOOK/crm.controller.item.getFile/?token=..."
      }
    ]
  }
}
```

Файл больше допустимого портал отклоняет:

```json
{
  "success": false,
  "error": {
    "code": "BITRIX_ERROR",
    "message": "Максимальный размер файла превышен"
  }
}
```

Чтобы сохранить уже прикреплённые файлы и добавить новый, передайте идентификаторы существующих файлов вместе с новой парой.

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

## Шаг 3. Удалить файл

Передайте в поле только те файлы, которые нужно оставить. Пустой массив очищает поле с одним значением. У множественного поля пустой массив прежний набор не меняет — перечислите идентификаторы остающихся файлов, как показано в конце шага 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.tech'
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: [] }),
})
```

## Ограничения

**Размер одного файла.** Тело запроса на создание и обновление записи ограничено 40 МиБ, а base64 увеличивает размер примерно на треть, поэтому практический предел одного файла — чуть меньше 30 МиБ. Тело сверх лимита отклоняется с кодом `PAYLOAD_TOO_LARGE`.

Битрикс24 на своей стороне принимает и больше, так что этот потолок — наш. Отдельно учтите время: вызов в Битрикс24 ограничен 15 секундами без повторной попытки, поэтому файл у самой границы на медленном портале может отказать с `BITRIX_TIMEOUT` — надёжнее оставлять запас. Для файлов в десятки мегабайт используйте поле «Файл (диск)» и загрузку на Диск.

**Отказы этого сценария.** `ENTITY_NOT_FOUND` — записи с таким идентификатором нет, проверьте идентификатор и тип сущности. `READONLY_FIELD` — поле недоступно на запись, проверьте его в ответе `GET /v1/{entity}/fields`. `BITRIX_ERROR` — Битрикс24 отклонил значение, чаще всего файл больше допустимого: уменьшите файл или перенесите его на Диск.

Полный перечень кодов — [Ошибки](/docs/errors).

## Полный код

Скрипт добавляет файл в поле, не потеряв то, что там уже лежит: читает запись, забирает идентификаторы прикреплённых файлов и отправляет их обратно вместе с новой парой. Это единственный запускаемый артефакт страницы — примеры шагов выше показывают отдельные вызовы.

```javascript
// attach.mjs — добавить файл в поле «Файл» карточки CRM, сохранив прежние
// Расширение .mjs обязательно: скрипт использует await на верхнем уровне,
// а файл .js без "type": "module" Node читает как CommonJS и падает на разборе.
import { readFile } from 'node:fs/promises'
import { basename } from 'node:path'

const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('Переменная окружения VIBE_API_KEY не задана')

const ENTITY = 'deals'            // deals | leads | contacts | companies | quotes | invoices
const ENTITY_ID = 100
const FIELD = 'ufCrm_1a2b3c'      // имя поля из 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 ?? `запрос отклонён (${res.status})`)
  return body.data
}

// Запись заменяет содержимое поля целиком, поэтому прежние файлы надо
// перечислить идентификаторами. Без этого шага они молча исчезнут.
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)} больше 25 МиБ — используйте поле «Файл (диск)»`)
}

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

// Портал отвечает успехом и на запись, которая ничего не изменила, поэтому
// число файлов сверяется по ответу, а не предполагается.
const after = Array.isArray(updated[FIELD]) ? updated[FIELD].length : 0
console.log(`было ${existing.length}, стало ${after}`)
```

Скрипт идемпотентен по количеству, но не по содержимому: повторный запуск добавит второй экземпляр того же файла. Если запускаете по расписанию, сверяйте имя файла с уже прикреплёнными и пропускайте совпадения.

## Смотрите также

- [Работа с файлами Диска](/docs/recipes/disk-files)
- [Синтаксис фильтрации](/docs/filtering)
- [Entity API](/docs/entity-api)
