# Обработка событий портала в приложении

**Сложность:** средний | **Скоупы:** vibe:infra, tasks | **Стек:** cURL / JavaScript (Node.js, Express)

Приложение на Black Hole-сервере получает события Битрикс24 в момент их наступления, без опроса портала и без собственного публичного адреса. Платформа сама регистрирует обработчик, доставляет каждое событие в приложение через туннель, будит спящий сервер и повторяет доставку, если приложение ответило не `2xx`.

## Что понадобится

- Приложение, развёрнутое на Black Hole-сервере — доставка идёт через туннель этого сервера. Порядок публикации описан в [Деплое](/docs/infra/deploy)
- **Ключ авторизации `vibe_app_`, к которому привязан сервер.** Обычный ключ `vibe_api_` и менеджмент-ключ `vibe_live_` для подписок не подходят — создание подписки вернёт `400 NOT_OAUTH_APP`. Существующий сервер переводится на ключ авторизации в кабинете («Сменить управляющий ключ»), либо создайте новый сразу под ним. Порядок — [Подписки на события портала](/docs/infra/event-subscriptions)
- Токен сессии `vibe_session_` — вызовы под ключом авторизации идут вместе с ним. Получение сессии описано в [Ключах и авторизации](/docs/keys-auth)
- Скоуп `vibe:infra` на ключе авторизации — им управляются подписки
- **Коммерческий тариф Битрикс24 на портале.** На бесплатном тарифе регистрация события отклоняется — `502 BIND_FAILED`
- Персональный ключ `vibe_api_` со скоупами тех данных, которые читает обработчик. В примерах ниже обработчик читает задачи, поэтому нужен скоуп `tasks`
- Node.js 18+ и пакет `express` на сервере приложения

Во всех примерах `$VIBE_URL` — базовый адрес `https://vibecode.bitrix24.tech`, `$VIBE_APP_KEY` — ключ авторизации `vibe_app_`, `$VIBE_SESSION` — токен сессии `vibe_session_`, `$VIBE_API_KEY` — персональный ключ.

## Как устроено решение

1. Сервер приложения привязан к ключу авторизации `vibe_app_` — под этим приложением платформа регистрирует обработчик события в Битрикс24.
2. Подписка создаётся одним вызовом: код события и путь на приложении, куда его доставлять.
3. Битрикс24 отправляет событие платформе, платформа доставляет его в приложение через туннель Black Hole.
4. Обработчик сверяет `auth[application_token]`, делает свою работу и отвечает `2xx`.
5. Журнал доставок показывает статус каждого события и текст ошибки при неудаче.

Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».

## Шаг 1. Найти сервер приложения

Подписка создаётся на конкретном сервере, поэтому первым делом нужен его ID. [`GET /v1/infra/servers`](/docs/infra/servers) возвращает серверы, которыми владеет ключ.

### cURL

```bash
curl -s -H "X-Api-Key: $VIBE_APP_KEY" \
  -H "Authorization: Bearer $VIBE_SESSION" \
  "$VIBE_URL/v1/infra/servers"
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/infra/servers`, {
  headers: {
    'X-Api-Key': VIBE_APP_KEY,
    Authorization: `Bearer ${VIBE_SESSION}`,
  },
})
const { data: servers } = await res.json()
const serverId = servers[0].id
```

```json
{
  "success": true,
  "data": [
    {
      "id": "srv_4c81",
      "name": "prod-app",
      "subdomain": "myapp",
      "status": "running"
    }
  ]
}
```

Дальше нужен `id` — именно он подставляется в адрес подписки. Поле `subdomain` задаёт публичный адрес приложения, на который поедут события.

Если список пуст, сервер под этим ключом ещё не создан — вернитесь к разделу [Подписки на события портала](/docs/infra/event-subscriptions).

## Шаг 2. Создать подписку на событие

[`POST /v1/infra/servers/:id/event-subscriptions`](/docs/infra/event-subscriptions/create) принимает код события и путь на приложении. Код события начинается с заглавной латинской буквы, дальше идут заглавные буквы, цифры и подчёркивание: `ONTASKADD`, `ONTASKUPDATE`, `ONCRMDEALADD`.

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_APP_KEY" \
  -H "Authorization: Bearer $VIBE_SESSION" \
  -H "Content-Type: application/json" \
  "$VIBE_URL/v1/infra/servers/$SERVER_ID/event-subscriptions" \
  -d '{ "event": "ONTASKADD", "appPath": "/api/webhooks/b24" }'
```

