За последние пару лет я сделал с десяток приложений для Маркетплейса Битрикс24 — от простых iframe-панелей до коннекторов мессенджеров и роботов для бизнес-процессов. На PHP, TypeScript и Python. И каждый раз одна и та же история: документация разбросана по пяти сайтам, половина примеров устарела, а реальные подводные камни обнаруживаются только в продакшене.

Эта статья — сборник всего, что я хотел бы знать перед тем, как начать. Не обзор REST API (он и без меня описан), а именно практические нюансы: что работает не так, как написано, что ломается тихо и как сделать приложение, которое будет стабильно работать на сотне порталов.


Типы приложений: что вообще можно сделать

Прежде чем лезть в код, стоит определиться, какой тип приложения вы делаете. От этого зависит архитектура.

iframe-приложение — самый распространённый тип. Ваш сервер отдаёт HTML-страницу, Б24 показывает её внутри iframe. Может открываться как отдельная страница в левом меню, как вкладка в карточке сделки/контакта или как виджет. Пользователь видит ваш интерфейс прямо внутри Б24.

Коннектор открытых линий (imconnector) — для интеграции мессенджеров. Сообщения из внешнего канала попадают в открытые линии Б24, менеджеры отвечают оттуда — ответ уходит обратно в мессенджер.

Чат-бот — живёт в чатах Б24, отвечает пользователям. Может быть привязан к открытой линии для автоответов клиентам.

Робот / действие бизнес-процесса (bizproc.activity) — появляется в конструкторе роботов. Срабатывает при смене стадии сделки, создании лида и т.д.

Placement — встраивание UI в конкретные места Б24: кнопка в карточке звонка, вкладка в сделке, элемент в меню CRM.

Одно приложение может быть всем сразу: iframe для настроек + коннектор для сообщений + робот для автоматизации. При установке всё регистрируется одним пакетом.


ONAPPINSTALL: первый запрос и первая ловушка

Когда пользователь устанавливает приложение, Б24 делает POST на ваш URL с OAuth-токенами. Казалось бы, всё просто — прочитал req.body.auth, сохранил. Но нет.

Проблема: формат данных

Б24 может прислать токены в разных форматах. Облачная версия шлёт одну структуру, коробочная — другую. При переустановке приложения — третью. Вот реальный код, который обрабатывает все варианты на TypeScript:

