Для AI-агентов: markdown этой страницы — /docs-content/infra/app-runtime.md индекс документации — /llms.txt

Авторизация в приложении на Black Hole

Black Hole — это закрытый режим сервера приложения: снаружи он недоступен напрямую, единственный вход — через платформенный шлюз Gateway. Когда сотрудник открывает приложение внутри Битрикс24 в одном из мест встраивания, Gateway проверяет, кто это, и кладёт данные пользователя прямо в заголовки запроса к серверу приложения. Приложению не нужны ни своя форма входа, ни хранение паролей: оно доверяет заголовкам от Gateway, а когда нужны данные портала — обращается к API за этого пользователя. Кто может открыть приложение, задаёт политика доступа — Доступ и режимы.

Gateway сам аутентифицирует пользователя и пробрасывает к приложению уже подписанный запрос. vibe_session_* не попадает в JS-доступное состояние приложения и не остаётся в браузере после первичного обмена. Приложение получает заголовок X-Vibe-Authorization: Bearer vibe_session_<…> и читает данные пользователя через GET /v1/me. Это BFF-паттерн (Backend-for-Frontend) — тот же, что у Cloudflare Access, Google IAP и AWS API Gateway.

Скоуп: vibe:apps (на стороне V1 API), внутри туннеля — без явного скоупа, Gateway сам управляет доступом.

Заголовки запроса · Параметры iframe · Данные пользователя · Примеры кода · Жизненный цикл сессии

Жизненный цикл запроса

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

Битрикс24 placement                       Backend (vibecode.bitrix24.tech)
   │                                         │
   ├── POST /v1/bitrix-handler ──────────────►│  exchange OAuth code,
   │     (B24 placement triple)               │  upsert AppUserToken,
   │                                          │  createAppSession (mint vibe_session_*),
   │                                          │  mint signed __init JWT (TTL 5 min)
   │                                          │
   │◄── 302 https://app-XXX/?placement=…&member_id=…[&placement_options=…]&__init=<jwt> ──┤
   │
Browser
   │
   ├── GET https://app-XXX/?placement=…&member_id=…[&placement_options=…]&__init=<jwt> ───►│ Gateway
   │                                                                     │  Priority 0 redeem:
   │                                                                     │   • verify JWT (HMAC-SHA256, "gateway-init")
   │                                                                     │   • subdomain binding
   │                                                                     │   • mark jti used (one-time)
   │                                                                     │   • store (sub, subdomain) → raw in cache
   │                                                                     │   • mint _vibe_gw cookie
   │                                                                     │
   │◄────────── 302 https://app-XXX/?placement=…&member_id=…[&placement_options=…] ───────┤
   │                                          Set-Cookie: _vibe_gw=…
   │
   ├── GET https://app-XXX/?placement=…&member_id=…[&placement_options=…] ──────────────►│ Gateway
   │   Cookie: _vibe_gw=…                                                │  Priority 1 (cookie):
   │                                                                     │   • resolve raw from cache
   │                                                                     │   • strip incoming X-Vibe-Authorization (anti-spoof)
   │                                                                     │   • inject X-Vibe-Authorization: Bearer vibe_session_…
   │                                                                     │
   │                                                                     ├── GET / ─────► App server
   │                                                                     │   X-Vibe-Authorization: Bearer vibe_session_…
   │                                                                     │
   │◄────────────────────── HTTP response ───────────────────────────────┤

Ключевая разница от стандартного OAuth:

  • vibe_session_* не попадает в JS-доступное состояние приложения и не остаётся в браузере. При первом открытии токен находится в поле r одноразового __init-JWT. JWT подписан, но не зашифрован: его payload можно прочитать обычным base64url-декодированием. Gateway сразу обменивает код, сжигает его jti и убирает __init из URL.
  • __init — одноразовый. Повторный обмен того же jti отклоняется. Время жизни — 5 минут. На медленных сетях загрузки iframe этого хватает с запасом — если не уложитесь в 5 минут, проверьте журналы Gateway init code replay rejected / init code verify failed.
  • Cookie _vibe_gw живёт 10 минут — а у placement-приложений (открытых через встройку в Битрикс24) около 40 минут — и продлевается при активности. Не пытайтесь её читать в JS — она HttpOnly.
  • Пробуждение из сна: если сервер в SLEEPING, Gateway обменивает __init до страницы пробуждения, кладёт исходные данные в кэш и cookie в ответ. После переподключения агента браузер делает location.replace на чистый URL — кэш ещё не истёк, и X-Vibe-Authorization проставляется на первый же GET.

Что приходит в каждый запрос

Пример для приложения, слушающего 127.0.0.1:3000. Host относится к внутреннему адресу приложения, а не к публичному адресу в браузере. Публичный Host и нормализация Forwarded / X-Forwarded-* этим механизмом не гарантируются.

GET /api/tasks
Host: 127.0.0.1:3000

X-Vibe-Request-Id:   req_<16 hex chars>
X-Vibe-User-Id:      42
X-Vibe-Portal-Id:    f2342f7a-b1c5-4d50-8a3e-30219c5ae0c1
X-Vibe-User-Name:         Иван Иванов
X-Vibe-User-Name-Encoded: %D0%98%D0%B2%D0%B0%D0%BD%20%D0%98%D0%B2%D0%B0%D0%BD%D0%BE%D0%B2
X-Vibe-User-Role:    MEMBER
X-Vibe-Authorization: Bearer vibe_session_<43 chars>
X-Vibe-Public:       true                          ← только на сервере с политикой PUBLIC

Gateway может проставить до восьми платформенных заголовков на каждый проксируемый через него запрос — то есть на запрос из браузера, которым приложение открыли. X-Vibe-Request-Id приходит на любой такой запрос, X-Vibe-Public — на любой запрос к серверу с политикой PUBLIC, заголовки пользователя — когда посетитель опознан, а X-Vibe-Authorization — когда для него найден токен приложения. У внешнего API приложения путь другой, и заголовков личности на нём нет — смотрите оговорку ниже:

Заголовок Когда Значение
X-Vibe-Request-Id всегда Идентификатор запроса для корреляции в журналах. Префикс req_ + 16 hex-символов.
X-Vibe-User-Id аутентифицированный запрос Идентификатор посетителя. Либо числовой ID пользователя в Битрикс24 (например, 42) — совпадает с currentUser.bitrixUserId в ответе /v1/me, либо значение с префиксом, если ID в портале у посетителя нет (см. «Виды значений» ниже).
X-Vibe-Portal-Id аутентифицированный запрос UUID портала во внутренней БД Вайбкод. Не путать с доменом — он отдельно.
X-Vibe-User-Name аутентифицированный запрос Отображаемое имя пользователя в виде сырых UTF-8 байтов. Важно: большинство HTTP-стеков (Express и др.) читают значения заголовков как latin1 (RFC 7230 §3.2.4), поэтому локализованные имена отображаются нечитаемыми символами (Ðван вместо Иван). Для корректного имени используйте X-Vibe-User-Name-Encoded (ниже). Заголовок сохранён для обратной совместимости.
X-Vibe-User-Name-Encoded аутентифицированный запрос То же отображаемое имя, в процентном кодировании по RFC 3986 (UTF-8). Декодируется любым стандартным декодером: в JS — decodeURIComponent(req.headers['x-vibe-user-name-encoded']). Рекомендуемый источник имени вместо сырого X-Vibe-User-Name.
X-Vibe-User-Role аутентифицированный запрос ADMIN или MEMBER — роль текущего пользователя (того, кто открыл приложение) на портале. ADMIN означает право управлять настройками приложения — в том числе у владельца портала. Это надёжный способ определить, администратор ли текущий пользователь. Для произвольного сотрудника из GET /v1/users признак администратора недоступен — он приходит только в профиле текущего сотрудника. Важно: одно исключение — если приложение открыли из каталога Битрикс24, а у сотрудника ещё нет учётной записи Вайбкод, роль приходит MEMBER независимо от его прав на портале — брать её неоткуда, роль хранится в учётной записи. Не завязывайте на этот заголовок необратимые решения. Для проверки прав запросите профиль текущего сотрудника и прочитайте isAdmin.
X-Vibe-Authorization опознанный пользователь с токеном приложения Bearer vibe_session_<…> — токен доступа для серверных вызовов V1 API.
X-Vibe-Public запрос к серверу с политикой PUBLIC Всегда true. Обозначает политику сервера, а не авторизованность запроса: заголовок получает и анонимный, и опознанный посетитель.

