Для 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 — персональный ключ.
Как устроено решение
- Сервер приложения привязан к ключу авторизации
vibe_app_— под этим приложением платформа регистрирует обработчик события в Битрикс24. - Подписка создаётся одним вызовом: код события и путь на приложении, куда его доставлять.
- Битрикс24 отправляет событие платформе, платформа доставляет его в приложение через туннель Black Hole.
- Обработчик сверяет
auth[application_token], делает свою работу и отвечает2xx. - Журнал доставок показывает статус каждого события и текст ошибки при неудаче.
Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».
Шаг 1. Найти сервер приложения
Подписка создаётся на конкретном сервере, поэтому первым делом нужен его ID. GET /v1/infra/servers возвращает серверы, которыми владеет ключ.
cURL
curl -s -H "X-Api-Key: $VIBE_APP_KEY" \
-H "Authorization: Bearer $VIBE_SESSION" \
"$VIBE_URL/v1/infra/servers"
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
{
"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
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
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()
{
"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 здесь нет.
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
curl -s -H "X-Api-Key: $VIBE_API_KEY" \
"$VIBE_URL/v1/tasks/20152"
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 })
})
{
"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
curl -s -H "X-Api-Key: $VIBE_APP_KEY" \
-H "Authorization: Bearer $VIBE_SESSION" \
"$VIBE_URL/v1/infra/servers/$SERVER_ID/event-subscriptions"
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, а не внутри него.
{
"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 показывает причину последней неудачи.
Ограничения
Событие — только на один сервер. Событие привязывается к одному серверу приложения, поэтому повторная привязка того же события ко второму серверу одного приложения отклоняется.
{
"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 последними записями. Более ранние доставки в нём не возвращаются, поэтому долгую историю приложение ведёт у себя.
Полный код
// 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}`))