Работа с файлами в REST API Битрикс24 — та задача, где документация заканчивается ровно там, где начинаются проблемы. Официальные примеры показывают, как загрузить один файл в одно поле. А дальше выясняется, что у crm.item.update и crm.deal.update разные несовместимые форматы, что вложенный base64 через http_build_query уходит в никуда, а сервер при этом отвечает result, и что скачать файл по downloadUrl без дополнительных плясок нельзя вообще.
За последний год я делал файловую логику в шести разных проектах: перелив сделок между порталами, загрузка фотоописей с телефона, парсинг PDF из карточек, сборка архивов, синхронизация аватаров. Везде подход получился разный — не из любви к разнообразию, а потому что задачи разные и каждая ломается по-своему.
Что вообще есть в Битриксе для файлов
Сначала карта местности, потому что путаница начинается уже здесь.
Файл в Битрикс24 может жить в трёх принципиально разных местах, и работа с каждым устроена по-своему:
Пользовательское поле типа “файл” (UF) в карточке сделки, лида, элемента смарт-процесса. Работа через crm.*.update.
Диск — файловое хранилище портала со своей иерархией папок. Работа через семейство disk.*.
Вложения к задачам и комментариям — отдельная механика через attachedObject.
Плюс всегда есть вариант вообще не хранить файл в Битриксе, а положить его во внешнее хранилище и записать в поле ссылку. Иногда это единственный работающий путь.

Дальше по порядку.
Часть 1. Запись файлов
Два формата, которые нельзя смешивать
Первое, что ломает мозг новичку: у разных методов обновления карточки разный формат файлового значения.
Через crm.deal.update файл передаётся так — ключ fileData, внутри массив из имени и base64:
$fields = [ 'UF_CRM_PHOTO' => [ 'fileData' => [ 'photo.jpg', base64_encode(file_get_contents($path)), ], ], ]; $response = callBitrix24API('crm.deal.update', [ 'id' => $dealId, 'fields' => $fields, ]);
А через crm.item.update — совершенно иначе. Никакого fileData, просто массив из двух строк:
$values = []; // сохранить уже прикреплённый файл — объект с id $values[] = ['id' => 4821]; // добавить новый — массив ровно из двух строк: имя и base64 $values[] = ['photo.jpg', base64_encode($content)]; $params = [ 'entityTypeId' => 2, // 2 = сделка 'id' => $dealId, 'fields' => ['UF_CRM_PHOTO' => $values], 'useOriginalUfNames' => 'Y', ];
Разница не косметическая. Если передать fileData в crm.item.update — ничего не произойдёт. Если передать формат ["имя", "base64"] в crm.deal.update — тоже.
Хуже того: при смешении форматов в одном запросе можно затереть уже прикреплённые файлы. Мы к этому вернёмся отдельно, это отдельная грабля.
Для множественного поля в crm.deal.update формат тоже свой — массив объектов с fileData:
// один файл — объект const value = { fileData: [files[0].name, files[0].base64] }; // несколько файлов — массив объектов const value = files.map(f => ({ fileData: [f.name, f.base64] }));
Грабля первая: тихий провал через http_build_query
Это самая дорогая по времени грабля из всех, потому что она не выдаёт ошибку.
Типовой код обращения к REST-методу выглядит так — тело формируется через http_build_query, потому что так во всех примерах:
private function requestRaw(string $url, array $params, int $timeout = 30): array { $body = http_build_query($params); $headers = ['Content-Type: application/x-www-form-urlencoded']; return $this->executeRequest($url, $body, $headers, $timeout); }
Для обычных полей работает прекрасно. Но когда в $params лежит вложенный массив с base64-строкой, происходит следующее: http_build_query разворачивает вложенность в плоские ключи вида fields[UF_CRM_PHOTO][0][0], base64 при этом урлкодируется целиком, длина payload взлетает, и Битрикс на такой запрос отвечает…
{"result": {...}, "time": {...}}
То есть успехом. HTTP 200, никакой ошибки, в ответе result. А поле при этом не обновилось. Файла в сделке нет.