Виды значений `X-Vibe-User-Id`

Вид значения Кто это
только цифры (42) пользователь портала Битрикс24
net_<id> пользователь Битрикс24 Нетворк, не состоящий в портале (политика «Аутентифицированные»)
share:<…> анонимный посетитель по гостевой ссылке
vibe:<…> пользователь платформы Вайбкод, у которого ID в портале определить не удалось

Прежде чем передавать значение в метод REST Битрикс24 — убедитесь, что оно состоит только из цифр. Значение с префиксом означает «у этого посетителя нет ID в портале», и подставлять его в USER_ID, DIALOG_ID или RESPONSIBLE_ID нельзя: Битрикс24 приводит строку к числу, и нечисловой идентификатор может превратиться в ID постороннего сотрудника. Заголовок может и вовсе отсутствовать — у анонимного посетителя на политике PUBLIC.

  • X-Vibe-Authorization — единственный источник Bearer-токена. Платформенный префикс X-Vibe- выбран, чтобы не конфликтовать с собственной Authorization-цепочкой приложения (например, если оно проксирует к своему вышестоящему API). Любой входящий X-Vibe-Authorization от клиента Gateway срезает до проброса — приложение никогда не получит подделанный токен.
  • X-Vibe-User-Id / -Portal-Id / -User-Name / -User-Role — быстрый способ опознать пользователя без дополнительного вызова /v1/me. Подходят для логов, аналитики и простого ACL. Для полного контекста (scopes, capabilities, tariff) всё равно делайте один вызов /v1/me и кэшируйте его на токен.
  • На внешнем API приложения этих заголовков нет. Вызов через Внешний API приложения приходит не из браузера и сессии не несёт, поэтому приложению не приезжают ни X-Vibe-User-*, ни X-Vibe-Authorization. Вместо них платформа ставит X-Vibe-Caller-Kind: external-api, X-Vibe-Caller-Portal-Id и X-Vibe-Caller-Key-Id — известны портал и ключ, но не сотрудник. Код, который читает личность посетителя, обязан различать два пути по X-Vibe-Caller-Kind.
  • Значения относятся к порталу, которому принадлежит приложение — независимо от того, открыл посетитель приложение плиткой из портала, из каталога платформы Вайбкод или по прямой ссылке. Участник нескольких порталов Битрикс24 приходит со своим номером сотрудника, именем и ролью именно в этом портале. Имя приходит из карточки сотрудника: при открытии плиткой и из каталога — из данных, сохранённых при авторизации приложения, по прямой ссылке — из портала. Карточка недоступна — платформа подставляет ближайшее известное значение (имя учётной записи или адрес почты); пустым заголовок не приходит.
  • Имя — отображаемое значение, а не идентификатор. Оно меняется вслед за карточкой сотрудника и может быть снимком прошлой авторизации. Не используйте его как ключ учётной записи и не стройте на нём проверки доступа — для этого есть X-Vibe-User-Id.
  • Если актуальное имя нужно для бизнес-логики, передайте числовой X-Vibe-User-Id в GET /v1/users/:id. Метод читает карточку сотрудника портала и не зависит от того, как пользователь открыл приложение.
  • Имя пользователя — читайте X-Vibe-User-Name-Encoded, не X-Vibe-User-Name. Сырой X-Vibe-User-Name несёт UTF-8 байты, которые HTTP-стеки трактуют как latin1 → кириллица и локализованные имена отображаются нечитаемыми символами. Корректное имя: const name = decodeURIComponent(req.headers['x-vibe-user-name-encoded'] ?? ''). Старый заголовок оставлен как есть, чтобы не ломать приложения, уже использующие обходной путь Buffer.from(name, 'latin1').toString('utf8').
  • X-Vibe-Request-Id — кладите в свой структурированный лог, чтобы трассировать запрос между Gateway и приложением. При обращении в поддержку Вайбкод присылайте этот ID — он есть и в журналах платформы.
  • Cookie: _vibe_gw — служебная сессия Gateway. Браузерный JS её не видит из-за HttpOnly, а Gateway вырезает её и другие платформенные cookie из заголовка Cookie перед отправкой в туннель. Сервер приложения _vibe_gw не получает.
  • access_token / auth[*] в теле запроса — приходят без изменений вместе с остальным телом. На сервере с политикой PUBLIC обязательно проверяйте auth[application_token] (после разбора формы — auth.application_token) у событий Битрикс24, прежде чем доверять данным или выполнять действие.

При ответе приложения 301, 302, 303, 307 или 308 платформа возвращает клиенту первый ответ, а не посещает следующий адрес сама. Статус, тело и обычные cookie приложения сохраняются. Следовать Location решает браузер или вызывающий HTTP-клиент.

Для запроса через публичный адрес приложения, включая разрешённый собственный домен:

Ответ приложения Что получает клиент
302, Location: /health Те же 302 и /health
302, Location: http://127.0.0.1:3000/health, приложение действительно доступно по этому внутреннему адресу 302 и /health на публичном origin приложения, например https://app-example.example.test/health
302, Location: https://provider.example.test/authorize Внешний адрес без изменений

Переписывается только абсолютный HTTP(S)-адрес с той же схемой, именем хоста и портом, что у фактического внутреннего адреса приложения. Путь, параметры и фрагмент сохраняются без декодирования. Другие адреса, включая //host/path, а также вложенный OAuth-параметр redirect_uri не исправляются. Для собственного OAuth настройте публичный callback URL в приложении и у провайдера.

Каждый обычный Set-Cookie передаётся отдельно. Имена cookie платформы, в частности _vibe_gw, session и oauth_state, остаются запрещёнными для приложения: используйте собственные имена. Существующее правило Domain не меняется — указанный домен заменяется поддоменом приложения на платформе, в том числе при обращении через собственный домен. Cookie без Domain этого ограничения не имеют.

Специальная политика /robots.txt продолжает действовать: редирект приложения может быть заменён локальным ответом платформы. Заведомо некорректный Location, например с %zz в пути, по-прежнему может привести к ошибке прокси. При сервисном вызове через Внешний API приложения выбранный публичный домен неизвестен, поэтому абсолютный Location не переписывается, а cookie ответа по-прежнему не выдаются вызывающему API.

URL-параметры iframe после placement-редиректа

После POST /v1/bitrix-handler платформа перенаправляет (302) браузер на <appUrl> и добавляет в строку запроса:

Параметр Когда Значение
placement всегда Код placement-а (LEFT_MENU, CRM_DEAL_DETAIL_TAB, IM_TEXTAREA, …). Совпадает с PLACEMENT в POST от B24.
member_id всегда Идентификатор портала Bitrix24 (тот же member_id, что в B24 OAuth).
placement_options когда B24 присылает PLACEMENT_OPTIONS — типично для DETAIL_TAB / DETAIL_TOOLBAR / LIST_MENU и других placement'ов, где есть контекст сущности JSON-строка с контекстом сущности — например {"ID":"42"} для CRM_DEAL_DETAIL_TAB. Сюда B24 кладёт ID текущей карточки (сделки/лида/контакта/компании).
__init первый запрос Одноразовый JWT для Gateway, обмениваемый на _vibe_gw cookie. После обмена — исчезает (замена на чистый URL).