router.post('/install', async (req, res) => {
  let auth = req.body.auth; // Стандартный вложенный объект

  // Вариант: токены в корне body с другими именами полей
  if (!auth && (req.body.AUTH_ID || req.body.member_id)) {
    auth = {
      member_id: String(req.body.member_id || ''),
      domain: String(req.query.DOMAIN || req.body.domain || ''),
      access_token: String(req.body.AUTH_ID || ''),
      refresh_token: String(req.body.REFRESH_ID || ''),
      expires_in: parseInt(String(req.body.AUTH_EXPIRES || '3600')),
      client_endpoint: String(
        req.body.CLIENT_ENDPOINT || `https://${req.body.domain}/rest/`
      ),
      application_token: String(req.body.APPLICATION_TOKEN || ''),
    };
  }

  // Вариант: данные прямо в body без вложенности
  if (!auth && req.body.access_token) {
    auth = req.body;
  }

  // Вариант: данные в query (да, бывает)
  if (!auth && req.query.DOMAIN) {
    auth = {
      member_id: String(req.query.member_id || ''),
      domain: String(req.query.DOMAIN || ''),
      access_token: String(req.query.AUTH_ID || ''),
      // ...
    };
  }

На PHP это выглядит проще (потому что CRest SDK абстрагирует детали), но суть та же: нельзя рассчитывать на один формат.

Обязательный BX24.installFinish()

Ответная HTML-страница должна подключить JS-библиотеку Б24 и вызвать BX24.installFinish(). Без этого Б24 считает, что установка не завершена, и приложение будет в статусе «установка»:

<script src="https://api.bitrix24.com/api/v1/"></script>
<script>
  BX24.init(function () {
    BX24.installFinish();
  });
</script>

Частая ошибка — забыть про это и вернуть 200 OK с JSON. Установка вроде бы прошла, токены сохранились, но Б24 не «отпустит» пользователя из экрана установки.


Мультитенантность: один сервер, много порталов

Любой портал Б24 может установить ваше приложение. У каждого — свои токены, свои данные. Это мультитенантная архитектура. Вопрос: где хранить конфиги?

Вариант 1: файловая система (PHP)

Самый простой. Для каждого портала создаётся директория с конфигом:

config/
├── portal1.bitrix24.ru/config.php
├── portal2.bitrix24.ru/config.php
└── portal3.bitrix24.ru/config.php

При установке определяем домен портала и сохраняем туда токены. При запросе — читаем конфиг по домену из рефера:

$portal = Client::getPortalFromUrl($_SERVER['HTTP_REFERER']);
$bitrix = new Client($portal, $data);
$bitrix->refreshToken();

Плюс: просто, работает, не нужна БД. Минус: не скейлится, нет транзакций, при гонке двух запросов файл может повредиться.

Вариант 2: PostgreSQL (TypeScript, Python)

Для серьёзных приложений — база данных. Минимальная схема:

CREATE TABLE IF NOT EXISTS clients (
  id SERIAL PRIMARY KEY,
  member_id VARCHAR(255) UNIQUE NOT NULL,  -- уник. ID портала
  domain VARCHAR(255) NOT NULL,
  access_token TEXT NOT NULL,
  refresh_token TEXT NOT NULL,
  expires_at TIMESTAMP NOT NULL,
  application_token VARCHAR(255),
  client_endpoint VARCHAR(255),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

member_id — уникальный идентификатор портала на сервере авторизации. По нему определяете, от какого портала пришёл запрос. Все связанные сущности привязываются к client_id через foreign key.

Вариант 3: SQLite (Python)

Для легковесных приложений (чат-боты, простые роботы) — SQLite. Без отдельного сервера БД:

class Database:
    def __init__(self, path: str):
        self._path = path
        conn = connect(self._path, timeout=60.0)
        cursor = conn.cursor()
        cursor.execute("""
            CREATE TABLE IF NOT EXISTS bitrix_auth_settings (
                application_token TEXT NOT NULL,
                settings_key VARCHAR(10) NOT NULL,
                UNIQUE(settings_key)
            )
        """)
        conn.commit()
        conn.close()

Достаточно для приложений с небольшой нагрузкой. Не подходит, если ожидаете параллельные запросы от десятков порталов.


Обновление токенов: самый коварный баг

Токен доступа живёт 1 час. Refresh token — 28 дней. Если за 28 дней никто не зашёл в ваше приложение — refresh token протухает, и приложение ломается. Единственный способ починить — переустановить.

Но даже в рамках одного часа всё не так просто.

Проблема: URL для обновления

Для облачного Б24 на .bitrix24.ru обновление идёт через https://oauth.bitrix24.ru/oauth/token/. Но иногда этот URL не работает и нужен https://oauth.bitrix24.tech/oauth/token/. Для коробочной версии — через домен самого портала. И ещё некоторые конфигурации принимают только GET, а другие — только POST.

Рабочий подход — перебор:

function getOAuthTokenUrls(portalDomain?: string): string[] {
  const list: string[] = [];
  if (portalDomain) {
    const domain = portalDomain.replace(/^https?:\/\//, '').split('/')[0];
    if (domain.endsWith('bitrix24.ru')) {
      list.push('https://oauth.bitrix24.tech/oauth/token/');
      list.push('https://oauth.bitrix24.ru/oauth/token/');
    }
    list.push(`https://${domain}/oauth/token/`);
  }
  return list;
}

// Для каждого URL пробуем GET и POST
for (const url of urlsToTry) {
  for (const useGet of [true, false]) {
    const result = await tryRefresh(url, useGet, params);
    if ('auth' in result) {
      await db.updateClientTokens(memberId, result.auth);
      return result.auth;
    }
  }
}

Превентивное обновление

Не ждите, пока токен протухнет. Обновляйте заранее — за 5 минут до истечения:

export async function getValidAccessToken(memberId: string): Promise<string> {
  const client = await db.getClientByMemberId(memberId);
  const now = new Date();
  const expiresAt = new Date(client.expires_at);
  const bufferMs = 5 * 60 * 1000; // 5 минут запаса

  if (now.getTime() + bufferMs >= expiresAt.getTime()) {
    const newAuth = await refreshTokens(memberId, client.refresh_token, client.domain);
    return newAuth.access_token;
  }
  return client.access_token;
}

А чтобы не потерять refresh token за 28 дней — заведите cron, который обходит все порталы и обновляет протухающие токены.


iframe: ваш UI внутри Б24

Приложение отображается в iframe. Это накладывает ограничения.

Заголовки безопасности

Если используете helmet() в Express — он по умолчанию ставит X-Frame-Options: DENY. Ваше приложение не откроется в Б24:

app.use(helmet({
  contentSecurityPolicy: false,
  crossOriginEmbedderPolicy: false,
}));

// Явно убираем X-Frame-Options
app.use((req, res, next) => {
  res.removeHeader('X-Frame-Options');
  next();
});

Определение портала

Внутри iframe вам доступны query-параметры или данные из POST. Домен портала можно вытащить из HTTP_REFERER (PHP) или из req.query.DOMAIN (JS). Это нужно, чтобы определить, к какому клиенту относится запрос:

$portal = Client::getPortalFromUrl($_SERVER['HTTP_REFERER']);

JS-библиотека BX24

Для работы с контекстом Б24 из фронтенда подключайте:

<script src="https://api.bitrix24.com/api/v1/"></script>

Через BX24 можно получить данные текущего пользователя, сделки, контакта — и выполнять REST-вызовы с автоматической авторизацией, без ручного управления токенами.


Регистрация сущностей при установке

В зависимости от типа приложения, при установке нужно зарегистрировать разные вещи.

Коннектор открытых линий

await callBitrixMethod(clientEndpoint, 'imconnector.register', {
  ID: 'my_connector',
  NAME: 'My Connector',
  ICON: { DATA_IMAGE: '<svg>...</svg>', COLOR: '#25D366' },
  PLACEMENT_HANDLER: `${APP_URL}/placement`,
}, accessToken);

Подписка на события

const events = [
  'OnImConnectorMessageAdd',
  'OnImConnectorMessageDelete',
];

for (const event of events) {
  try {
    await callBitrixMethod(clientEndpoint, 'event.bind', {
      event,
      handler: `${APP_URL}/webhooks/bitrix`,
    }, accessToken);
  } catch (error) {
    // ВАЖНО: event.bind кидает ошибку при дубликате
    if (isAlreadyBoundError(error)) continue;
    throw error;
  }
}

event.bind не возвращает false при повторной привязке — он выбрасывает ошибку. При переустановке приложения события уже привязаны, и без обработки дубликатов установка упадёт.

Робот (действие бизнес-процесса)

await callBitrixMethod(clientEndpoint, 'bizproc.activity.add', {
  CODE: 'my_robot',
  HANDLER: `${APP_URL}/bizproc/robot`,
  NAME: { ru: 'Моё действие' },
  DESCRIPTION: { ru: 'Описание действия' },
  PROPERTIES: {
    message: { Name: { ru: 'Сообщение' }, Type: 'string', Required: 'Y' },
  },
  USE_PLACEMENT: 'Y',
  PLACEMENT_HANDLER: `${APP_URL}/bizproc/placement`,
}, accessToken);

После выполнения робот обязан вернуть результат через bizproc.event.send — иначе бизнес-процесс зависнет:

await callBitrixMethod(endpoint, 'bizproc.event.send', {
  EVENT_TOKEN: eventToken,
  RETURN_VALUES: { status: 'ok' },
  LOG_MESSAGE: 'Действие выполнено',
});

Очереди и фоновая обработка

Некоторые задачи нельзя выполнить за один HTTP-запрос. Пример: обновить поля у всех контактов портала (тысячи записей). REST API отдаёт по 50 элементов за вызов, лимит — 2 запроса в секунду. Тысяча контактов = минуты работы.

Решение — очереди. Пользователь нажимает кнопку → приложение создаёт файл задачи → cron подхватывает и обрабатывает порциями:

// Пользователь нажал "Запустить обработку"
$contact->createProcessFile($portal, $categories);
// Создаётся файл queue/portal.bitrix24.ru__contact.json

// Cron (каждую минуту):
$queue = array_diff(scandir($dir), ['..', '.']);
$file = array_values($queue)[0];
$data = json_decode(file_get_contents("$dir/$file"), true);

$result = $contact->process($data);

if ($result['finish']) {
    unlink("$dir/$file");
    // Уведомляем пользователя через им-нотификацию
    $bitrix->request('im.notify.system.add', [
        'USER_ID' => $data['user'],
        'MESSAGE' => 'Обработка завершена'
    ]);
} else {
    // Сохраняем прогресс и продолжим на следующем тике
    $data['start'] = $result['last'];
    file_put_contents("$dir/$file", json_encode($data));
}

Для TypeScript/Python — аналогично, но вместо файлов можно использовать Redis или PostgreSQL как очередь.

Важный нюанс: по завершении длительной операции уведомляйте пользователя через im.notify.system.add. Иначе он не узнает, что задача готова.


Подводные камни: сборник граблей

Собрал за годы разработки. Каждый пункт — реальный баг в продакшене.

event.bind кидает ошибку при дубликате. Не { result: false }, а HTTP-ошибку. Ловите и игнорируйте при переустановке.

OAuth URL для облака и коробки отличается. .bitrix24.ruoauth.bitrix24.ru или oauth.bitrix24.tech. Коробка → домен портала. GET vs POST — тоже отличается. Перебирайте варианты.

Refresh token протухает за 28 дней. Если приложение не обновляет токен 28 дней — оно ломается. Заведите cron для превентивного обновления.

imconnector.send.messages не возвращает ID отправленного сообщения. Если потом нужно удалить/обновить — ведите свой маппинг.

document_id в роботах приходит в разных форматах. LEAD_67, crm,CCrmDocumentLead,LEAD_67, 67. Парсите все:

function parseCrmDocumentId(documentId: string): string {
  const s = String(documentId || '').trim();
  const numOnly = s.replace(/\D/g, '');
  if (numOnly) return numOnly;
  const parts = s.split(',');
  const last = parts[parts.length - 1] || '';
  const match = last.match(/^(?:LEAD|DEAL)_(\d+)$/i) || last.match(/(\d+)$/);
  return match ? match[1] : s;
}

Rate limit: 2 запроса в секунду. На batch-операциях упираетесь мгновенно. Используйте batch (до 50 команд за вызов) и очереди.

iframe и helmet(). По умолчанию helmet ставит X-Frame-Options: DENY. Приложение не откроется в Б24. Отключайте CSP и X-Frame-Options для iframe-маршрутов.

application_token нужно проверять. Б24 присылает его при установке и при каждом событии. Сравнивайте — это защита от подделки.

Данные из формы installation могут прийти и в POST, и в GET, и в query. При переустановке формат может отличаться от первой установки. Проверяйте все источники.

bizproc.event.send обязателен. Если робот не вернул результат — бизнес-процесс зависает навсегда. Оборачивайте в try/catch и возвращайте event.send даже при ошибке.

Петля сообщений в коннекторах. Менеджер ответил → вы отправили в мессенджер → мессенджер прислал вебхук → вы попытались отправить обратно в Б24 → бесконечный цикл. Решается кэшем отправленных сообщений с TTL.


Чеклист перед модерацией

  1. HTTPS — обязательно, самоподписанные не принимаются

  2. BX24.installFinish() — страница установки должна его вызвать

  3. Переустановка — приложение не должно падать при повторной установке (дубликаты в БД, event.bind)

  4. Refresh token — автоматическое обновление, cron для протухающих

  5. Права — запрашивайте только те scope, которые реально используете

  6. Мультиклиентность — работа с несколькими порталами одновременно

  7. Обработка ошибок — приложение не должно показывать 500 при невалидных данных

  8. Уведомления — длительные операции должны уведомлять пользователя о завершении


Вопросы по нюансам разработки — в комментариях. Особенно приветствую вопросы про коннекторы мессенджеров (imconnector) и роботы (bizproc.activity) — по ним меньше всего документации, и большинство тонкостей приходится выяснять методом проб и ошибок.