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

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

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

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

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

  • Приложение, развёрнутое на Black Hole-сервере — доставка идёт через туннель этого сервера. Порядок публикации описан в Деплое
  • Ключ авторизации vibe_app_, к которому привязан сервер. Обычный ключ vibe_api_ и менеджмент-ключ vibe_live_ для подписок не подходят — создание подписки вернёт 400 NOT_OAUTH_APP. Существующий сервер переводится на ключ авторизации в кабинете («Сменить управляющий ключ»), либо создайте новый сразу под ним. Порядок — Подписки на события портала
  • Токен сессии vibe_session_ — вызовы под ключом авторизации идут вместе с ним. Получение сессии описано в Ключах и авторизации
  • Скоуп 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 возвращает серверы, которыми владеет ключ.

cURL

Terminal
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 задаёт публичный адрес приложения, на который поедут события.

Если список пуст, сервер под этим ключом ещё не создан — вернитесь к разделу Подписки на события портала.

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

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

cURL

Terminal
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. Полный набор полей ответа и коды ошибок — Создать подписку.

Шаг 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 и способы авторизации вызовов из обработчика — Обработчик на стороне приложения.

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

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

cURL

Terminal
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 — отклонена. По нему обработчик и отличает реальную смену состояния от повторной доставки того же события.

Вызовы идут от лица владельца персонального ключа. Когда они должны идти под тем же приложением, что и подписка, обработчику нужен токен сессии — второй способ авторизации описан в разделе Обработчик на стороне приложения.

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

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

cURL

Terminal
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.

Полный перечень кодов — Ошибки.

Доставка может повториться. Одно и то же событие может прийти повторно. Обрабатывайте его так, чтобы второй приём с тем же 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}`))

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