Заметка по именованию: B24 шлёт это поле как PLACEMENT_OPTIONS (в верхнем регистре) в POST к /v1/bitrix-handler. Мы пробрасываем его под именем placement_options в нижнем регистре в URL iframe. Это одно и то же поле — ищите в спецификации B24 по имени в верхнем регистре.

Как читать placement_options — забирайте из URLSearchParams и парсите JSON:

JavaScript
const params = new URLSearchParams(window.location.search)
const placement = params.get('placement')                  // "CRM_DEAL_DETAIL_TAB"
const options = JSON.parse(params.get('placement_options') || '{}')
const entityId = options.ID                                // "42" — текущая карточка

Не используйте BX24.placement.info() — SDK не видит контекст при перенаправлении через платформенный обработчик и вернёт {placement:null, options:null}. Всё, что вам нужно про placement и его параметры, уже лежит в URL.

Открытие сущностей Битрикс24 из приложения

Приложение живёт на своём субдомене внутри iframe, а карточки сделок, задач и остальных сущностей — на портале. Поддерживаемый способ открыть карточку: увести верхнее окно на её адрес по клику пользователя.

JavaScript
// portal — домен, который сервер приложения получил из GET /v1/me.
// Только внутри обработчика клика. Навигации верхнего окна из вложенного
// iframe нужна transient user activation: на загрузке страницы её нет, а после
// await она может истечь или быть потреблённой другим API — навигация не сработает.
button.addEventListener('click', () => {
  window.top.location.href = `https://${portal}/crm/deal/details/${dealId}/`
})

Домен для такой ссылки берите только из доверенного источника, и он один: поле portal в ответе GET /v1/me. Это ответ платформы, привязанный к сессии вашего приложения, — подставить в него чужой домен снаружи нельзя.

document.location.ancestorOrigins[0] для этой задачи не подходит, хотя список и заполняет сам браузер. Его первый элемент — origin непосредственного родителя, а это не обязательно портал: во вложенном фрейме родителем окажется промежуточный сайт, и ссылка уведёт верхнее окно к нему. Сверять ancestorOrigins с доменом из /v1/me тоже смысла нет — раз доверенный ответ уже получен, домен берётся сразу из него.

Строку запроса собственного приложения доверенным источником не считайте. Её формирует тот, кто открыл ссылку, а не платформа, и подставить туда можно что угодно — в том числе правдоподобный домен. Навигация верхнего окна уводит пользователя туда, куда указывает домен, поэтому приложение, доверяющее домену из своего же URL, уводит пользователя на чужой сайт с адреса, которому тот доверял.

Штатный placement-редирект домен портала не передаёт — в нём только placement, member_id, placement_options и __init (у self-hosted вместо __init приходит code). Но если домен в строке запроса всё-таки оказался, это не признак того, что его прислала платформа: он мог попасть туда как угодно, и проверять его всё равно нужно по доверенному источнику.

Это навигация, а не слайдер поверх приложения. Верхнее окно уходит на страницу Битрикс24, приложение при этом размонтируется. Возврат — навигацией назад средствами Битрикс24.

Примеры адресов:

Сущность Адрес
Сделка /crm/deal/details/<id>/
Лид /crm/lead/details/<id>/
Контакт /crm/contact/details/<id>/
Компания /crm/company/details/<id>/
Предложение /crm/type/7/details/<id>/
Счёт /crm/type/31/details/<id>/
Элемент смарт-процесса /crm/type/<entityTypeId>/details/<id>/
Задача /company/personal/user/<responsibleId>/tasks/task/view/<id>/

Список рекомендательный, а не исчерпывающий: платформа адрес перехода не ограничивает — вести можно на любую страницу портала, которая открывается сама по себе.

Нативный BX24 JS SDK не работает, если приложение открыто через обработчик Вайбкод. BX24.init не завершается — и вместе с ним недоступны BX24.openPath и BX24.openSlider. Причина та же, что и у BX24.placement.info() выше: после перенаправления обработчика фрейм открыт на другом источнике и без контекста Битрикс24, которого SDK ждёт от родительского окна. Поэтому — навигация верхнего окна.

Так открываются все приложения Вайбкод — и на Black Hole, и с appUrl на вашем сервере: placement в обоих случаях приходит на обработчик платформы, а тот перенаправляет фрейм на адрес приложения. Исключение — собственное приложение Битрикс24 со своим обработчиком (см. Self-hosted приложение): placement приходит сразу на ваш источник, перенаправления нет, контекст SDK на месте — там BX24.init работает штатно и этот раздел не нужен.

Авто-высота iframe

Внутри Битрикс24 приложение открыто во встроенном iframe, и его высоту задаёт Битрикс24, а не контент: если страница выше окна, низ обрезается, собственного скролла у iframe нет. Чтобы платформа растила iframe под контент, приложение само сообщает свою высоту — постит сообщение родительскому окну (window.parent, не window.top). Платформенная обёртка ловит это сообщение и релеит высоту в Битрикс24, а тот подстраивает высоту плейсмента.

Обёртка включается у приложения отдельно. Пока она выключена, платформа открывает встройку прежним способом — перенаправлением на адрес приложения, и высота не подстраивается. Чтобы платформа отдавала обёртку, включите опцию «Авто-высота iframe» в карточке приложения либо передайте placementResizeEnabled: true в PATCH /v1/apps/:id.

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

После включения обёртки приложение может не открыться вовсе — проверьте свою директиву frame-ancestors. Под обёрткой приложение открыто во вложенном iframe на источнике платформы, то есть у приложения появляется новый источник-предок, а браузер сверяет frame-ancestors по всей цепочке предков. Приложение, разрешившее только домен портала, браузер не откроет, и средствами платформы это не исправить. Добавьте https://vibecode.bitrix24.tech в свою директиву frame-ancestors. Приложения без собственного заголовка Content-Security-Policy правок не требуют.

JavaScript
// Пересчитывайте при изменении контента — надёжнее всего через ResizeObserver на корне приложения.
// targetOrigin обязан быть '*' (или origin обёртки) — почему не домен портала, см. ниже.
const height = document.documentElement.scrollHeight
window.parent.postMessage({ type: 'vibe:resize', height }, '*')

targetOrigin — '*' или origin обёртки, но не домен портала. Браузер сверяет targetOrigin отправителя с origin окна-получателя ещё до доставки. Непосредственный родитель приложения под обёрткой — сама обёртка, а не портал, поэтому захардкоженный домен портала в targetOrigin заставит браузер отбросить сообщение раньше, чем обёртка его увидит, и со стороны платформы это уже не лечится. '*' безопасен: обёртка всё равно проверяет origin отправителя, прежде чем менять высоту.

Что принимается. Поле type — vibe:resize или vibe:setHeight. Высота — число пикселей в поле height, нечисловое значение отбрасывается. Обёртка не измеряет ваш контент за вас — высоту всегда несёт приложение, поэтому сообщение без числовой высоты не делает ничего.

Ещё раз про ancestorOrigins[0]. Под обёрткой предупреждение из раздела «Открытие сущностей Битрикс24 из приложения» становится буквальным: document.location.ancestorOrigins[0] — это origin обёртки (vibecode.bitrix24.tech), а не домен портала. Домен по-прежнему берите только из ответа GET /v1/me (поле portal) — единственного доверенного источника.