### JavaScript

```javascript
const res = await fetch(
  `${VIBE_URL}/v1/infra/servers/${serverId}/event-subscriptions`,
  {
    method: 'POST',
    headers: {
      'X-Api-Key': VIBE_APP_KEY,
      Authorization: `Bearer ${VIBE_SESSION}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ event: 'ONTASKADD', appPath: '/api/webhooks/b24' }),
  },
)
const { data: subscription } = await res.json()
```

```json
{
  "success": true,
  "data": {
    "id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
    "serverId": "e765edfc-ba0a-43de-b8ea-838dd872c522",
    "event": "ONTASKADD",
    "appPath": "/api/webhooks/b24",
    "b24Handler": "https://vibecode.bitrix24.tech/v1/portal-events/9a1c2b3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
    "status": "ACTIVE",
    "createdAt": "2026-06-07T10:00:00.000Z"
  }
}
```

Повторный вызов с той же парой «приложение и событие» обновляет `appPath` существующей подписки — скрипт настройки можно запускать заново. Если то же событие уже занято другим сервером того же приложения, ответ будет `409 EVENT_BOUND_ELSEWHERE`. Полный набор полей ответа и коды ошибок — [Создать подписку](/docs/infra/event-subscriptions/create).

## Шаг 3. Принять событие в приложении

Платформа доставляет событие POST-запросом по пути `appPath` в формате `application/x-www-form-urlencoded`. Обработчик сверяет `auth[application_token]` со своим `application_token` и отвечает `2xx`. Ниже — код самого приложения, а не вызов к API Вайбкод, поэтому примера на cURL здесь нет.

```javascript
import express from 'express'

const APP_TOKEN = process.env.APPLICATION_TOKEN
const app = express()
app.use(express.urlencoded({ extended: true }))

app.post('/api/webhooks/b24', (req, res) => {
  // События без верного токена приложения отклоняются
  if (req.body.auth?.application_token !== APP_TOKEN) {
    return res.status(403).json({ ok: false })
  }

  const event = req.body.event
  const taskId = req.body.data?.FIELDS?.ID
  console.log(`Событие ${event}, задача #${taskId}`)

  // Ответ не 2xx приводит к повторной доставке
  res.status(200).json({ ok: true })
})

app.listen(3000)
```

Разбор тела события, состав полей `auth` и способы авторизации вызовов из обработчика — [Обработчик на стороне приложения](/docs/infra/event-subscriptions/handler).

## Шаг 4. Вызвать V1 API из обработчика

Событие несёт только идентификатор сущности, поэтому за содержимым обработчик обращается к V1 API. Платформа не добавляет к доставке заголовков авторизации — обработчик задаёт их сам. Короткий путь — персональный ключ `vibe_api_` в заголовке `X-Api-Key`.

### cURL

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" \
  "$VIBE_URL/v1/tasks/20152"
```

### JavaScript

```javascript
app.post('/api/webhooks/b24', async (req, res) => {
  if (req.body.auth?.application_token !== APP_TOKEN) {
    return res.status(403).json({ ok: false })
  }

  const taskId = req.body.data?.FIELDS?.ID

  const { data: task } = await fetch(`${VIBE_URL}/v1/tasks/${taskId}`, {
    headers: { 'X-Api-Key': process.env.VIBE_API_KEY },
  }).then((r) => r.json())

  console.log(`Задача «${task.title}», ответственный ${task.responsibleId}`)

  res.status(200).json({ ok: true })
})
```

```json
{
  "success": true,
  "data": {
    "id": 20152,
    "title": "Подготовить документы",
    "status": 2,
    "responsibleId": 29,
    "createdDate": "2026-07-21T09:14:02.000Z"
  }
}
```

