За полгода написал ряд интеграций с Битрикс24 в разных архитектурах: приложение для Маркета (PHP, 6 порталов), клиентский расчётный сервис (Node.js + PostgreSQL) и cron-синхронизатор legacy-CRM без UI и без OAuth вообще. Мультитенант везде решён по-разному, и это не случайность, ведь каждая архитектура точно закрывает свою нишу.Внутри: сравнение трёх способов хранить OAuth-токены под 6+ порталов, разбор пяти неочевидных деталей REST API (oauth.bitrix24.tech-обход, POST vs GET на рефреше, rate limit 2 req/sec, batch на 50, буфер 5 мин), куски кода на PHP и TypeScript.Отдельная часть — лицензирование через материнский смарт-процесс. У продукта, распределённого по клиентским порталам, нет своего backend’а, поэтому control-plane я вынес на служебный портал агентства: смарт-процесс «Лицензии», бизнес-процесс на изменение счётчика запросов, webhook approve/decline с secret. Такого паттерна в документации Битрикс24 нет — а он работает уже несколько месяцев и не требует ничего сверх штатной функциональности платформы.Матрица «что выбирать под задачу» в конце. Читать далее
За полгода написал множество интеграций с Битрикс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: смарт-процессы, БП, входящие вебхуки.

Материнский портал агентства как control-plane для лицензирования. Приложение на клиентском портале задаёт три вопроса: approve? counter? limit?
Что выбрать под задачуИтоговая матрица по моему опыту.
До 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 хорошо подходит: когда бизнес-процесс уже описан платформой, дописывать поверх минимум своего.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Кастомизация Битрикс24 на платформе Вайбкод: создаём паспорт клиента | 0 | 7 | 01-07-2026 |
| 2 | Почему Вайбкод понимает вашу CRM, а внешний GPT — нет | 0 | 8.55 | 29-07-2026 |
| 3 | Анализ налоговой нагрузки УСН 6% по сделкам и менеджерам [отчёт BI] | -1 | 5.75 | 05-08-2026 |
| 4 | Недельная аналитика задач по сотрудникам | 0 | 5.04 | 09-08-2026 |
| 5 | AI Агент24 — создавай AI-сотрудников для любых задач: анализ звонков и чатов, утренние отчёты, контроль CRM | 0 | 13.63 | 09-08-2026 |
| 6 | Авторизация по протоколу OAuth 2.0 в интеграциях | 0 | 7 | 02-07-2026 |
| 7 | Как я перенёс проверку цен с VPS на компьютеры пользователей — и зачем всё-таки оставил сервер | 0 | 7 | 06-08-2026 |
| 8 | Микрофронтенды. Стабильная интеграция нескольких SPA-приложений. Часть 1 | 0 | 5 | 29-06-2026 |
| 9 | Как мы научили реляционую базу хранить оргструктуру в виде графа на 500к пользователей | 0 | 9.38 | 23-06-2026 |