Получение данных пользователя (`GET /v1/me`)

X-Vibe-Authorization несёт только токен доступа, не данные пользователя. Чтобы узнать пользователя, портал, скоупы и capabilities — один раз вызываем /v1/me на сервере приложения и кэшируем в собственной сессии:

GET https://vibecode.bitrix24.tech/v1/me
X-Api-Key: <ключ-вашего-приложения>
Authorization: Bearer vibe_session_<…>     ← забрали из X-Vibe-Authorization

200 OK
{
  "success": true,
  "data": {
    "type": "oauth_app",
    "portal": "company.bitrix24.ru",
    "scopes": ["crm", "tasks", "imbot", ...],
    "currentUser": {
      "bitrixUserId": "42",
      "_note": "B24 numeric user id of the OAuth-authorized end user (from Bearer session)."
    },
    "capabilities": { "servers": { "create": { "available": true, "reason": "TRIAL_ACTIVE" }, ... }, ... },
    "tariff": { ... },
    "app": { "title": "My App", "id": "<uuid>" }
  }
}

Показаны характерные поля. В ответе также есть api (список доступных эндпоинтов и сущностей), oauth, oauthTutorial, deployment, infra, feedback и другие — модель использует их, чтобы построить запросы без чтения остальной документации.

Идентификатор пользователя — data.currentUser.bitrixUserId (строка с числовым ID Битрикс24). Не userId, не user.id, не верхнеуровневое поле — именно currentUser.bitrixUserId. Совпадает с заголовком X-Vibe-User-Id (см. секцию «Что приходит в каждый запрос») — но только когда тот несёт число: у посетителя без ID в портале заголовок приходит с префиксом (net_ / share: / vibe:). Для простой идентификации заголовка достаточно, для вызовов Битрикс24 — проверяйте, что значение состоит из цифр. /v1/me нужен, когда требуются scopes, capabilities, tariff или информация о приложении.

Без Authorization: Bearer тот же /v1/me отвечает с тем же набором полей, только currentUser будет null. Это рабочий режим: используется для серверной инициализации приложения до OAuth — портал, скоупы и capabilities уже видны. Не пытайтесь распознавать «нет сессии» по 401 — его не будет, проверяйте currentUser !== null.

Кэш — 24 часа TTL. После 401 на V1 — отправляйте пользователя обратно на placement (см. ниже).

Приложение как сервис: собственный ключ (X-Api-Key)

