За полгода написал множество интеграций с Битрикс24 в нескольких разных архитектурах. Для примеров продукт для маркетплейса, клиентский расчётный сервис, cron-синхронизатор legacy-БД без UI. Multi-tenant решён везде по-разному, и это не случайность: каждая архитектура точно ложится на своё поле применения. На одном из проектов сверху ещё накрутил лицензирование через отдельный «материнский» портал — control-plane на смарт-процессе, которого в документации Битрикс24 нет.
Внутри: сравнение трёх подходов к хранению OAuth-токенов, разбор общих деталей REST-API, которые пришлось учитывать во всех трёх, и отдельно — как выглядит control-plane для маркетового приложения на смарт-процессе с бизнес-процессом.
Пять деталей OAuth в Битрикс24, касающиеся всех трёх подходов
Прежде чем зайти в архитектурные различия, пробегусь по деталям, которые пришлось обходить в каждом из трёх проектов. Они не документированы в явном виде или размазаны по разным разделам API-доков.
oauth.bitrix24.tech для .ru-доменов. У порталов с .bitrix24.ru иногда не работает рефреш через сам домен клиента: при определённых состояниях учётной записи возвращается 400/403. Обход — через прокси oauth.bitrix24.tech, который Битрикс держит именно для таких случаев. Порядок попыток в моих проектах: сначала oauth.bitrix24.tech, потом oauth.bitrix24.ru, потом домен клиента, потом дефолтный OAuth-URL из конфига. Это исторический баг, но живой.
POST vs GET на OAuth-эндпоинте. Некоторые порталы возвращают 400 на POST, но корректно отдают токены на GET с параметрами в query-string. Отдельно этого нигде не написано, а в проде встречается. В коде хожу с фолбэком: POST не сработал — пробую GET.
Rate limit ~2 req/sec для входящих вебхуков. Официальный лимит для webhook-режима держится в районе двух запросов в секунду. В коде минимальный интервал 550 мс между вызовами плюс rate-limiter на клиенте. Для OAuth-приложения лимиты выше и менее строгие, но при активной cron-синхронизации даже они реально мешают.
Batch максимум 50 команд. batch в REST принимает до 50 команд в одном хите. При синхронизации из внешней базы это ключевая цифра для батчинга: иначе цикл в 60 секунд сжигает лимиты. В синхронизаторе (см. третий проект ниже) батчу по 50 команд подряд, если больше — режу на подкоманды.
Буфер 5 минут до истечения токена. Access-token живёт час. Рефрешить надо не в момент истечения, а с запасом на сетевые задержки и retry. У меня жёстко 5 минут. Это простое решение проблемы «токен протух прямо во время долгого cron-скрипта».
С этим общим фоном захожу в различия.
Архитектура 1: файлы и CRest
Проект — отраслевое приложение для Битрикс24 Маркета, PHP-стек. Сейчас шесть боевых порталов, потолок по этой архитектуре — примерно до пары десятков без переделки.
Идея простая. Каждый портал получает свою директорию в config/[domain]/. Внутри — config.php с OAuth-токенами и app_config.json со специфичными для портала ID сущностей (смарт-процессы, поля, списки). Управляет этим кастомный класс Client, который резолвит домен из $_SERVER['HTTP_REFERER'] (или явно передаётся в конструктор), загружает конфиг и делегирует вызовы REST библиотеке CRest.
Скелет класса:
class Client { protected $portal; protected $access_token; protected $refresh_token; protected $client_id; protected $client_secret; protected $server_endpoint = ''; public function __construct($portal = null, $auth = []) { $this->portal = $portal ?? self::getPortalFromUrl($_SERVER['HTTP_REFERER'] ?? ''); $this->loadConfig($auth); } protected function loadConfig($auth = []) { $configFile = $this->getConfigDir() . '/config.php'; if (file_exists($configFile)) { $config = include $configFile; $this->access_token = $config['Доступы']['access_token'] ?? ''; $this->refresh_token = $config['Доступы']['refresh_token'] ?? ''; $this->client_id = $config['Доступы']['client_id'] ?? ''; $this->client_secret = $config['Доступы']['client_secret'] ?? ''; $this->server_endpoint = $config['Доступы']['server_endpoint'] ?? ''; } if (!empty($auth)) { // обновляем из handler'а установки и сохраняем в файл } } protected function getConfigDir() { return __DIR__ . '/../config/' . $this->portal; } public function request($method, $params = []) { $_REQUEST['DOMAIN'] = $this->portal; require_once __DIR__ . '/../crest.php'; return CRest::call($method, $params); } }
Работает это так. Пользователь заходит с портала, клиент детектит домен, поднимает из файла его конфиг, передаёт домен в $_REQUEST['DOMAIN'] и дальше CRest сам разбирается с рефрешем токенов, сохраняя обновлённые значения обратно в тот же файл.
Плюсы очевидные. Никакой БД. Разворачивается на любом shared-хостинге. Все данные портала лежат в одной директории — удобно бэкапить, копировать, диагностировать. Каждый портал изолирован файлово: сломался конфиг одного — остальные работают.
Минусы вылезают на масштабе и в concurrent-сценариях. Если у одного портала одновременно уходят два запроса, и у обоих истёк access_token, оба стартуют рефреш. Второй записывает свои токены поверх первого, и его же следующий запрос отваливается с expired_token. Классический race condition, решаемый файловым локом на config.php на время рефреша. У меня это вкручено, но по умолчанию в CRest этого нет.
Второе. Когда порталов десятки, config/ начинает раздуваться, поиск нужного домена по HTTP_REFERER иногда врёт (например, если приложение открыто во вкладке отдельно от портала), и появляется соблазн собрать индекс. В этот момент понимаешь: индекс — это уже БД. Пора переезжать.
Для 5–15 порталов файловая архитектура — оптимум. Дальше либо переписывать, либо мириться со странностями. Мой текущий продукт в этой зоне.

Архитектура 2: БД и явное управление токенами
Второй проект — клиентский расчётный сервис. Стек Node.js + TypeScript + PostgreSQL, в Docker. По задаче — single-tenant, один портал. Но код я писал multi-portal-ready сразу: заказчик планировал масштабировать сервис на нескольких своих компаний, и переделывать потом всё равно пришлось бы.
Хранение — таблица portals в PostgreSQL. Одна строка на портал, upsert по домену:
export async function upsert(input: UpsertInput): Promise<PortalAuth> { const { rows } = await getPool().query( `INSERT INTO portals (domain, member_id, access_token, refresh_token, server_endpoint, expires_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (domain) DO UPDATE SET member_id = COALESCE(EXCLUDED.member_id, portals.member_id), access_token = EXCLUDED.access_token, refresh_token = EXCLUDED.refresh_token, server_endpoint = COALESCE(EXCLUDED.server_endpoint, portals.server_endpoint), expires_at = EXCLUDED.expires_at, updated_at = NOW() RETURNING *`, [input.domain, input.memberId ?? null, input.accessToken, input.refreshToken, input.serverEndpoint ?? null, input.expiresAt] ); return rowToPortal(rows[0]); }
Обновление токенов — своя функция, не CRest, не готовая обёртка. Это принципиально: там, где логика может свалиться (сеть, expired refresh, портал вернул error_description), хочу видеть весь путь целиком.
export async function refreshTokensForPortal(portal: PortalAuth): Promise<PortalAuth> { const params = buildRefreshParams(portal.refreshToken); const urls = getOAuthUrlsToTry(portal.domain); // .tech → .ru → домен клиента → дефолтный OAuth-URL let lastError: BitrixError | null = null; for (const url of urls) { try { const response = await tryRefresh(url, params); // внутри tryRefresh: сначала POST, при 400 — GET if (response.access_token) { const expiresAt = new Date( Date.now() + (response.expires_in ?? 3600) * 1000 ); return await repo.upsert({ domain: portal.domain, memberId: response.member_id, accessToken: response.access_token, refreshToken: response.refresh_token, serverEndpoint: response.server_endpoint, expiresAt, }); } } catch (e) { lastError = e as BitrixError; } } throw lastError ?? new BitrixError('OAuth refresh failed'); }
На каждый вызов REST-метода в клиенте я сначала смотрю expires_at в БД (с буфером 5 минут), при необходимости освежаю токены, потом делаю сам запрос. Если запрос вернулся с expired_token — принудительный рефреш и один повтор.
Ключевой выигрыш — race condition решается на уровне БД. Атомарность INSERT ... ON CONFLICT DO UPDATE гарантирует, что одновременный рефреш от двух процессов даст последовательное перезаписывание, а не потерянный токен. При необходимости можно добавить SELECT FOR UPDATE на строку портала перед рефрешем, но у меня в проде обходится без него.
Дальше по мелочи. Масштабируется на любое количество порталов. Легко строить статистику: последний успешный запрос, время рефреша, ошибки. Легко ставить алерты (rows без обновлений сутки — портал отвалился, скорее всего сменил тариф или удалили приложение).
Цена — оверхед. Нужна БД, миграции, инфра, хотя бы Docker-composed Postgres рядом с приложением. Для 1–3 порталов избыточно.
Итог: если приложение стоит на нескольких порталах и планируется рост, БД — правильный дефолт. Если ты в single-tenant без роста — можно оставаться на файлах.
Архитектура 3: без OAuth, только входящий вебхук
Третий проект — cron-синхронизатор legacy-CRM с Битрикс24. Каждые 60 секунд забираем новые записи из внешней базы, мапим поля, ищем-обновляем-создаём контакты и сделки в Битрикс. UI нет, интерфейса нет, приложением в Маркете это не является. Есть только серверный процесс на клиентском сервере.
В такой конфигурации OAuth не нужен вообще. Единственный контакт с Битрикс — входящий вебхук, привязанный к техническому пользователю с нужными правами. Токен зашит в env-переменной сервера, живёт бесконечно (пока пользователя не отключат или не сменят ключ вебхука).
class BitrixClient { private const RATE_LIMIT_MS = 550; // ~2 req/sec с запасом private const MAX_BATCH_COMMANDS = 50; private const MAX_RETRIES = 3; public static function fromEnv(LoggerInterface $logger): self { return new self( webhookUrl: $_ENV['B24_WEBHOOK_URL'], logger: $logger, batchSize: (int)($_ENV['SYNC_BATCH_SIZE'] ?? 50) ); } public function batch(array $commands): array { $chunks = array_chunk($commands, self::MAX_BATCH_COMMANDS, true); $results = []; foreach ($chunks as $chunk) { $this->respectRateLimit(); $results[] = $this->request('batch', ['cmd' => $chunk, 'halt' => 0]); } return array_merge(...$results); } }
Cron разбит по частоте.
# Входы/выходы — раз в минуту * * * * * cd /opt/sync && php bin/sync-entries.php >> logs/cron-entries.log 2>&1 # Ежедневная синхронизация — 09:35 35 9 * * * cd /opt/sync && php bin/sync-daily.php >> logs/cron-daily.log 2>&1 # Полная выгрузка — по понедельникам в 09:35 35 9 * * 1 cd /opt/sync && php bin/sync-weekly.php >> logs/cron-weekly.log 2>&1
Плюсы. Никакого рефреша, никакой БД для токенов, никаких OAuth-плясок. Клиент очень простой, весь код прямолинейный. Rate limiter встроен на уровне HTTP-клиента, батчинг из коробки. Cron-расписание видно в одном файле.
Ограничения. Приложение живёт только на одном портале, на том, где создан вебхук. Multi-tenant так не строится. Права вебхука глобальные, не привязаны к конкретному пользователю приложения. И если portal admin по какой-то причине вебхук удалит или сменит ключ, интеграция молча остановится. Внешний мониторинг успешности запросов — обязательная часть развёртывания.
Для интеграций без UI и без масштабирования на множество порталов — идеально. Никакого лишнего.
Лицензирование через материнский смарт-процесс
Теперь про то, чего вообще нет в документации Битрикс24.
Продукт из первой архитектуры — коммерческий, у него есть тарифы, лимиты запросов на период, счётчик использования, платное и бесплатное разрешение. И этот продукт установлен на шести разных клиентских порталах. Естественный вопрос: где хранить логику лицензирования, если сам продукт распределён по клиентским порталам, а централизованного backend’а у него нет?
Мой ответ — на «материнском» портале агентства. У нас есть отдельный служебный портал Битрикс24 с включённым модулем CRM, где живёт смарт-процесс «Лицензии». Один элемент смарт-процесса — одна лицензия одного клиента. В его полях: домен клиента, тарифный план, даты начала и конца, счётчик запросов за период, лимит запросов, флаг approve/decline, флаг коммерческого разрешения.
Клиентское приложение при каждом использовании стучится на материнский портал через входящий вебхук и:
Проверяет флаг approve — если false, показывает пользователю блокирующий экран.
Инкрементит счётчик запросов.
Смотрит лимит и оставшиеся запросы — если исчерпаны, тоже блокирует.
Все вызовы идут напрямую через curl к вебхуку материнского портала, без OAuth и без CRest. У приложения на клиентском портале есть свой набор OAuth-токенов для работы с самим клиентским порталом, но с материнским оно общается вообще по другой оси.
Скелет сервиса:
class ParentPortalService { private const WEBHOOK_URL = 'https://<parent-portal>/rest/<uid>/<key>/'; private const WEBHOOK_SECRET = '<из env>'; private const ENTITY_TYPE_ID = /* ID смарт-процесса Лицензии */; private const BIZPROC_TEMPLATE_ID = /* ID шаблона БП «превышение лимита» */; public function callWebhook(string $method, array $params = []): ?array { $url = rtrim(self::WEBHOOK_URL, '/') . '/' . $method; $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query($params), CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, ]); $response = curl_exec($ch); curl_close($ch); return json_decode($response, true); } public function findLicenseByDomain(string $domain): ?array { return $this->callWebhook('crm.item.list', [ 'entityTypeId' => self::ENTITY_TYPE_ID, 'filter' => ['ufCrmXXDomain' => $domain], ]); } public function incrementRequestCounter(int $licenseId): void { // crm.item.update — плюс один к счётчику; БП на изменение счётчика // на стороне материнского портала сам сравнит лимит и, при превышении, // сдвинет approve в false } public function isApproved(int $licenseId): bool { // читаем ufCrmXX_APPROVE } }
Хитрости, которые вылезли по ходу.
companyId в crm.item работает только с включёнными Клиентами. У смарт-процесса есть флаг isClientEnabled. Если он выключен, привязать элемент к компании через параметр companyId в REST не получится: придётся использовать пользовательское поле-привязку типа E с форматом значения CO_{ID}. У лицензионного смарт-процесса Клиенты обычно выключены (не нужны), поэтому привязка идёт через UF-поле.
Бизнес-процесс на изменение счётчика. У смарт-процесса есть шаблон БП, который запускается при каждом изменении поля-счётчика. БП делает следующий шаг после инкремента: сравнивает счётчик с лимитом, при превышении меняет флаг approve на false, шлёт письмо ответственному менеджеру агентства. То есть логика «лицензия исчерпана» полностью живёт на материнском портале, приложение о ней не знает — оно просто спрашивает флаг approve при каждом использовании.
Approve/decline вебхуки на клиентской стороне. Помимо того, что клиентское приложение стучится на материнский портал, есть обратный канал. Агентство на своём материнском портале в БП или руками меняет флаг approve, и это моментально видит клиентское приложение при следующем запросе. Для критических изменений (например, «отключить с сегодня в связи с окончанием договора») есть выделенные REST-эндпоинты приложения, которые материнский портал дёргает целенаправленно — с secret-ключом в query-string.
// api/webhook_approve.php на стороне клиентского приложения if (($_GET['secret'] ?? '') !== ParentPortalService::WEBHOOK_SECRET) { http_response_code(403); exit(json_encode(['error' => 'Invalid secret'])); } $licenseId = (int)($_GET['ID'] ?? 0); $service = new ParentPortalService(); $success = $service->setApproved($licenseId, true); echo json_encode(['success' => $success]);
Смысл конструкции — держать всю бизнес-логику лицензирования в одном месте (материнский портал агентства) и не дублировать её в шести клиентских экземплярах. Смарт-процесс, БП и вебхуки дают полноценный лицензионный control-plane, не требующий отдельного backend’а. И — что мне важно как основателю агентства — полностью управляемого стандартной админкой Битрикс24. Менеджер агентства управляет лицензиями через ту же CRM, в которой ведёт остальных клиентов.
Это не документированный паттерн, но он рабочий уже несколько месяцев и не требует ничего сверх штатной функциональности Битрикс24: смарт-процессы, БП, входящие вебхуки.

Что выбрать под задачу
Итоговая матрица по моему опыту.
До 15 порталов, приложение в Маркете, PHP-стек. Файлы + CRest. Разворачивается на shared-хостинге, никакой БД. Обязательно вкрутить файловый лок на config.php при рефреше.
Больше 15 порталов или планируется рост. БД, свой рефреш, свои retry. Node.js + Postgres, Python + Postgres, PHP + MySQL — стек вторичен. Первично: атомарность обновления токенов и явное expires_at.
Интеграция без UI, без масштабирования на множество порталов. Входящий вебхук, никаких OAuth. Rate limiter и batching в клиенте, cron в отдельном файле.
Продукт с монетизацией и множеством клиентских порталов. БД (или файлы, если порталов немного) плюс материнский control-plane на смарт-процессе. Логику лицензирования на клиентские экземпляры не тащите — она принадлежит агентству.
Заключение
Эти три архитектуры не заменяют друг друга, они закрывают разные ниши. Одну и ту же задачу можно решить любой из них, но правильный выбор экономит месяцы поддержки.
Если бы начинал сейчас, я бы в первом Маркет-приложении сразу пошёл в БД, а не в файлы. Не потому что файлы не работают, а потому что переезд с файлов в БД, когда порталов уже пять-шесть, стоит дороже, чем один раз потратить пару вечеров на нормальную схему в начале. Со вторым и третьим проектом менять ничего не хочется — они на своих местах.
Лицензирование через материнский смарт-процесс — то, чем я, пожалуй, доволен больше всего за эти полгода. Не пришлось строить отдельный backend, не пришлось учить менеджера агентства работать в чужой админке, вся история клиента живёт рядом с его карточкой в CRM. И это ровно то, для чего Битрикс24 хорошо подходит: когда бизнес-процесс уже описан платформой, дописывать поверх минимум своего.
