Работа с файлами в 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 сделки/лида

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

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