Работа с файлами в 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;
}
Скоринг URL: у одного файла несколько адресов, и не все рабочие
Скоринг URL: у одного файла несколько адресов, и не все рабочие

Выглядит как хак, и это хак. Но альтернатива — качать оба варианта и проверять, что вернулось, а это лишний сетевой запрос на каждый аватар при синхронизации сотен пользователей.

Грабля восьмая: чужие хосты в ссылках

Последняя, специфическая. В некоторых сценариях Битрикс возвращает ссылки на служебные хосты — например, на промежуточный домен 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 сделки/лида

crm.item.update + JSON

Корректно работает со смешением старых {id} и новых файлов

Много файлов сразу

Он же, но по одному запросу на файл

Не упирается в лимит тела, ошибка не роняет пачку

Файл в структуру портала

disk.*

Нормальный путь, видно в интерфейсе

Файлы нужны вне портала

Внешнее хранилище + ссылки в UF

Не жрёт место, переживает переезд

Скачать файл из карточки

downloadUrl + подстановка токена

Единственный рабочий путь, только 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": ...} — и ничего больше. Именно поэтому здесь так важно не доверять ответу сервера, а проверять фактическое состояние поля.

Если у вас есть свои находки по файлам в Битриксе — особенно по смарт-процессам и вложениям задач, где я копал меньше, — напишите в комментариях. Такие вещи негде собрать, кроме как совместно.

Комментарии (1)


  1. hardtop
    28.08.2026 20:31

    Шикарный детектив получился!