Всё выше — это модель per-user: приложение внутри Битрикс24 работает от имени текущего пользователя, форвардит X-Vibe-Authorization, и /v1/* читается его правами. Для неё нужен OAuth-app-ключ vibe_app_ (создаётся в разделе «Ключи авторизации») плюс проброшенная Bearer-сессия — личный ключ vibe_api_ Bearer-сессию нести и биндить placement не может.

Ключ сервера и ключ приложения — разные вещи. Сервер, на котором работает приложение, может принадлежать личному ключу vibe_api_: Gateway всё равно проставит X-Vibe-Authorization, потому что сессию выписал placement, а не ключ сервера. А вот в самом запросе к /v1/* пара заголовков обязана быть согласованной — X-Api-Key должен быть ключом того же приложения, которое выписало сессию в Authorization: Bearer, иначе ответ будет 403 SESSION_APP_MISMATCH.

Но приложение бывает и сервисом: сервер, бэкенд или дашборд, который сам дергает /v1/* без пользовательской сессии (например, отчёт по всей компании). Тогда оно аутентифицируется собственным личным ключом vibe_api_ в заголовке X-Api-Key. Ключ создаёт пользователь в разделе «API-ключи» (/keys), передаёт приложению через параметр env при деплое (например VIBE_API_KEY), а приложение читает его из окружения и шлёт как X-Api-Key. Авто-генерируемого «ключа сервера» не существует.

Как выбрать поток. Показываете данные, зависящие от текущего пользователя, — используйте per-user-поток выше с форвардом X-Vibe-Authorization. Один общий X-Api-Key читается как владелец ключа, а не текущий пользователь, и все посетители увидят данные владельца. Личный ключ обращается к Битрикс24 от имени владельца, поэтому для сервисного дашборда держите политику доступа сервера в OWNER_ONLY (см. Политика доступа) и выбирайте владельца ключа осознанно. Для отчёта, который только читает данные, выпускайте ключ в режиме READONLY с минимальными правами.

Авторизация после деплоя. Ключ запроса к POST /v1/infra/servers/:id/deploy не переносится в runtime автоматически. Успешный деплой с OAuth-app-ключом добавляет в warnings напоминание об этом и о том, что доступ приложения к порталу не проверен; в SSE оно приходит в событии done, у Galaxy — в JSON-ответе. Предупреждение не проверяет содержимое env и не утверждает, что у приложения нет учётных данных. Для per-user-запросов сервер приложения переносит значение входящего X-Vibe-Authorization в исходящий Authorization, а в X-Api-Key передаёт ключ того же приложения, которое выдало сессию. Для headless-сервиса явно передайте личный ключ через env с минимально необходимыми scopes и политикой доступа OWNER_ONLY, как описано выше.

Имена BITRIX_API_KEY и BITRIX_API_BASE_URL могут использоваться вашим кодом, но платформа не создаёт эти переменные автоматически. Их значения и чтение настраивает автор приложения; отсутствие этих имён в env не доказывает отсутствие учётных данных runtime. Полный контракт предупреждения — Деплой приложения.

Примеры кода

Node.js / Express

JavaScript
import express from 'express'
const app = express()

const VIBE_API = 'https://vibecode.bitrix24.tech'
const VIBE_APP_KEY = process.env.VIBE_APP_KEY  // ваш vibe_app_<…> ключ

app.use(async (req, _res, next) => {
  // Gateway уже всё проверил — но на всякий случай подстрахуемся
  const bearer = req.headers['x-vibe-authorization']?.toString().replace(/^Bearer /, '')
  if (!bearer) return next() // anonymous (PUBLIC policy) — кэшируем без identity

  // Кэшируем identity in-process per token (одно обращение к /v1/me на сессию).
  // /v1/me возвращает { success, data: { currentUser, portal, scopes, ... } } —
  // разворачиваем envelope, чтобы дальше работать с самими полями.
  let me = identityCache.get(bearer)
  if (!me) {
    const res = await fetch(`${VIBE_API}/v1/me`, {
      headers: { 'X-Api-Key': VIBE_APP_KEY, Authorization: `Bearer ${bearer}` },
    })
    if (!res.ok) return next() // 401 → показать экран повторной авторизации
    const json = await res.json()
    me = json.data
    identityCache.set(bearer, me)
  }
  req.user = me           // me.currentUser?.bitrixUserId, me.portal, me.scopes, me.capabilities
  req.bearer = bearer
  next()
})

app.get('/api/tasks', async (req, res) => {
  // currentUser может быть null, если запрос без Bearer пришёл (PUBLIC-политика).
  // Альтернатива — прочитать заголовок X-Vibe-User-Id (Gateway уже его проставил).
  // Заголовок может нести префиксованную идентичность (net_ / share: / vibe:) —
  // для вызовов Битрикс24 годится только числовое значение.
  const rawId = req.headers['x-vibe-user-id']
  const userId = req.user?.currentUser?.bitrixUserId ?? (/^\d+$/.test(rawId ?? '') ? rawId : null)
  const r = await fetch(`${VIBE_API}/v1/tasks?filter[RESPONSIBLE_ID]=${userId}`, {
    headers: { 'X-Api-Key': VIBE_APP_KEY, Authorization: `Bearer ${req.bearer}` },
  })
  res.json(await r.json())
})

Python / FastAPI

Python
from fastapi import FastAPI, Request, HTTPException
import httpx

VIBE_API = "https://vibecode.bitrix24.tech"
VIBE_APP_KEY = os.environ["VIBE_APP_KEY"]
app = FastAPI()
identity_cache: dict[str, dict] = {}

async def resolve_identity(request: Request):
    raw = request.headers.get("x-vibe-authorization", "")
    bearer = raw.removeprefix("Bearer ")
    if not bearer:
        return None
    if bearer in identity_cache:
        return identity_cache[bearer]
    async with httpx.AsyncClient() as c:
        r = await c.get(
            f"{VIBE_API}/v1/me",
            headers={"X-Api-Key": VIBE_APP_KEY, "Authorization": f"Bearer {bearer}"},
        )
    if r.status_code != 200:
        return None
    # /v1/me возвращает {"success": True, "data": {...}} — кладём в кэш именно data.
    me = r.json()["data"]
    identity_cache[bearer] = me
    return me

@app.get("/api/tasks")
async def tasks(request: Request):
    me = await resolve_identity(request)
    if not me:
        raise HTTPException(401, "Re-authentication required")
    # currentUser может быть None при запросе без Bearer (PUBLIC-политика); откат на заголовок Gateway.
    # Заголовок может нести префиксованную идентичность (net_ / share: / vibe:) —
    # для вызовов Битрикс24 годится только числовое значение.
    raw_id = request.headers.get("x-vibe-user-id") or ""
    user_id = (me.get("currentUser") or {}).get("bitrixUserId") or (raw_id if raw_id.isdigit() else None)
    async with httpx.AsyncClient() as c:
        r = await c.get(
            f"{VIBE_API}/v1/tasks",
            params={"filter[RESPONSIBLE_ID]": user_id},
            headers={"X-Api-Key": VIBE_APP_KEY,
                     "Authorization": request.headers["x-vibe-authorization"]},
        )
    return r.json()

Go / net-http

go
package main

import (
    "encoding/json"
    "net/http"
    "os"
    "strings"
    "sync"
)

const vibeAPI = "https://vibecode.bitrix24.tech"

var vibeAppKey = os.Getenv("VIBE_APP_KEY")
var identityCache sync.Map // bearer string -> *Identity

// CurrentUser совпадает по полям с объектом data.currentUser в ответе /v1/me.
// Когда запрос пришёл без Bearer (PUBLIC-политика) — data.currentUser приходит как null,
// и поле CurrentUser в раскодированной структуре остаётся пустым (BitrixUserID == "").
type CurrentUser struct {
    BitrixUserID string `json:"bitrixUserId"`
}

type Identity struct {
    Portal      string       `json:"portal"`       // домен портала, например "company.bitrix24.ru"
    Scopes      []string     `json:"scopes"`
    CurrentUser *CurrentUser `json:"currentUser"`  // nil без Bearer
}

type meEnvelope struct {
    Success bool     `json:"success"`
    Data    Identity `json:"data"`
}

func resolveIdentity(r *http.Request) (*Identity, string, error) {
    raw := strings.TrimPrefix(r.Header.Get("X-Vibe-Authorization"), "Bearer ")
    if raw == "" {
        return nil, "", nil
    }
    if v, ok := identityCache.Load(raw); ok {
        return v.(*Identity), raw, nil
    }
    req, _ := http.NewRequest("GET", vibeAPI+"/v1/me", nil)
    req.Header.Set("X-Api-Key", vibeAppKey)
    req.Header.Set("Authorization", "Bearer "+raw)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, "", err
    }
    defer resp.Body.Close()
    if resp.StatusCode != 200 {
        return nil, "", nil
    }
    var env meEnvelope
    if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
        return nil, "", err
    }
    identityCache.Store(raw, &env.Data)
    return &env.Data, raw, nil
}

// Пример использования: r.User.CurrentUser.BitrixUserID — числовой ID пользователя Битрикс24.
// Альтернатива — заголовок r.Header.Get("X-Vibe-User-Id"). Он может нести префиксованную
// идентичность (net_ / share: / vibe:) — для вызовов Битрикс24 проверьте, что это только цифры.

Жизненный цикл сессии

Одновременно действуют три независимых срока, и путать их не нужно:

  • Токен vibe_session_* живёт 24 часа (AppSession.expiresAt), и сама строка сессии не продлевается. Специально обновлять токен не нужно: при следующем резолве платформа найдёт ещё живую сессию или выпишет новую, пока у пользователя сохраняется выданный приложению доступ.
  • Сессия Gateway (_vibe_gw) живёт 10 минут, а у placement-сессий (приложение открыто через встройку в Битрикс24) — около 40 минут. Она продлевается при активности: запрос к серверу приложения во второй половине жизни сессии (для обычной — когда до истечения осталось меньше 5 минут, для placement — примерно после 20-й минуты) получает свежую cookie. Пока приложение получает запросы и политика доступа сервера пускает пользователя, сессия держится и заголовок X-Vibe-Authorization проставляется автоматически. Как сессия закрывается после смены политики или удаления записи — Политика доступа и Удалить запись доступа.
  • Пользовательский токен портала (AppUserToken.tokenExpiresAt) живёт столько, сколько сообщил Битрикс24 в expires_in (если значения нет — 3600 секунд), и платформа обновляет его автоматически, когда это возможно. Токен отдельный для каждой тройки «приложение + пользователь + портал» и появляется только после того, как этот пользователь открыл или авторизовал приложение через встройку Битрикс24. Если он ни разу этого не делал, токена нет и его вызовы V1 получают 401; доступ другого сотрудника это не заменяет.

Отсюда следствие, которое стоит учитывать: 401 не означает, что закончились именно 24 часа vibe_session_*. Gateway-сессия могла истечь раньше, пользователь мог ещё не выдать приложению доступ, а обновление токена портала могло не пройти. Например, если пользователь долго заполняет форму целиком в браузере и не обращается к серверу, Gateway-сессия истечёт; следующий запрос придёт в приложение без X-Vibe-Authorization, и вызов V1 вернёт 401, хотя vibe_session_* ещё действителен.

Как с этим работать:

  • На 401 от V1 — попросите пользователя заново открыть приложение из меню Битрикс24. Важно: это должно быть именно повторное открытие из меню (оно ре-POST-ит placement, и Gateway выдаёт свежую сессию), а не возврат на уже открытую вкладку — повторный показ кэшированной вкладки идёт обычным GET без нового POST и сессию не восстановит. Серверный редирект здесь не поможет: из iframe он выйти не может. Самостоятельно обновлять токен не нужно.
  • Не пишите «откройте из меню» как единственный текст ошибки. Совет верен только для истёкшей сессии. У 401 есть вторая причина: пользователь открывает приложение ПЕРВЫЙ раз и ещё не выдал ему доступ — тогда открытие из меню ничего не изменит, нужна авторизация приложения или один вход через встройку. Различить их на стороне приложения нечем, поэтому формулируйте нейтрально («нужно заново войти или выдать приложению доступ»). Первый вход из каталога платформа теперь перехватывает сама и показывает экран с кнопкой «Авторизовать приложение» — до вашего кода такой запуск не доходит.
  • Для длинных форм и редакторов — сохраняйте изменения по частям или держите сессию активной периодическим лёгким запросом к своему серверу, пока пользователь работает (любой запрос к приложению проходит через Gateway и продлевает сессию). Учтите: браузер тротлит фоновые вкладки, поэтому keepalive надёжен только пока вкладка приложения активна.
  • Серверная фоновая работа без пользователя (крон, интеграция) — отдельный случай: vibe_session_* привязана к конкретному пользователю и на сервере без его браузера не продлевается. Если нужен долгоживущий серверный доступ ОТ ИМЕНИ ВЛАДЕЛЬЦА сервера, используйте api-bearer-токен и его продление — POST /v1/infra/servers/:id/access-tokens/:tokenId/refresh (требует включённого на платформе раздела токенов доступа — Доступность). Такой токен действует от владельца, а не от текущего пользователя, — не подменяйте им пер-юзерные вызовы V1.
JavaScript
// По одному 401 нельзя отличить истёкшую Gateway-сессию от отсутствующего доступа.
// Верните отказ своему SPA и предложите пользователю заново войти или выдать доступ.
// Для восстановления Gateway-сессии нужно свежее открытие placement из меню Битрикс24.
if (vibeResponse.status === 401) {
  return res.status(401).json({ error: 'authorization_required', reopen: true })
}

Что не делать

  • Не вызывайте sessionStorage.setItem('vibe_token', …) — во время первого перехода токен кратковременно проходит через браузер внутри подписанного, но незашифрованного __init; Gateway сразу обменивает код, сжигает его jti и убирает параметр из URL. Токен не попадает в JS-состояние приложения, и помещать его в sessionStorage / localStorage нельзя: OAuth 2.0 Security BCP § 4.1 явно запрещает такое хранение access-токенов.
  • Не возвращайте токен в JSON-ответе SPA. X-Vibe-Authorization живёт исключительно между Gateway и сервером приложения. Раскрытие в ответе браузеру уничтожает всё преимущество BFF-паттерна (XSS → утечка токена).
  • Не читайте _vibe_gw cookie руками. Cookie помечена HttpOnly — JS даже не увидит её. Если хочется session ID для логирования — генерируйте свой.
  • Не используйте Authorization вместо X-Vibe-Authorization. Платформенный префикс выбран специально, чтобы приложение могло пользоваться своей собственной Authorization-цепочкой (например, для проксирования к своему вышестоящему сервису).
  • Не храните vibe_session_* где-либо за пределами одной HTTP-сессии. Это токен доступа, который действует 24 часа и охватывает весь V1 API в скоупе приложения. Любая утечка — компрометация.
  • Не полагайтесь на декодированное содержимое __init и не логируйте этот параметр. Payload __init читается обычным base64url-декодированием, но предназначен только Gateway и в будущем поменяется. _vibe_gw тоже остаётся внутренней HMAC-подписанной сессией Gateway.

Аналогия для понимания

Модель для понимания: Cloudflare Access / Google IAP / AWS API Gateway. Прокси (Gateway) делает всю аутентификацию и кладёт данные пользователя в заголовки на входе. Приложение читает заголовки и доверяет им, потому что:

  1. Заголовок может прийти только от Gateway (всё остальное срезается).
  2. Туннель защищён mTLS / WebSocket на уровне инфраструктуры.
  3. Если приложению нужна возможность — оно делает серверный вызов на платформенное API, неся токен как Bearer.

Идиоматическое чтение — req.headers['x-vibe-authorization'] (или эквивалент вашего фреймворка). Идиоматический ответ на 401 от V1 — перенаправление пользователя на placement, не самостоятельное обновление токена.

Входящие запросы извне: вебхуки и события Битрикс24

По умолчанию приложение на Black Hole — приватное: Gateway аутентифицирует каждый запрос (цепочка выше). Виджет Битрикс24 может отправить на субдомен приложения только auth[access_token], без auth[application_token] и member_id: Gateway проверяет токен на портале, который уже привязан к этому субдомену, и затем применяет политику доступа. Поля домена и пользователя из формы сами по себе личность не подтверждают. Токен приложения остаётся отдельным доказательством подлинности события и особенно важен в режиме PUBLIC.

Внешний вызов без проверенной сессии или токена — например исходящий вебхук Битрикс24 или POST от обработчика события — получает отказ. Голый POST с Accept: */* получает JSON 401 BH_LOGIN_REQUIRED, а не HTML-страницу входа. JSON также выбирается по Accept: application/json, Content-Type: application/json, Authorization: Bearer, X-Api-Key, X-Requested-With: XMLHttpRequest, любому ненавигационному значению Sec-Fetch-Dest (включая empty) и для путей /v1/… или /api/…; эти сильные признаки имеют приоритет над Accept: text/html и браузерным назначением. Без сильного признака явный HTML или Sec-Fetch-Dest: document|iframe|frame выбирает HTML с HTTP 401, когда Gateway уже формирует отказ аутентификации; запрос без явного HTML и без назначения по умолчанию получает JSON 401. Есть отдельная ветка до этого отказа: GET/HEAD документа к приватному серверу RUNNING без подключённого туннеля может получить HTTP 302 в OAuth-вход. Поэтому машинному клиенту следует явно передавать Accept: application/json, а перенаправление по Location оставлять браузерной навигации.

