# 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](/docs/infra/deploy)
- **A `vibe_app_` authorization key the server is bound to.** A regular `vibe_api_` key and a `vibe_live_` management key are not suitable for subscriptions — creating a subscription returns `400 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](/docs/infra/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](/docs/keys-auth)
- The `vibe:infra` scope 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 the `tasks` scope is needed
- Node.js 18+ and the `express` package 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

1. The application server is bound to the `vibe_app_` authorization key — under this application the platform registers the event handler in Bitrix24.
2. A subscription is created by a single call: the event code and the path on the application to deliver it to.
3. Bitrix24 sends the event to the platform, the platform delivers it to the application through the Black Hole tunnel.
4. The handler checks `auth[application_token]`, does its work and answers `2xx`.
5. 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`](/docs/infra/servers) returns the servers owned by the key.

### 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"
    }
  ]
}
```

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](/docs/infra/event-subscriptions) section.

## Step 2. Create an event subscription

[`POST /v1/infra/servers/:id/event-subscriptions`](/docs/infra/event-subscriptions/create) 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

```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.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](/docs/infra/event-subscriptions/create).

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

```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) => {
  // 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](/docs/infra/event-subscriptions/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

```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 "${task.title}", responsible ${task.responsibleId}`)

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

```json
{
  "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](/docs/infra/event-subscriptions/handler) section.

## Step 5. Check the deliveries

[`GET /v1/infra/servers/:id/event-subscriptions`](/docs/infra/event-subscriptions/list) 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

```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 ?? '')
}
```

The subscription list and the delivery log arrive together, and `recentDeliveries` sits **next to** `data`, not inside it.

```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"
    }
  ]
}
```

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.

```json
{
  "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`](/docs/infra/event-subscriptions/delete).

The full list of error codes — [Errors](/docs/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

```javascript
// 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}`))
```

## See also

- [Portal event subscriptions](/docs/infra/event-subscriptions)
- [Create a subscription](/docs/infra/event-subscriptions/create)
- [Application-side handler](/docs/infra/event-subscriptions/handler)
- [List subscriptions](/docs/infra/event-subscriptions/list)
- [Delete a subscription](/docs/infra/event-subscriptions/delete)
- [Task automation](/docs/recipes/task-automation)
- [Keys and authorization](/docs/keys-auth)
- [Deploy API](/docs/infra/deploy)
- [Errors](/docs/errors)
