For AI agents: markdown of this page — /docs-content-en/recipes/webhook-handler.md documentation index — /llms.txt
Handling account events in an application
Difficulty: medium | Scopes: vibe:infra, tasks | Stack: cURL / JavaScript (Node.js, Express)
An application on a Black Hole server receives Bitrix24 events at the moment they happen, without polling the account and without a public address of its own. The platform registers the handler itself, delivers every event to the application through the tunnel, wakes a sleeping server and repeats the delivery if the application responds with anything other than 2xx.
What you need
- An application deployed on a Black Hole server — delivery goes through the tunnel of that server. The publishing procedure is described in Deploy API
- A
vibe_app_authorization key the server is bound to. A regularvibe_api_key and avibe_live_management key are not suitable for subscriptions — creating a subscription returns400 NOT_OAUTH_APP. A server under an authorization key is created from scratch — an existing one cannot be re-bound. The procedure is in Portal event subscriptions - A
vibe_session_session token — calls under an authorization key go together with it. Obtaining a session is described in Keys and authorization - The
vibe:infrascope on the authorization key — subscriptions are managed with it - A commercial Bitrix24 plan on the account. On a free plan, event registration is rejected —
502 BIND_FAILED - A personal
vibe_api_key with the scopes of the data the handler reads. In the examples below the handler reads tasks, so thetasksscope is needed - Node.js 18+ and the
expresspackage on the application server
In all examples $VIBE_URL is the base address https://vibecode.bitrix24.com, $VIBE_APP_KEY is the vibe_app_ authorization key, $VIBE_SESSION is the vibe_session_ session token, $VIBE_API_KEY is the personal key.
How the solution works
- The application server is bound to the
vibe_app_authorization key — under this application the platform registers the event handler in Bitrix24. - A subscription is created by a single call: the event code and the path on the application to deliver it to.
- Bitrix24 sends the event to the platform, the platform delivers it to the application through the Black Hole tunnel.
- The handler checks
auth[application_token], does its work and answers2xx. - The delivery log shows the status of every event and the error text on failure.
The step examples show individual calls. The ready-to-run script is in the "Full code" section.
Step 1. Find the application server
A subscription is created on a specific server, so its ID is needed first. GET /v1/infra/servers returns the servers owned by the key.
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"
}
]
}
What is needed next is id — it is the value substituted into the subscription address. The subdomain field sets the public address of the application the events will travel to.
If the list is empty, no server has been created under this key yet — return to the Portal event subscriptions section.
Step 2. Create an event subscription
POST /v1/infra/servers/:id/event-subscriptions accepts the event code and the path on the application. An event code starts with a capital Latin letter, followed by capital letters, digits and underscores: 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.com/v1/portal-events/9a1c2b3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"status": "ACTIVE",
"createdAt": "2026-06-07T10:00:00.000Z"
}
}
A repeated call with the same "application and event" pair updates the appPath of the existing subscription — the setup script can be run again. If the same event is already taken by another server of the same application, the response is 409 EVENT_BOUND_ELSEWHERE. The full set of response fields and the error codes are in Create a subscription.
Step 3. Receive the event in the application
The platform delivers the event as a POST request to the appPath path in application/x-www-form-urlencoded format. The handler checks auth[application_token] against its own application_token and answers 2xx. Below is the application's own code, not a call to the Vibecode API, so there is no cURL example here.
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) => {
// Events without a valid application token are rejected
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 ${event}, task #${taskId}`)
// Any response other than 2xx triggers a redelivery
res.status(200).json({ ok: true })
})
app.listen(3000)
Parsing the event body, the composition of the auth fields and the ways to authorize calls from the handler are described in Application-side handler.
Step 4. Call the V1 API from the handler
An event carries only the entity identifier, so the handler goes to the V1 API for the content. The platform adds no authorization headers to the delivery — the handler sets them itself. The short path is a personal vibe_api_ key in the X-Api-Key header.
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 "${task.title}", responsible ${task.responsibleId}`)
res.status(200).json({ ok: true })
})
{
"success": true,
"data": {
"id": 20152,
"title": "Prepare the documents",
"status": 2,
"responsibleId": 29,
"createdDate": "2026-07-21T09:14:02.000Z"
}
}
The status field is the numeric state of the task: 1 — new, 2 — pending, 3 — in progress, 4 — awaiting control, 5 — completed, 6 — deferred, 7 — declined. It is what lets the handler tell a genuine state change from a repeated delivery of the same event.
The calls are made on behalf of the personal key owner. When they must be made under the same application as the subscription, the handler needs a session token — the second authorization method is described in the Application-side handler section.
Step 5. Check the deliveries
GET /v1/infra/servers/:id/event-subscriptions returns the server's subscriptions and up to 50 recent deliveries. The recentDeliveries array shows whether the event arrived and how the application responded.
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 ?? '')
}
The subscription list and the delivery log arrive together, and recentDeliveries sits next to data, not inside it.
{
"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"
}
]
}
The delivery status takes the values PENDING, DELIVERING, DELIVERED and FAILED. The lastError field shows the reason for the last failure.
Limitations
One event, one server. An event is bound to one application server, so binding the same event to a second server of one app is rejected.
{
"success": false,
"error": {
"code": "EVENT_BOUND_ELSEWHERE",
"message": "This event is already bound to another server on the same app"
}
}
The refusal arrives with status 409. The previous subscription is deleted first through DELETE /v1/infra/servers/:id/event-subscriptions/:subId.
The full list of error codes — Errors.
A delivery may repeat. The same event may arrive again. Handle it so that receiving the same data[FIELDS][ID] a second time does not do the work twice.
The repeat guard has an expiry. The retry protection is deliberately bounded in time. An «already handled» mark with no expiry lives forever, and then the first change to a task mutes every later one — the status, the deadline, the assignee change and the handler stays silent until a restart. The window suppresses a repeated delivery of one event and lets genuine changes through.
Processing errors are yours. The handler answers 2xx before starting the work, so the platform treats the delivery as successful and will not repeat it. An error inside the processing is yours to handle: put the event on your own retry queue, or answer non-2xx before starting the work when you want the platform to redeliver.
Exhausted attempts degrade the subscription. Unsuccessful deliveries are repeated with an increasing delay. Once the attempts are exhausted, the delivery moves to FAILED, the subscription is marked DEGRADED, and the owner receives a notification.
A wake-up delay. The platform wakes a sleeping server for the delivery, so the first event after an idle period arrives with a wake-up delay.
The log keeps 50 records. The delivery log in the response is limited to the 50 most recent records. Earlier deliveries are not returned in it, so the application keeps a long history on its own side.
Full code
// event-handler.js — receiving account events and reading a task through the V1 API
import express from 'express'
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.com'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('VIBE_API_KEY environment variable is not set')
const APP_TOKEN = process.env.APPLICATION_TOKEN
// Without the token the comparison below is always true: every event gets a 403
// and is redelivered forever. A safe refusal, but a hard one to diagnose.
if (!APP_TOKEN) throw new Error('The APPLICATION_TOKEN environment variable is not set')
const PORT = process.env.PORT ?? 3000
const app = express()
app.use(express.urlencoded({ extended: true }))
// Identifiers of already processed events — protection against repeated delivery
// The retry-protection window. The mark must not be kept forever: a key of
// «event + entity» with no expiry would permanently mute every later genuine
// change to the same task. Expired entries are swept in place.
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 ?? `task not read (${res.status})`)
return body.data
}
const handlers = {
ONTASKADD: async (taskId) => {
const task = await getTask(taskId)
console.log(`New task "${task.title}", responsible ${task.responsibleId}`)
},
ONTASKUPDATE: async (taskId) => {
const task = await getTask(taskId)
console.log(`Task #${taskId} updated, status ${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}`
// The 2xx response is returned immediately — the platform does not wait for processing to finish
res.status(200).json({ ok: true })
if (!entityId || seenRecently(key)) return
try {
await handlers[event]?.(entityId)
// The mark is set only after success: the 2xx has already gone out, the
// platform will not retry, so marking before the work would lose the event.
remember(key)
} catch (error) {
console.error(`Processing error ${key}: ${error.message}`)
}
})
app.listen(PORT, () => console.log(`Event handler listening on port ${PORT}`))