Поле `status` — числовое состояние задачи: `1` — новая, `2` — ждёт выполнения, `3` — выполняется, `4` — ждёт контроля, `5` — завершена, `6` — отложена, `7` — отклонена. По нему обработчик и отличает реальную смену состояния от повторной доставки того же события.

Вызовы идут от лица владельца персонального ключа. Когда они должны идти под тем же приложением, что и подписка, обработчику нужен токен сессии — второй способ авторизации описан в разделе [Обработчик на стороне приложения](/docs/infra/event-subscriptions/handler).

## Шаг 5. Проверить доставки

[`GET /v1/infra/servers/:id/event-subscriptions`](/docs/infra/event-subscriptions/list) возвращает подписки сервера и до 50 недавних доставок. По массиву `recentDeliveries` видно, дошло ли событие и что ответило приложение.

### cURL

```bash
curl -s -H "X-Api-Key: $VIBE_APP_KEY" \
  -H "Authorization: Bearer $VIBE_SESSION" \
  "$VIBE_URL/v1/infra/servers/$SERVER_ID/event-subscriptions"
```

### JavaScript

```javascript
const res = await fetch(
  `${VIBE_URL}/v1/infra/servers/${serverId}/event-subscriptions`,
  {
    headers: {
      'X-Api-Key': VIBE_APP_KEY,
      Authorization: `Bearer ${VIBE_SESSION}`,
    },
  },
)
const { recentDeliveries } = await res.json()

for (const d of recentDeliveries) {
  console.log(`${d.createdAt} ${d.event} — ${d.status}`, d.lastError ?? '')
}
```

Список подписок и журнал доставок приходят вместе, причём `recentDeliveries` лежит **рядом** с `data`, а не внутри него.

```json
{
  "success": true,
  "data": [
    { "id": "sub_7f3a", "event": "ONTASKADD", "appPath": "/api/webhooks/b24" }
  ],
  "recentDeliveries": [
    {
      "id": "dlv_91c2",
      "event": "ONTASKADD",
      "status": "DELIVERED",
      "attempts": 1,
      "lastError": null,
      "createdAt": "2026-07-21T09:14:03.000Z",
      "deliveredAt": "2026-07-21T09:14:04.000Z"
    }
  ]
}
```

Статус доставки принимает значения `PENDING`, `DELIVERING`, `DELIVERED` и `FAILED`. Поле `lastError` показывает причину последней неудачи.

## Ограничения

**Событие — только на один сервер.** Событие привязывается к одному серверу приложения, поэтому повторная привязка того же события ко второму серверу одного приложения отклоняется.

```json
{
  "success": false,
  "error": {
    "code": "EVENT_BOUND_ELSEWHERE",
    "message": "This event is already bound to another server on the same app"
  }
}
```

Отказ приходит со статусом `409`. Прежнюю подписку сначала удаляют через [`DELETE /v1/infra/servers/:id/event-subscriptions/:subId`](/docs/infra/event-subscriptions/delete).

Полный перечень кодов — [Ошибки](/docs/errors).

**Доставка может повториться.** Одно и то же событие может прийти повторно. Обрабатывайте его так, чтобы второй приём с тем же `data[FIELDS][ID]` не сделал работу дважды.

**Защита от повтора — со сроком.** Защита от повтора ограничена по времени намеренно. Отметка «уже обработано» без срока живёт вечно, и тогда первое же изменение задачи заглушит все последующие — сменили статус, срок, ответственного, а обработчик молчит до перезапуска. Окно гасит повторную доставку одного события и пропускает настоящие изменения.

**Ошибки обработки — на вашей стороне.** Обработчик отвечает `2xx` до начала работы, поэтому платформа считает доставку успешной и не повторит её. Ошибку внутри обработки надо разбирать самому: складывать событие в свою очередь повторов или отвечать не-`2xx` до начала работы, если нужна штатная передоставка.

