У Битрикса есть официальная обёртка для REST API — класс CRest, который лежит в каждом примере приложения и который все копируют к себе в проект. Он умещается в один файл, не требует зависимостей и работает — ровно до того момента, пока приложение не выходит в реальную эксплуатацию с несколькими порталами и фоновыми воркерами.
Дальше начинается то, о чём в документации не пишут: гонки за одноразовый токен, обрезанные конфиги, отвалившиеся порталы. Один из таких багов однажды стоил мне шести суток простоя приложения — и причина была в одной строке стокового кода.
Я переписал CRest с нуля под то, как приложения Bitrix24 работают на самом деле. Ниже — какие именно грабли стокового класса ломают прод, как каждая из них чинится, и почему ключом портала должен быть не домен. Код открыт, ссылка в конце.
Что такое CRest и почему его все используют
Коротко для тех, кто не работал с Bitrix24. Чтобы приложение могло обращаться к порталу — читать сделки, создавать задачи, слушать события — оно ходит в REST API по OAuth-токену. Токен живёт около часа, потом его надо обновлять по refresh_token.
Битрикс раздаёт готовый класс CRest, который всё это инкапсулирует: хранит токены в settings.json, сам их обновляет, делает вызовы. Он идёт в каждом официальном примере, поэтому 90% приложений на рынке используют именно его — часто просто скопировав файл в проект.
Проблема в том, что CRest написан под демонстрацию, а не под продакшен. На одном портале в одном процессе он работает. Всё ломается, когда порталов становится много, а процессов — параллельно.
Вот полный список того, что я в нём переписал, одной таблицей — дальше разберу главное подробно:
Проблема стокового CRest | Как решено |
|---|---|
Один | Интерфейс хранилища: один файл / мультипортал / PDO |
Гонка refresh: N воркеров жгут одноразовый токен | Лок на тенант + перечитывание под локом |
Провал refresh маскируется под успех | Либо валидный токен, либо исключение |
| Атомарный |
Захардкоженный хост OAuth | Хост из |
Ключ портала — домен |
|
Токены в логах открытым текстом | Маскировка секретов в логах |
batch > 50 команд молча обрезается | Автонарезка любого количества |
Ручная пагинация | Генератор с быстрым режимом |
Валидация событий по | По |
Дальше — по самым дорогим, с кодом.
Грабля первая, самая дорогая: обрезанный конфиг
Начну с той, что стоила дороже всего.
Стоковый CRest сохраняет токены так:
file_put_contents($_SERVER['DOCUMENT_ROOT'].'/settings.json', $data);
Одна строка, ничего лишнего. И ровно здесь зарыт баг, который однажды уронил моё приложение на шесть суток.
Что произошло. file_put_contents без блокировки не атомарен: если процесс прервётся посреди записи — упадёт воркер, кончится место на диске, придёт другой запрос параллельно — файл останется записанным наполовину. А теперь ключевое: при чтении наполовину записанного settings.json парсинг JSON падает, и стоковый код интерпретирует это как «токенов нет, приложение не установлено».
Приложение считало себя неустановленным. Оно перестало отвечать на события портала, и, что хуже, при следующей попытке «переустановиться» затирало остатки валидных токенов. Шесть суток ушло на то, чтобы понять, что дело не в API, не в токенах и не в правах, а в одной незаблокированной записи файла.
Лечится это атомарной записью — пишем во временный файл, потом атомарно переименовываем:
private function atomicWrite(string $file, string $payload): void { $tmp = $file . '.' . getmypid() . '.' . bin2hex(random_bytes(4)) . '.tmp'; if (@file_put_contents($tmp, $payload, LOCK_EX) === false) { throw new StorageException('cannot write tmp file: ' . $tmp); } if (!@rename($tmp, $file)) { @unlink($tmp); throw new StorageException('cannot rename ' . $tmp . ' -> ' . $file); } @chmod($file, 0640); }
rename() в пределах одной файловой системы атомарен на уровне ОС. Читатель всегда видит либо старый файл целиком, либо новый целиком — обрезка невозможна физически.