Чтобы принимать запросы извне, переведите сервер в публичный режим:

Terminal
curl -X PATCH https://vibecode.bitrix24.tech/v1/infra/servers/SERVER_ID/access-policy \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "accessPolicy": "PUBLIC" }'

В режиме PUBLIC Gateway пробрасывает внешний запрос прямо в приложение с заголовком X-Vibe-Public: true — аутентификация не обязательна. Доступ открыт всем, кто знает адрес субдомена, поэтому проверяйте входящие данные на стороне приложения (для событий Битрикс24 — поле auth.application_token в теле запроса). Если же у посетителя уже есть сессия, Gateway дополнительно прокидывает заголовки идентичности X-Vibe-User-* (и X-Vibe-Authorization, когда у пользователя есть токен приложения) — так же, как на приватных политиках. Анонимный запрос приходит без них. Опознан ли посетитель — определяйте по наличию X-Vibe-User-Id, а не X-Vibe-Authorization: последний несёт только токен доступа для /v1 и законно пуст у гостевых ссылок и у пользователей без токена приложения, даже когда идентичность известна. Подробнее о режимах — Политика доступа.

Таймаут шлюза закрывается по одному из двух окон. Если за 30 секунд от приложения не пришло вообще ни одного кадра ответа, срабатывает собственный таймаут шлюза, а не ответ приложения. То же самое случается и на более коротком окне: если приложение успело начать отвечать (заголовки уже пришли), но затем замолчало дольше 15 секунд, ничего не досылая, шлюз тоже перестаёт ждать — с точки зрения вызывающего это тот же факт «ответ не поступил вовремя». В обоих случаях браузер по-прежнему получает самополлящийся HTML-экран «приложение запускается» со статусом 503 — он сам опрашивает сервер и обновится, когда приложение ответит. Вызывающему, который не браузер (вебхук, интеграция, fetch), вместо страницы приходит 504 с кодом BH_APP_TIMEOUT — без заголовка Retry-After: шлюз знает только то, что отдал кадр запроса в туннель, а не то, что приложение его гарантированно получило, поэтому автоматический повтор небезопасен в обе стороны — запрос мог не дойти вовсе, а мог дойти и выполниться. Приложение и само может ответить 504 по своей причине, до того как истечёт таймаут шлюза, — такой ответ, как любой 502/504 от приложения, доходит дословно (подробнее ниже). Отличить эти два 504 можно только по телу: у шлюза это JSON-конверт с error.code: BH_APP_TIMEOUT, у приложения — то, что вернул его собственный код. Прежде чем повторять, проверьте эффект операции по правилу Что безопасно повторять. Для долгих задач (анализ, генерация, импорт) не держите соединение открытым: ответьте сразу (202 + идентификатор задачи), выполняйте работу в фоне, а вызывающий опрашивает статус отдельным запросом. Синхронный POST дольше 30 секунд через публичный субдомен не дойдёт до конца.