**Исчерпанные попытки роняют подписку.** Неуспешные доставки повторяются с нарастающей задержкой. После исчерпания попыток доставка переходит в `FAILED`, подписка помечается `DEGRADED`, владелец получает уведомление.

**Задержка на пробуждение.** Спящий сервер платформа будит под доставку, поэтому первое событие после простоя приходит с задержкой на пробуждение.

**Журнал — 50 последних записей.** Журнал доставок в ответе ограничен 50 последними записями. Более ранние доставки в нём не возвращаются, поэтому долгую историю приложение ведёт у себя.

## Полный код

```javascript
// event-handler.js — приём событий портала и чтение задачи через V1 API
import express from 'express'

const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('Переменная окружения VIBE_API_KEY не задана')
const APP_TOKEN = process.env.APPLICATION_TOKEN
// Без токена сравнение ниже истинно всегда: все события получат 403 и будут
// бесконечно передоставляться. Отказ безопасный, но диагностируется тяжело.
if (!APP_TOKEN) throw new Error('Переменная окружения APPLICATION_TOKEN не задана')
const PORT = process.env.PORT ?? 3000

const app = express()
app.use(express.urlencoded({ extended: true }))

// Идентификаторы уже обработанных событий — защита от повторной доставки
// Окно защиты от повторной доставки. Хранить отметку вечно нельзя: ключ
// «событие + сущность» без срока навсегда заглушил бы следующие настоящие
// изменения той же задачи. Записи с истёкшим сроком вычищаются на месте.
const DEDUP_WINDOW_MS = 5 * 60 * 1000
const processed = new Map()

function seenRecently(key) {
  const until = processed.get(key)
  return until !== undefined && until > Date.now()
}

function remember(key) {
  const now = Date.now()
  processed.set(key, now + DEDUP_WINDOW_MS)
  for (const [k, until] of processed) if (until <= now) processed.delete(k)
}

async function getTask(taskId) {
  const res = await fetch(`${VIBE_URL}/v1/tasks/${taskId}`, {
    headers: { 'X-Api-Key': VIBE_API_KEY },
  })
  const body = await res.json()
  if (!body.success) throw new Error(body.error?.message ?? `задача не прочитана (${res.status})`)
  return body.data
}

const handlers = {
  ONTASKADD: async (taskId) => {
    const task = await getTask(taskId)
    console.log(`Новая задача «${task.title}», ответственный ${task.responsibleId}`)
  },
  ONTASKUPDATE: async (taskId) => {
    const task = await getTask(taskId)
    console.log(`Задача #${taskId} обновлена, статус ${task.status}`)
  },
}

app.post('/api/webhooks/b24', async (req, res) => {
  if (req.body.auth?.application_token !== APP_TOKEN) {
    return res.status(403).json({ ok: false })
  }

  const event = req.body.event
  const entityId = req.body.data?.FIELDS?.ID
  const key = `${event}:${entityId}`

  // Ответ 2xx отдаётся сразу — платформа не ждёт окончания обработки
  res.status(200).json({ ok: true })

  if (!entityId || seenRecently(key)) return

  try {
    await handlers[event]?.(entityId)
    // Отметка ставится только после успеха: 2xx уже отдан, повтора от платформы
    // не будет, и отметка до обработки означала бы потерю события навсегда.
    remember(key)
  } catch (error) {
    console.error(`Ошибка обработки ${key}: ${error.message}`)
  }
})

app.listen(PORT, () => console.log(`Обработчик событий слушает порт ${PORT}`))
```

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

- [Подписки на события портала](/docs/infra/event-subscriptions)
- [Создать подписку](/docs/infra/event-subscriptions/create)
- [Обработчик на стороне приложения](/docs/infra/event-subscriptions/handler)
- [Список подписок](/docs/infra/event-subscriptions/list)
- [Удалить подписку](/docs/infra/event-subscriptions/delete)
- [Автоматизация задач](/docs/recipes/task-automation)
- [Ключи и авторизация](/docs/keys-auth)
- [Деплой](/docs/infra/deploy)
- [Ошибки](/docs/errors)