И вторая половина защиты — чтение. Раз уж на диске может оказаться битый файл (например, его пишет чужой код мимо нашего SDK), чтение идёт под разделяемым локом и с ретраями:
private function readLocked(string $file): ?string { for ($attempt = 0; $attempt < 3; $attempt++) { $fh = @fopen($file, 'rb'); if ($fh !== false) { if (@flock($fh, LOCK_SH)) { $raw = stream_get_contents($fh); @flock($fh, LOCK_UN); } @fclose($fh); } if (is_string($raw) && trim($raw) !== '') { return $raw; } usleep(50000); } return null; }
Пустая строка вместо токена больше никогда не превращается в «приложение не установлено» — она превращается в короткое ожидание и повторную попытку.
Грабля вторая: гонка за одноразовый токен
Вторая проблема появляется, как только у приложения есть фоновые воркеры — крон, обработчики очередей, что угодно параллельное.
refresh_token у Битрикса одноразовый: обменяли на новую пару токенов — старый refresh немедленно инвалидируется. Логично для безопасности, но смертельно для наивной реализации.
Представьте два крон-воркера, которые одновременно упёрлись в протухший токен. Оба читают из хранилища один и тот же refresh_token. Оба отправляют его на обновление. Первый успевает — получает свежую пару, старый refresh сгорает. Второй приходит со сгоревшим токеном и получает invalid_grant. Портал отваливается целиком, до ручной переустановки.