Спящий сервер и первый запрос. Если сервер спит и запрос попадает под условия автоматического пробуждения — например публичный режим и метод POST, — платформа начинает подъём. Что увидит вызывающий, зависит от того, кто он:

  • Браузер получает HTML-страницу пробуждения со статусом 503. Она сама опрашивает сервер и перезагружается, когда приложение поднялось.
  • Не-браузер получает JSON-ошибку со статусом 503, кодом BH_SERVER_WAKING (или BH_TUNNEL_CONNECTING, если сервер уже поднят и переподключается агент) и заголовком Retry-After: повторите запрос через указанное число секунд.

Если пробуждение запрещено. Для приложения в режиме PUBLIC, у которого нет туннеля и подтверждён запрет пробуждения, шлюз возвращает 409: браузеру — страницу «Приложение временно недоступно» с кнопкой «Обновить», остальным — JSON с кодом BH_APP_UNAVAILABLE. Ответ не раскрывает причину запрета, сведения о балансе или бренд подписки и не содержит Retry-After. Пробуждение и автоматический опрос не запускаются. Уже открытая страница ожидания при следующем опросе переходит на этот экран. После снятия запрета приложение можно открыть кнопкой «Обновить» — исходный путь и параметры сохраняются, одноразовый код входа убирается, POST автоматически не повторяется. Это правило действует только для PUBLIC: в приватном режиме опознанный пользователь при запрете пробуждения получает прежний 402 BH_WAKE_BLOCKED.

Кто считается не-браузером. Достаточно любого одного признака: заголовок Authorization: Bearer, заголовок X-Api-Key, Accept: application/json, X-Requested-With: XMLHttpRequest, Sec-Fetch-Dest: empty, путь запроса начинается на /v1/ — либо метод запроса POST, PUT, PATCH или DELETE. Последнее и покрывает вебхук Битрикс24, который приходит без единого из перечисленных заголовков. Обратно: запрос, у которого Sec-Fetch-Dest равен document, iframe или frame, считается переходом в браузере и получает страницу, а не JSON, — даже если это POST формы.

Полный набор кодов, которые отдаёт сам шлюз. Все они приходят в общем конверте ошибки { "success": false, "error": { "code", "message" } }.

HTTP Код Что произошло Retry-After
503 BH_SERVER_WAKING Сервер спал, платформа его поднимает есть
503 BH_TUNNEL_CONNECTING Сервер поднят, агент переподключается есть
503 BH_APP_STARTING Кадра ответа от приложения не было вовсе, либо ответил сам агент (не приложение) есть
504 BH_APP_TIMEOUT Шлюз не дождался ответа приложения в своё окно (30 секунд без единого кадра, либо начатый ответ замолчал дольше 15 секунд); запрос мог быть доставлен приложению и выполниться нет
503 BH_APP_NOT_DEPLOYED Туннель открыт, но выкладки на этот сервер платформа не видела нет
502 BH_TUNNEL_DISCONNECTED Туннель к серверу оборван есть
503 BH_PLATFORM_UNAVAILABLE Платформа не смогла прочитать состояние сервера. Запрос до приложения не дошёл есть
500 BH_SERVER_ERROR Сервер в состоянии ошибки — повтор не поможет, нужен ремонт нет
404 BH_SERVER_NOT_FOUND За этим адресом нет сервера: удалён либо адрес набран неверно нет
409 BH_APP_UNAVAILABLE Только PUBLIC: туннеля нет, пробуждение запрещено. Причина не раскрывается, автоматического повтора нет нет
402 BH_WAKE_BLOCKED Приватный режим, пользователь опознан: пробуждение заблокировано — исчерпан баланс либо нужна подписка нет
503 BH_AUTH_UNAVAILABLE Проверить доступ сейчас не удалось — временный сбой на стороне платформы, это не отказ в доступе есть
403 BH_ACCESS_DENIED Вызывающий опознан, но политика доступа сервера его не пускает нет
401 BH_LOGIN_REQUIRED Приватный режим: вызывающий не прошёл проверку доступа нет

Сбой чтения состояния сервера. BH_PLATFORM_UNAVAILABLE возвращает Retry-After: 5 и error.retryAfter: 5. Для управляемой доставки событий это не обещание повтора через пять секунд: минимальный интервал проб подписки по умолчанию составляет 60 секунд. Распознанный временный отказ платформы позволяет пробовать раньше длинной паузы. Если ранняя проба снова получает такой отказ, она сохраняет срок плановой проверки и не приближает окончательную остановку подписки. Плановые отказы по-прежнему расходуют бюджет повторов.

Коды без Retry-After в основном означают: без внешнего действия состояние не изменится, поэтому повтор по таймеру бессмысленен. Исключение — BH_APP_TIMEOUT: приложение как раз может дозавершить обработку само, но запрос уже мог быть ему доставлен, и небезопасен здесь не повтор по таймеру, а слепой повтор без проверки эффекта — см. Что безопасно повторять.

Шлюз классифицирует ответ по его автору, а не по статусу. Ответ, который написало ваше приложение, доходит до вызывающего дословно — статус, заголовки, тело. Это касается 502, 504 и 503 любого типа, кроме двух форм, неотличимых от собственного отказа агента: 503 с Content-Type: text/html и 502 совсем без заголовка Content-Type по-прежнему подменяются экраном BH_APP_STARTING (подробности и как их избежать — в следующем абзаце). Заголовок Content-Type во всех остальных случаях больше не решает, подменять ответ или нет, — раньше именно он отличал «приложение сообщает о своём состоянии» от «приложение недоступно», теперь для этого используется признак автора ответа. Кроме двух форм выше, на 503 с кодом BH_APP_STARTING и заголовком Retry-After подменяется только то, что приложение не писало: кадра из туннеля не было вовсе (агент сообщил, что на порту никто не слушает), либо ответил сам агент, а не приложение. Собственный текстовый отказ агента по перегрузке — 503 Too many concurrent requests с Content-Type: text/plain — под эти формы не попадает и доходит до вызывающего дословно: увидев такую строку, ищите не баг приложения, а насыщение агента параллельными запросами.

Агент и приложение говорят по одному каналу, и часть агентских отказов неотличима от ответа приложения по форме: 503 с телом text/html (у агента — страница «нет приложения на порту») и 502 вовсе без заголовка Content-Type. Это ограничение, а не гарантия: если ваше приложение ответит 503 с Content-Type: text/html либо 502 совсем без Content-Type, шлюз по-прежнему подменит такой ответ экраном BH_APP_STARTING — избегайте этих двух форм для собственных ошибок. Любой другой 503 (например text/plain или application/json) и любой 502 с явным Content-Type проходят наружу как есть; 500 с любым типом содержимого тоже всегда проходит без изменений. Из этого следует правило для отладки: увидели BH_APP_STARTING на маршруте, который раньше отвечал, — прежде всего проверьте журнал самого приложения: частая причина ровно та, что описана выше, — ваше приложение уже ответило 503 с Content-Type: text/html или 502 без Content-Type, и шлюз подменил именно этот ответ. Если в журнале приложения пусто, смотрите журнал агента: тогда кадра от приложения не было вовсе.

Короткий разрыв туннеля на уже работающем сервере шлюз старается пережить незаметно: запрос удерживается до ~15 секунд, и если туннель за это время возвращается, запрос доставляется в приложение и вызывающий получает настоящий ответ приложения, а не ошибку.

Холодный старт в это окно не укладывается: поднятие машины занимает от минуты, поэтому первый запрос к по-настоящему спящему серверу до приложения не доходит и повторить его должен вызывающий. Битрикс24 события отправляет без гарантии повторной доставки, поэтому для событийных сценариев выберите одно из двух. Отключите авто-сон (PATCH /v1/infra/servers/:id/sleep со значением «никогда») — тогда обработчик всегда онлайн. Либо перейдите на офлайн-события Битрикс24 — тогда сервер может спать (см. ниже).