Лечится передачей тела в JSON:
private function requestJson(string $url, array $params, int $timeout = 30): array { $body = json_encode($params, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); if ($body === false) { return []; } $headers = [ 'Content-Type: application/json', 'Accept: application/json', ]; return $this->executeRequest($url, $body, $headers, $timeout); }
Обратите внимание на флаг JSON_INVALID_UTF8_SUBSTITUTE — без него json_encode вернёт false на любом файле с битой кодировкой в имени, и вы получите пустой запрос вместо ошибки. Ещё одна тихая смерть.
Правило: всё, что содержит base64, отправляем только JSON-телом. Никаких http_build_query.
И раз ответ с result не гарантирует успеха — проверять надо иначе. Не по факту наличия result, а перечитыванием поля:
$this->requestJson($this->webhookUrl . 'crm.item.update', $params, 120); $last = $this->getLastResponse(); if ($last !== null && !isset($last['error']) && array_key_exists('result', $last)) { return ['success' => true]; } return [];
В боевом коде я после записи ещё и перечитываю поле отдельным запросом, если операция критичная. Дороже на один вызов, зато честно.
Грабля вторая: useOriginalUfNames
Короткая, но злая.
У crm.item.update по умолчанию useOriginalUfNames=N. В этом режиме метод ждёт имена полей в «новом» стиле — ufCrm_1_1234567890. А если вы передадите привычное UF_CRM_1234567890, то поле будет молча проигнорировано. В документации это описано фразой в духе «некорректное поле будет проигнорировано» — то есть ошибки снова не будет.
Решение — явно включать оригинальные имена:
$params = [ 'entityTypeId' => 2, 'id' => $dealId, 'fields' => $fields, 'useOriginalUfNames' => 'Y', // без этого UF_CRM_* игнорируются ];
Заметьте, как складываются первые две грабли: http_build_query даёт result без записи, забытый useOriginalUfNames даёт result без записи. Два разных механизма с одинаковым симптомом — и когда ловишь их вместе, диагностика превращается в ад.
Грабля третья: как не затереть существующие файлы
Множественное файловое поле при обновлении перезаписывается целиком. Передали один файл — остальные исчезли.
Поэтому перед добавлением надо вычитать то, что уже есть, и передать обратно. Для crm.item.update существующие файлы передаются как объекты с id:
public function getExistingDealFileIds(int $dealId, string $fieldKey): array { $raw = $this->getExistingDealFileValues($dealId, $fieldKey); $ids = []; foreach ($raw as $item) { if (is_array($item)) { // Битрикс возвращает то 'id', то 'ID' — в зависимости от метода $id = $item['id'] ?? $item['ID'] ?? null; if ($id !== null && $id !== '') { $ids[] = (int) $id; } } elseif (is_numeric($item)) { // а иногда просто число $ids[] = (int) $item; } } return $ids; }
Три ветки разбора в одном методе — не паранойя. Формат ответа отличается в зависимости от того, каким методом читали карточку, и от версии портала.
Дальше собираем итоговое значение поля: старые как {id}, новые как ["имя", "base64"]:
$existingIds = $this->bitrix->getExistingDealFileIds($dealId, 'photo_files'); $values = []; foreach ($existingIds as $fileId) { $values[] = ['id' => $fileId]; // сохраняем то, что было } foreach ($photoFiles as $file) { $content = @file_get_contents($file['tmp_name']); if ($content === false) { $this->errors[] = "Не удалось прочитать файл: {$file['name']}"; continue; } $values[] = [$file['name'], base64_encode($content)]; // добавляем новые } $response = $this->bitrix->updateDealItemFields($dealId, [ $fieldCode => $values, ]);
И вот тут ключевое, что стоило мне отдельного разбирательства: в crm.deal.update формат с fileData при смешении с элементами {id: N} работает непредсказуемо и может затереть старые файлы. В crm.item.update формат ["имя","base64"] со смешением {id: N} работает корректно.
Это и есть причина, по которой для файловых UF я в итоге везде перешёл на crm.item.update.
Таймауты и размер
База64 раздувает файл примерно на треть. Фотография на 4 МБ превращается в 5.3 МБ текста в теле запроса, десяток фотографий — в 50+ МБ.
Отсюда два следствия.
Первое — увеличенный таймаут. Дефолтные 30 секунд не хватает:
curl_setopt_array($curl, [ CURLOPT_POST => 1, CURLOPT_RETURNTRANSFER => 1, CURLOPT_URL => $url, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => $timeout, // для файлов 120 CURLOPT_CONNECTTIMEOUT => min(10, $timeout), ]);
Второе — файлы лучше заливать по одному, а не пачкой. В проекте синхронизации между двумя порталами я специально развёл нефайловые поля и файлы:
// Нефайловые поля — одним батчем if (Object.keys(updNonFile).length) { await dst.updateDeal(dstDealId, updNonFile); } // Каждый файл — отдельным запросом. // Гарантия, что ни один пакет не упрётся в лимит размера тела // независимо от размера файла. Плюс ошибка на одном файле // не роняет весь перелив. for (const f of updFiles) { await dst.updateDeal(dstDealId, { [f.key]: f.value }); }
Медленнее, но предсказуемо. При переливе сотен сделок это окупается тем, что не приходится разбирать, какой именно файл из пачки всё уронил.
Альтернатива: Disk API
Когда файл не привязан к карточке, а должен лежать в файловой структуре портала, работает семейство disk.*. Логика трёхшаговая: найти хранилище, найти или создать папку, положить файл.
// 1. Находим хранилище — нужно именно пользовательское $storagesResponse = callBitrix24API('disk.storage.getlist'); $storageId = 1; foreach ($storagesResponse['result'] ?? [] as $storage) { if ($storage['ENTITY_TYPE'] === 'user') { $storageId = $storage['ID']; break; } } // 2. Ищем папку среди дочерних элементов $foldersResponse = callBitrix24API('disk.storage.getchildren', ['id' => $storageId]); $folderId = null; foreach ($foldersResponse['result'] ?? [] as $item) { if ($item['TYPE'] === 'folder') { $folderId = $item['ID']; break; } } // 3. Нет папки — создаём if (!$folderId) { $createResponse = callBitrix24API('disk.storage.addfolder', [ 'id' => $storageId, 'data' => ['NAME' => 'Файлы из комментариев задач'], ]); $folderId = $createResponse['result']['ID'] ?? 1; }
Обратите внимание на фолбэк ?? 1 в последней строке. Хранилище с ID=1 существует практически всегда, и это спасает от падения всего процесса, если создание папки не прошло. В боевом коде такие подстраховки важнее красоты.
Плюс Disk API — файл получает нормальный путь и его видно в интерфейсе портала. Минус — три запроса вместо одного и необходимость самому следить за дублями по имени.
Альтернатива: внешнее хранилище
Ещё один путь, который в одном проекте попросил сам заказчик: файлы грузятся не в Битрикс, а во внешнее облако, а в карточку пишутся ссылки.
Схема простая: загружаем файл во внешнее хранилище через его API, получаем публичную ссылку, кладём ссылку в обычное строковое множественное UF-поле. Никакого base64 в Битрикс вообще.
Когда это оправдано:
нужен доступ к файлам вне портала, без лицензии Битрикса;
объёмы такие, что место на портале жалко;
заказчик хочет копию в своём привычном облаке;
файлы должны пережить возможный переезд с портала.
Минус очевиден: ссылки живут отдельно от файлов, и если во внешнем хранилище кто-то что-то передвинет, в карточке останутся битые URL. Плюс это внешняя зависимость с собственным OAuth, токенами и лимитами.
В том проекте мы делали оба варианта одновременно — фото уходили и в файловое поле сделки, и в облако. Дублирование, но заказчику так было спокойнее.
Часть 2. Чтение файлов
С записью разобрались. Теперь противоположная задача, которая оказалась не проще: достать файл из Битрикса.
Грабля четвёртая: downloadUrl не работает как ссылка
Читаем карточку, получаем файловое поле. В ответе — объект со ссылкой:
{ "id": 4821, "name": "photo.jpg", "downloadUrl": "/bitrix/components/bitrix/crm.deal.show/show_file.php?auth=&fileId=4821..." }
Кажется, что достаточно сходить по downloadUrl. Не достаточно, и по двум причинам сразу.
Во-первых, ссылка относительная — надо приклеить хост портала. Во-вторых, и это главное, в ней параметр auth= пустой. Битрикс отдаёт шаблон, в который вы должны подставить свой access token:
// Подставляем access_token в параметр auth= const urlWithAuth = rawUrl.includes('auth=') ? rawUrl.replace(/auth=[^&]*/, `auth=${encodeURIComponent(accessToken)}`) : `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}auth=${encodeURIComponent(accessToken)}`; const baseHost = this.http.defaults.baseURL?.replace(/\/rest\/.*$/, ''); const fullUrl = urlWithAuth.startsWith('http') ? urlWithAuth : `${baseHost}${urlWithAuth}`; const resp = await axios.get(fullUrl, { responseType: 'arraybuffer', timeout: 60_000, maxRedirects: 5, maxContentLength: Infinity, maxBodyLength: Infinity, });
Важная деталь: responseType: 'arraybuffer'. Если этого не указать, клиент попытается разобрать бинарник как текст и вы получите мусор вместо картинки.
И ещё: скачивание файлов работает только по OAuth-токену, входящий вебхук здесь не поможет. Если проект живёт на вебхуках, под файлы придётся отдельно поднимать OAuth-приложение.
Грабля пятая: HTML вместо файла
Продолжение предыдущей. Если токен протух или невалиден, Битрикс не отдаёт 401 — он отдаёт страницу авторизации с кодом 200.
То есть в вашем arraybuffer окажется HTML, который вы радостно сохраните как photo.jpg. Файл будет, весить будет, открываться не будет.
Проверка обязательна:
const buf = Buffer.from(resp.data); const head = buf.subarray(0, 512).toString('utf8').toLowerCase(); if (head.includes('<!doctype html') || head.includes('<html')) { logger.warn({ portal: this.name }, 'получен HTML вместо файла — токен невалиден'); continue; }
Проверять по сигнатуре содержимого, а не по Content-Type — заголовок в этой ситуации может врать.
Грабля шестая: у файла несколько представлений
Дальше начинается то, ради чего в моих проектах появились отдельные функции нормализации.
Одно и то же изображение Битрикс может отдать в трёх разных видах в зависимости от метода и сущности:
строкой-URL;
HTML-фрагментом с тегом
<img src="...">;объектом с набором ключей.
Причём набор ключей у объекта тоже плавает: showUrl, SHOW_URL, downloadUrl, DOWNLOAD_URL, url, src. Плюс отдельно бывает, что вместо URL приходит просто числовой ID файла.
Функция, которая всё это разгребает:
/** * URL фото из PERSONAL_PHOTO / IMAGE / avatar: * строка-URL, HTML <img> или объект {showUrl}. ID файла — не URL. */ function pf_photo_url_from_value($photo): string { if (is_array($photo)) { foreach (['showUrl', 'SHOW_URL', 'downloadUrl', 'DOWNLOAD_URL', 'url', 'src', 'IMAGE', 'image'] as $k) { if (!empty($photo[$k]) && is_string($photo[$k])) { return pf_absolute_media_url($photo[$k]); } } return ''; } $photo = trim((string) $photo); // пустая строка или голый ID — не URL if ($photo === '' || ctype_digit($photo)) { return ''; } // HTML-фрагмент — вытаскиваем src if (preg_match('/src=["\']([^"\']+)["\']/i', $photo, $m)) { return pf_absolute_media_url($m[1]); } return pf_absolute_media_url($photo); }
Восемь возможных ключей, три формата значения, отдельная проверка на ctype_digit. Всё это не выдумано — каждая ветка появилась после того, как реальный портал вернул именно такой вид.
Отдельно — приведение к абсолютному URL, потому что относительные ссылки приходят постоянно:
private function normalizeToAbsolutePortalUrl(string $url): string { $u = trim($url); if ($u === '') { return ''; } if (preg_match('#^https?://#i', $u)) { return $u; // уже абсолютный } $host = $this->getPortalHost(); // хост из webhook_url if ($host === '') { return $u; } if ($u[0] !== '/') { $u = '/' . $u; } return 'https://' . $host . $u; }
Грабля седьмая: не все URL одинаково рабочие
А это моя любимая, потому что до неё дойти можно только опытным путём.
У одного и того же изображения может быть несколько разных URL, и часть из них не работает. Классический случай — аватар пользователя: оригинальный файл может называться Снимок экрана 2024-11-03 в 14.22.15.png или прийти из мессенджера с пробелами и кириллицей в имени. Такая ссылка отваливается на ровном месте — где-то не так закодировалась, где-то сервер отдал 404.
При этом у Битрикса рядом лежит хешированный вариант из resize_cache — с чистым именем из шестнадцатеричных символов. Он работает стабильно.
Раз надёжность вариантов разная, я сделал скоринг: беру два кандидата и выбираю лучший по эвристике.
/** IM hashed/resize_cache URL надёжнее оригинала PERSONAL_PHOTO * (пробелы, WhatsApp, «Снимок»). */ function pf_prefer_avatar_url(string $a, string $b): string { $score = static function (string $u): int { if ($u === '') return -10; $dec = strtolower(rawurldecode($u)); // заглушки — хуже, чем ничего if (str_contains($dec, 'blank.gif') || str_contains($dec, 'nopic')) return -5; $s = 1; if (str_contains($dec, 'resize_cache')) $s += 4; // хешированный путь if (preg_match('#[0-9a-f]{16,}#', $dec)) $s += 2; // длинный hex в имени // признаки проблемного имени if (preg_match('/whatsapp|snimok|снимок|screenshot|\s/u', $dec)) $s -= 3; return $s; }; return $score($a) >= $score($b) ? $a : $b; }

Выглядит как хак, и это хак. Но альтернатива — качать оба варианта и проверять, что вернулось, а это лишний сетевой запрос на каждый аватар при синхронизации сотен пользователей.
Грабля восьмая: чужие хосты в ссылках
Последняя, специфическая. В некоторых сценариях Битрикс возвращает ссылки на служебные хосты — например, на промежуточный домен OAuth-инфраструктуры вместо самого портала. Скачать по такой ссылке нельзя.
Лечится проверкой, что хост в ссылке действительно принадлежит порталу:
def _pick_file_url(d, domain, token): if not isinstance(d, dict): return None for k in ("DOWNLOAD_URL", "downloadUrl", "url", "urlMachine", "showUrl", "SHOW_URL", "src"): u = d.get(k) if not isinstance(u, str) or not u.strip(): continue u = u.strip() # служебный хост или чужой show_file.php — пересобираем ссылку сами if _is_oauth_staging_host(u) or ("show_file.php" in u and "crm.deal" in u): fixed = _portal_show_file_url(u, domain, token) if fixed: return fixed continue if u.startswith("http://") or u.startswith("https://"): p = urlparse(u) # хост должен заканчиваться доменом нашего портала if p.netloc.split(":")[0].lower().endswith(domain.split(":")[0].lower()): return u continue return None
Здесь же видно ещё один ключ, которого нет в предыдущих списках — urlMachine. Он приходит в части ответов Disk API и означает «машинную» ссылку для программного скачивания, в отличие от showUrl для показа в браузере.
Что брать под какую задачу
Сводка по всему опыту.
Задача |
Метод |
Почему |
|---|---|---|
Положить файл в UF сделки/лида |
|
Корректно работает со смешением старых |
Много файлов сразу |
Он же, но по одному запросу на файл |
Не упирается в лимит тела, ошибка не роняет пачку |
Файл в структуру портала |
|
Нормальный путь, видно в интерфейсе |
Файлы нужны вне портала |
Внешнее хранилище + ссылки в UF |
Не жрёт место, переживает переезд |
Скачать файл из карточки |
|
Единственный рабочий путь, только OAuth |
Синхронизация между порталами |
Скачать → base64 → залить по одному |
Батч на файлах ломается непредсказуемо |
И чеклист граблей, чтобы не наступать заново:
Всё с base64 — только JSON-телом. http_build_query отдаёт result без записи.
useOriginalUfNames=Y в crm.item.update, иначе UF_CRM_* молча игнорируются.
Множественное поле перезаписывается целиком — вычитывайте существующие id и передавайте обратно.
Форматы fileData и ["имя","base64"] не взаимозаменяемы и в разных методах разные.
Таймаут для файловых запросов — от 60 секунд, base64 добавляет треть объёма.
downloadUrl приходит с пустым auth= — подставляйте access token, вебхук не подойдёт.
Проверяйте, не HTML ли вернулся вместо файла — протухший токен отдаёт страницу с кодом 200.
Ключ с URL плавает между showUrl, downloadUrl, url, src, urlMachine — перебирайте все.
Итог
Файловый API Битрикс24 не то чтобы плохой — он просто складывался годами, и в нём сосуществуют несколько поколений подходов с разными соглашениями. Проблема в том, что документация описывает каждый метод изолированно и почти никогда не говорит, что будет, если сделать «почти правильно».
А будет чаще всего {"result": ...} — и ничего больше. Именно поэтому здесь так важно не доверять ответу сервера, а проверять фактическое состояние поля.
Если у вас есть свои находки по файлам в Битриксе — особенно по смарт-процессам и вложениям задач, где я копал меньше, — напишите в комментариях. Такие вещи негде собрать, кроме как совместно.
hardtop
Шикарный детектив получился!