Решение — эксклюзивный лок на тенант и, что важно, перечитывание токена уже под локом:
$storage->withLock($tenantKey, function () use ($storage, $tenantKey, $credentials) { // перечитываем под локом — вдруг другой воркер уже обновил $token = $storage->load($tenantKey); if ($token->isFresh()) { return $token; // да, обновил — берём готовое } $fresh = $this->refresher->refresh($token, $credentials); $storage->save($tenantKey, $fresh); return $fresh; });
Первый воркер берёт лок и обновляет токен. Второй ждёт освобождения лока, перечитывает хранилище, видит уже свежий токен — и не делает второй refresh вовсе. Одноразовый refresh_token сжигается ровно один раз.
Грабля третья: ошибка, замаскированная под успех
Эта тонкая и оттого противная. В стоковом коде обработка ответа на обновление токена устроена так, что при определённых ошибках OAuth поле с ошибкой вычищается из ответа, и дальше данные сохраняются как ни в чём не бывало. В результате в settings.json записывается ответ без валидных токенов, а приложение считает, что всё прошло успешно.
Симптом — тот же отвал портала, но диагностировать его ещё сложнее: логи говорят «токен обновлён», а работать перестаёт.
В переписанной версии refresh либо возвращает полный валидный токен, либо кидает исключение — третьего не дано:
if (!empty($response['error'])) { throw new AuthException(sprintf('oauth refresh failed: %s (%s)', $response['error'], $response['error_description'] ?? '')); } if (empty($response['access_token']) || empty($response['refresh_token'])) { throw new AuthException('oauth response incomplete, keys: ' . implode(',', array_keys($response))); } return $token->withAuth($response);
И хранилище отказывается сохранять неполный токен — даже если его кто-то попытается записать:
public function save(string $tenantKey, Token $token): void { if (!$token->isComplete()) { throw new StorageException('refused to save incomplete token'); } $this->atomicWrite($this->file(), $this->encodeToken($token->toArray())); }
Битый токен теперь физически не может попасть в хранилище. Лучше громкое исключение, чем тихо мёртвый портал.
Грабля четвёртая: ключом должен быть member_id, а не домен
Это уже не про надёжность, а про архитектуру мультипортального приложения.
Стоковый CRest заточен под один портал: один settings.json, никакой концепции «какой портал сейчас». Когда приложение тиражное и стоит на сотнях порталов, нужно хранить токены каждого отдельно и уметь понять, от какого портала пришёл запрос.
Наивное решение — ключевать по домену портала (company.bitrix24.ru). И оно ломается в тот день, когда портал переезжает на другой домен. Такое бывает: компания меняет название, мигрирует с коробки в облако, переносит на свой домен. Домен меняется — все привязанные к нему конфиги теряются, приложение видит «новый» портал без токенов.
Правильный ключ — member_id. Это идентификатор, который Битрикс выдаёт порталу навсегда и который переживает смену домена. Домен остаётся как вторичный индекс для удобного поиска, но истина — в member_id.
// ключ тенанта — member_id, домен вторичен $memberId = $event['auth']['member_id']; $b24 = B24Client::fromRequest($storage, $credentials); // клиент уже привязан к member_id портала-отправителя
Отдельно к этому — проверка подлинности входящих событий. Стоковые примеры часто валидируют событие по access_token или по реферреру, что либо ненадёжно, либо ломается. Правильно — сверять application_token, который выдаётся при установке, и сравнивать его безопасным способом:
public function isAuthentic(TokenStorageInterface $storage): bool { $expected = $storage->loadAppToken($this->memberId); return $expected !== null && hash_equals($expected, $this->applicationToken); }
hash_equals вместо обычного сравнения — чтобы не утекала информация через время сравнения строк.
Что осталось от стокового CRest: совместимость
Здесь важный момент, из-за которого всё это можно внедрять постепенно.
У меня уже были проекты на стоковом CRest с вызовами вида CRest::call('crm.deal.get', [...]) по всему коду. Переписывать их разом — риск и трудозатраты. Поэтому в SDK есть слой совместимости: класс с той же сигнатурой, что у стокового CRest, но внутри — вся новая машинерия с атомарным хранилищем и гонкоустойчивым refresh.
// было — стоковый CRest $result = CRest::call('crm.deal.get', ['id' => 1]); // стало — тот же вызов, другой use сверху use Synapsea\B24\Compat\CRest; $result = CRest::call('crm.deal.get', ['id' => 1]);
Меняется одна строка use, весь остальной код остаётся как есть. Приложение получает защиту от гонок и обрезанных конфигов без переписывания бизнес-логики. Дальше можно мигрировать на полный API SDK постепенно, файл за файлом.
Честная ниша: чего SDK не делает
Теперь важное, чтобы не создавать ложных ожиданий. У Битрикса есть официальный современный SDK — b24phpsdk. Он делает вещь, которой у меня осознанно нет: типизированные обёртки по всем методам API, с DTO и автокомплитом по crm.deal.*.

Разница в фокусе. Официальный b24phpsdk — про удобство работы с методами API, но тянет за собой стек зависимостей (symfony-компоненты и прочее) и требует свежий PHP. На типичном клиентском шареде или на коробке он часто просто не ставится.
Мой SDK — про другое: транспорт и мультитенантность, ноль зависимостей, работает на PHP 8.1 и на любом шареде. Он не заменяет официальный SDK и не соревнуется с ним по типизации. Его ниша — быть надёжным фундаментом там, где стоковый CRest уже мал, а тяжёлый официальный SDK не встаёт по окружению.
Если вам нужны типизированные DTO по всем методам и окружение позволяет — берите официальный. Если нужен гонкоустойчивый мультипортальный транспорт на любом хостинге — вот тут мой.
Что ещё внутри
Коротко, без разворота, чтобы обозначить объём:
Массовые операции — автонарезка batch-запросов на пачки по 50 (стоковый молча отбрасывает лишнее), ретраи при QUERY_LIMIT_EXCEEDED с экспоненциальным backoff.
Постраничная выборка через генератор — fetchList() с быстрым режимом start=-1 и автофолбэком на классическую пагинацию.
Три бэкенда хранилища за общим интерфейсом — один файл, мультипортальный каталог, PDO. Свой можно дописать, реализовав интерфейс.
Маскировка секретов в логах — токены не утекают в лог открытым текстом.
Проактивное обновление токена для крон-воркеров — не дожидаясь expired_token посреди работы, а заранее, если до истечения осталось мало.
Установка и требования
Отдельно про то, почему я держал ноль зависимостей — это не самоцель, а прямое следствие того, где живут приложения Bitrix24.
Значительная часть заказных приложений разворачивается на клиентском хостинге, каким бы он ни был: часто это шаред с не самым свежим PHP и без возможности поставить произвольные системные пакеты. Официальный b24phpsdk требует PHP 8.2+ и тянет symfony-компоненты, carbon, money — на таком окружении он просто не встанет. Поэтому требования у моего SDK минимальные:
PHP ≥ 8.1 (протестировано на 8.1–8.3) ext-curl, ext-json ext-pdo — только если используете PDO-хранилище
Установка через composer стандартная:
composer require synapsea/b24sdk
А для совсем голого шареда, где composer недоступен, есть автолоадер без зависимостей:
require '/path/to/b24sdk/src/autoload.php';
Один принципиальный момент по безопасности, который стоковые примеры игнорируют: каталог с токенами должен лежать вне webroot. Стоковый CRest по умолчанию пишет settings.json в DOCUMENT_ROOT, то есть в общедоступную папку — токены портала оказываются в вебруте. В SDK путь к хранилищу задаётся явно и предполагается вне публичной директории.
Про лицензию
SDK выложен с открытым исходным кодом под лицензией FSL-1.1-ALv2. Это означает: свободное использование в любых проектах, включая коммерческие, но запрет на продажу форков самого SDK. Через два года каждая версия автоматически переходит под Apache 2.0.
Итог
Стоковый CRest — хороший способ показать, как работает REST API Битрикса, и плохой способ держать на нём продакшен. Он не переживает параллельных воркеров, не умеет мультипортальность и молча превращает сбой записи в мёртвое приложение. Всё это чинится, но чинить приходится каждому заново.
Я собрал эти исправления в один SDK: атомарное хранилище, гонкоустойчивый refresh, ключевание по member_id, drop-in совместимость со стоком для постепенной миграции. Ноль зависимостей, PHP 8.1, работает на любом шареде и коробке.
Исходный код открыт: github.com/ShyDamn/b24sdk. Если у вас были свои грабли со стоковым CRest — расскажите в комментариях, интересно свести список того, на что натыкаются все.