Можно ли направить событие Битрикс24 (`event.bind`) на субдомен приложения

Да. Битрикс24 event.bind принимает в качестве обработчика произвольный HTTPS-URL, поэтому адрес субдомена Black Hole (https://app-<hex>.vibecode.bitrix24.tech/<путь>) подходит — это разблокирует событийную архитектуру (событие в Битрикс24 → действие в приложении) без опроса каждые N минут. Условия:

  1. HTTPS обязателен — субдомены Black Hole всегда по HTTPS, условие выполнено.
  2. Публичный режим — accessPolicy: PUBLIC (см. выше), иначе POST от Битрикс24 упрётся в 401.
  3. Сервер не должен засыпать — иначе первое событие потеряется (см. выше). Отключите авто-сон. Если сервер должен спать, не подписывайтесь на онлайн-события: возьмите офлайн-очередь (следующий раздел).
  4. Ответ за 30 секунд — обработчик должен подтвердить приём быстро, тяжёлую обработку выносите в фон.
  5. event.bind вызывается через REST API самого Битрикс24 в контексте приложения. События доступны на коммерческих тарифах Битрикс24. В теле запроса Битрикс24 присылает auth.application_token — проверяйте его.

Если сервер должен засыпать: офлайн-события Битрикс24

Битрикс24 умеет не вызывать обработчик, а складывать изменения в очередь на своей стороне — приложение забирает их само, когда проснулось. Это штатный механизм ровно для случая «приложение временно недоступно», и он снимает требование «сервер не должен спать».

  • Подписка — event.bind с event_type=offline и без параметра handler.
  • Чтение — event.offline.get. С clear=0 метод не удаляет записи, а помечает пачку идентификатором process_id. После успешной обработки подтвердите её event.offline.clear, сбойные записи отметьте event.offline.error. Зарезервированная пачка хранится до 30 дней.
  • Без опроса по таймеру — подпишитесь обычным (онлайн) способом на onOfflineEvent: это сигнал «в очереди появились записи», данных он не несёт. Учтите, что сам этот сигнал приходит на обработчик и на спящем сервере теряется так же, как любое онлайн-событие, — как страховка оставьте периодическое чтение очереди.
  • Против петель — если приложение само меняет объекты в Битрикс24, передавайте auth_connector и при подписке, и в вызовах изменения: тогда собственные правки приложения в его очередь не попадут.
  • Очередь хранит не историю, а текущее состояние объекта: тысяча правок одной сделки оставит в ней одну запись.
  • Часть возможностей (подтверждение обработки, auth_connector) зависит от тарифа Битрикс24 — проверьте доступность методом feature.get.

Карточка ссылки на приложение

Ссылку на приложение в режиме PUBLIC мессенджеры и чат Битрикс24 разворачивают в карточку с названием, описанием и картинкой. Собирает её сам Gateway: приложение запроса не получает, спящий сервер не просыпается.

Данные берутся из карточки приложения в каталоге — название, описание и иконка. Названия в каталоге нет — подставляется имя сервера. Описания нет нигде — карточка остаётся с одним названием. Иконки нет — картинка платформы.

На любой другой политике доступа карточки нет: сборщик превью получает нейтральную страницу без названия, описания и картинки. Так же отвечает адрес, за которым нет сервера. Подробнее о режимах — Политика доступа.

Gateway запрещает HTTP-кеширование и карточки, и нейтральной страницы заголовком Cache-Control: no-store: каждый новый запрос заново проверяет текущую политику доступа. Это не управляет кешем на стороне мессенджера после получения карточки.

Уже показанную карточку отозвать нельзя. Принимающая сторона кеширует её у себя, поэтому переключение политики с PUBLIC на приватную закрывает только новые запросы: в отправленных раньше сообщениях карточка продолжает показываться столько, сколько её хранит мессенджер. Не включайте PUBLIC, если название или описание приложения не должны попасть наружу.

Placement открывается с экраном входа вместо приложения

Симптом: сотрудник открывает вкладку с placement в Битрикс24, но в iframe показывается экран входа, а не приложение. Либо приложение пускает только владельца ключа, а другим сотрудникам из списка доступа по-прежнему предлагает войти. Две причины конфигурации.

Обработчик placement указывает на субдомен, а не на платформу

Обычный placement должен открываться через обработчик платформы https://vibecode.bitrix24.tech/v1/bitrix-handler, а не напрямую на субдомен приложения (https://app-<hex>.vibecode.bitrix24.tech). Через обработчик платформа создаёт сессию vibe_session_* и одноразовый код для Gateway (см. «Жизненный цикл запроса» выше). Исключение — виджет Битрикс24, который POST-запросом передаёт действующий auth[access_token]: Gateway проверяет его и без auth[application_token] или member_id. Прямой POST виджета без действующего токена сессию не создаёт и получает 401 BH_LOGIN_REQUIRED; браузерный GET/HEAD может вместо этого перейти в OAuth-вход, как описано выше.

Адрес обработчика задаёт платформа, указывать субдомен приложения вручную не нужно. Если placement уже привязан к субдомену — привяжите место заново вызовом Привязать место: привязка регистрирует платформенный адрес обработчика. Переданный адрес субдомена платформа заменяет сама и отмечает замену полем handlerRewritten в ответе.

Список доступа применяется только при политике `NAMED_USERS`

В режиме OWNER_ONLY доступ есть только у владельца ключа — записи списка доступа, добавленные через POST /access, не применяются. Чтобы пустить конкретных сотрудников портала, переключите политику на NAMED_USERS и добавьте пользователей:

Terminal
curl -X PATCH https://vibecode.bitrix24.tech/v1/infra/servers/SERVER_ID/access-policy \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "accessPolicy": "NAMED_USERS" }'

Подробнее о режимах и списке доступа — Политика доступа.

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

Self-hosted приложение (не Black Hole)

Заголовок X-Vibe-Authorization проставляется только за Gateway, который стоит перед Black Hole-серверами. Если файлы приложения лежат на вашем собственном сервере, заголовок не подставляется — приложение само хранит токен сессии (свой BFF) и само вызывает GET /v1/me.

Как получить токен сессии текущего пользователя в placement (iframe):

  • Приложение Вайбкод с appUrl на вашем сервере. Placement приходит на обработчик Вайбкод, который перенаправляет на <appUrl>/?code=<одноразовый код>&placement=…&member_id=…. Ваш сервер обменивает код: POST /v1/oauth/token { app_key, code, redirect_uri } (значение redirect_uri — ровно настроенный appUrl). В браузер попадает только одноразовый код, не токен.
  • Собственное приложение Битрикс24 со своим обработчиком. Placement приходит на ваш сервер с токеном пользователя (AUTH_ID). Обменяйте его сервер-к-серверу: POST /v1/oauth/placement-session { app_key, access_token, member_id, domain }.

В обоих случаях ответ — { access_token: "vibe_session_…", user, expires_in }. Дальше приложение работает как BFF: хранит vibe_session_* на своей стороне, читает личность через GET /v1/me с Authorization: Bearer <vibe_session_*> и не отдаёт токен в браузер. Возможность встроить свою страницу в iframe — заголовки X-Frame-Options и CSP frame-ancestors — обеспечиваете вы, платформа за неё не отвечает. Стандартный GET /v1/oauth/authorize в iframe не открывается: страница согласия Битрикс24 запрещает встраивание. Открывайте его только в отдельной вкладке.

Смотрите также