За полгода написал множество интеграций с Битрикс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, флаг коммерческого разрешения.

Клиентское приложение при каждом использовании стучится на материнский портал через входящий вебхук и:

  1. Проверяет флаг approve — если false, показывает пользователю блокирующий экран.

  2. Инкрементит счётчик запросов.

  3. Смотрит лимит и оставшиеся запросы — если исчерпаны, тоже блокирует.

Все вызовы идут напрямую через 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?
Материнский портал агентства как 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 хорошо подходит: когда бизнес-процесс уже описан платформой, дописывать поверх минимум своего.