# Task automation

**Difficulty:** medium | **Scopes:** crm, task | **Stack:** cURL / JavaScript (Node.js)

The service watches deals moving through pipeline stages and creates a task when a deal reaches a configured stage. The task goes to the person responsible for the deal, gets a deadline and is linked to the deal itself, so it is visible from the deal card.

## What you need

- A Vibecode API key with the `crm` and `task` scopes
- Node.js 18 or newer

In all examples `$VIBE_URL` is the base address `https://vibecode.bitrix24.com`, `$VIBE_API_KEY` is your API key.

## How the solution works

1. Fetch the list of pipeline stages — identifiers and names are defined by the Bitrix24 account.
2. Once a minute, request the deals whose stage-change moment is newer than the stored cursor.
3. The response carries the current and the previous stage — the configuration uses them to decide which task to create.
4. Before creating, check whether the deal already has such a task.
5. The cursor is kept in a file next to the script, so a restart does not create tasks again.

The step examples show individual calls. The ready-to-run script is in the "Full code" section.

## Step 1. Pipeline stages

Stage identifiers differ from account to account, and an administrator renames them freely. The list of stages of the main pipeline is returned by [`GET /v1/statuses`](/docs/entities/statuses) with a filter on `DEAL_STAGE`.

### cURL

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" \
  "$VIBE_URL/v1/statuses?filter[entityId]=DEAL_STAGE"
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/statuses?filter[entityId]=DEAL_STAGE`, {
  headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data: stages } = await res.json()
```

```json
{
  "success": true,
  "data": [
    { "statusId": "NEW", "name": "New", "sort": 10, "semantics": null },
    { "statusId": "EXECUTING", "name": "In progress", "sort": 40, "semantics": null },
    { "statusId": "WON", "name": "Deal won", "sort": 60, "semantics": "S" }
  ]
}
```

The `sort` field defines the order of stages in the pipeline, `semantics` marks the final stages: `S` — won, `F` — lost, `null` — intermediate. For additional pipelines the filter identifier looks like `DEAL_STAGE_{categoryId}`, where `categoryId` is the pipeline number.

Match the identifiers from the response against the keys of the `STAGE_TASKS` configuration in the full code. A stage that does not exist in the account creates no task.

## Step 2. Deals that changed stage

A deal has a `movedTime` field — the moment of the last move to a stage, and `previousStageId` — the stage the deal came from. [`POST /v1/deals/search`](/docs/entities/deals/search) uses `movedTime` to select everything that has moved since the previous poll.

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/deals/search" \
  -d '{
    "filter": { "movedTime": { "$gte": "2026-07-21T08:00:00Z" }, "categoryId": 0 },
    "select": ["id", "title", "stageId", "previousStageId", "movedTime", "assignedById"],
    "sort": { "movedTime": "asc" },
    "limit": 50
  }'
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/deals/search`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    filter: { movedTime: { $gte: cursor }, categoryId: 0 },
    select: ['id', 'title', 'stageId', 'previousStageId', 'movedTime', 'assignedById'],
    sort: { movedTime: 'asc' },
    limit: 50,
  }),
})
const { data: moved } = await res.json()
```

```json
{
  "success": true,
  "data": [
    {
      "id": 7999,
      "title": "Website request",
      "stageId": "EXECUTING",
      "previousStageId": "NEW",
      "movedTime": "2026-07-21T08:56:02.000Z",
      "assignedById": 1
    }
  ],
  "meta": { "total": 1, "hasMore": false, "durationMs": 812 }
}
```

Selecting by `movedTime` replaces keeping the stages of all deals in memory: the account itself reports what moved and where from. For a deal that has never changed stage, `previousStageId` comes back empty — such a record is not processed.

## Step 3. Duplicate check

A task is linked to a deal through the `ufCrmTask` field with the value `D_<deal identifier>`. [`POST /v1/tasks/search`](/docs/entities/tasks/search) finds all tasks of the deal by that same field — before creating one, compare the titles.

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/tasks/search" \
  -d '{
    "filter": { "ufCrmTask": "D_7999" },
    "select": ["id", "title"],
    "limit": 50
  }'
```

### JavaScript

```javascript
async function taskExists(dealId, title) {
  const res = await fetch(`${VIBE_URL}/v1/tasks/search`, {
    method: 'POST',
    headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      filter: { ufCrmTask: `D_${dealId}` },
      select: ['id', 'title'],
      limit: 50,
    }),
  })
  const { data } = await res.json()
  return data.some(task => task.title === title)
}
```

```json
{
  "success": true,
  "data": [{ "id": "3933", "title": "Prepare documents — Website request" }],
  "meta": { "total": 1, "hasMore": false, "durationMs": 717 }
}
```

## Step 4. Creating the task

[`POST /v1/tasks`](/docs/entities/tasks/create) creates a task. `title` is required, the other fields fill in the card: `responsibleId` — the person responsible, `deadline` — the due date, `priority` — the importance (`0` — low, `1` — normal, `2` — high), `ufCrmTask` — the link to the deal.

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/tasks" \
  -d '{
    "title": "Prepare documents — Website request",
    "description": "The deal moved to in progress. Prepare the set of documents.",
    "responsibleId": 1,
    "priority": 2,
    "deadline": "2026-07-26T12:00:00Z",
    "ufCrmTask": ["D_7999"]
  }'
```

### JavaScript

```javascript
const deadline = new Date(Date.now() + 5 * 86400_000).toISOString()

const res = await fetch(`${VIBE_URL}/v1/tasks`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    title: `Prepare documents — ${deal.title}`,
    description: 'The deal moved to in progress. Prepare the set of documents.',
    responsibleId: deal.assignedById,
    priority: 2,
    deadline,
    ufCrmTask: [`D_${deal.id}`],
  }),
})
const { data: task } = await res.json()
```

```json
{
  "success": true,
  "data": {
    "id": "3933",
    "title": "Prepare documents — Website request",
    "responsibleId": "1",
    "priority": "2",
    "deadline": "2026-07-26T14:00:00+02:00",
    "ufCrmTask": ["D_7999"],
    "status": "2"
  }
}
```

Numeric task fields are returned as strings: `"id": "3933"`, `"responsibleId": "1"`, `"priority": "2"`. When comparing, cast the value to a number or compare it with a string.

Checklist items are added to an already created task by separate calls to [`POST /v1/tasks/:taskId/checklist`](/docs/entities/tasks/checklist) — one per item.

## Step 5. A cursor that survives a restart

A cursor held in a process variable is lost on restart. The service then takes the current moment as the starting point and skips every move that happened while it was down. A file next to the script removes this limitation without external dependencies. This is file-system work, not an API call, so there is no cURL example here.

```javascript
import { readFileSync, writeFileSync, renameSync } from 'node:fs'

const CURSOR_FILE = './stage-cursor.json'

function readCursor() {
  try {
    return JSON.parse(readFileSync(CURSOR_FILE, 'utf8')).movedAfter ?? null
  } catch {
    return null
  }
}

function writeCursor(movedAfter) {
  writeFileSync(`${CURSOR_FILE}.tmp`, JSON.stringify({ movedAfter }))
  renameSync(`${CURSOR_FILE}.tmp`, CURSOR_FILE)
}
```

The write goes to a temporary file followed by a rename. A rename within one file system is atomic, so stopping the process in the middle of a write leaves no truncated file.

The cursor moves to the `movedTime` of the last processed deal. The `$gte` filter condition includes the boundary, so the last deal also lands in the next selection — the check from step 3 protects against a repeated task.

## Limitations

**Polling, not events.** The service polls the account instead of receiving events. Up to a minute passes between a deal moving and a task being created — the interval is set in the code.

**Repeated stage moves.** The `movedTime` field is updated on any move, including a deal returning to a previous stage. A deal moved back and forth lands in the selection every time, and a task is created again if the title differs from the existing one. The task title is assembled from a fixed template and the deal name, so that the check from step 3 finds the match.

**The check sees 50 tasks.** The check takes up to 50 tasks of the deal (`limit: 50` in step 3). On a deal with more tasks the match may fall outside the selection and the task is created again — raise the `limit` or narrow the selection with a filter.

**One running instance.** The cursor file belongs to a single running process. Two copies of the service on different machines read their own files and each creates a task. To run several instances, move the cursor to shared storage and leave the polling to one designated instance.

**Stages belong to a pipeline.** Stage identifiers are valid inside their own pipeline. In the main pipeline a stage looks like `NEW`, in an additional one — like `C9:NEW` with the pipeline number in the prefix. The `STAGE_TASKS` configuration is written for the pipeline set by `CATEGORY_ID`.

**Deadline in the account time zone.** The deadline is sent in ISO 8601 format and returned in the time zone of the account: `2026-07-26T12:00:00Z` comes back as `2026-07-26T14:00:00+02:00`. This is the same moment in time written differently.

**The creator is the key owner.** Tasks are created on behalf of the API key owner. In the task card the owner is the creator, and the person responsible is the employee from the `assignedById` field of the deal.

**Account queue.** The account processes a limited number of API requests at a time, the rest wait in a queue. The ceiling comes from the platform settings, so do not rely on a specific number. A loop that sends requests back to back sooner or later receives a `429`. The `QUEUE_OVERFLOW` code means the queue is full and the request was rejected immediately, `QUEUE_TIMEOUT` — that the request waited for a slot longer than 30 seconds and never reached Bitrix24, so it is safe to repeat. The recommended pause arrives in the `Retry-After` header and is duplicated in `error.retryAfter`. Repeat with an increasing delay instead of immediately — that is exactly what "Full code" does.

```json
{
  "success": false,
  "error": {
    "code": "QUEUE_OVERFLOW",
    "message": "Portal queue overloaded — 128 Bitrix24 calls already pending",
    "userMessage": "The account is overloaded with requests. Try again in a few seconds.",
    "hint": "Honor the Retry-After header. Use exponential backoff with jitter for repeated failures.",
    "retryAfter": 3
  }
}
```

A ready-made retry function is also in [Limits and optimization](/docs/optimization).

**BLACKHOLE server sleep.** If the service runs on a BLACKHOLE server, turn off automatic sleep. The idle timer counts **incoming** requests to the server, while the service only makes outgoing calls — from the timer's point of view the server is idle and falls asleep together with the service. Sleep is turned off through [`PATCH /v1/infra/servers/:id/sleep`](/docs/infra/lifecycle/sleep) with the value `sleepAfterMinutes: null`. On preemptible plans (`bc-agent`, `bc-micro`) that is not enough: the cloud restarts the machine about once a day anyway. The cursor in the file survives a restart, but while the server is asleep the stages may change several times, and the service sees only the last one.

The full list of error codes — [Errors](/docs/errors).

## Full code

```javascript
// task-automation.js — tasks from deals moving between stages
import { readFileSync, writeFileSync, renameSync } from 'node:fs'

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 CATEGORY_ID = 0
const INTERVAL_MS = 60_000
const CURSOR_FILE = './stage-cursor.json'

const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }

// The account reports errors in the body, not only in the status: without a check
// destructuring yields undefined and the script dies on the first field access
// instead of a readable message.
// Any 429 refusal (QUEUE_OVERFLOW, QUEUE_TIMEOUT, RATE_LIMITED) means the request never reached
// Bitrix24 — repeating it is safe. How long to wait is told by the Retry-After
// header, and in its absence by the error.retryAfter field. A random fraction of
// a second is added to the pause so that parallel copies of the service do not
// retry at the same moment.
const MAX_RETRIES = 5

async function readData(url, init = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, init)
    const body = await res.json().catch(() => null)

    if (res.status === 429 && attempt < MAX_RETRIES) {
      // Honor the advised pause as-is: scaling it by the attempt number would
      // turn Retry-After: 3 into 48 seconds on the fifth attempt. The
      // exponent is only for the case where the server named no pause.
      const advised = res.headers.get('Retry-After') ?? body?.error?.retryAfter ?? null
      // A 60 s ceiling: the advised pause is honored, but an unexpectedly large
      // value from an intermediate proxy must not stall the loop.
      const base = advised === null ? Math.min(2 ** attempt, 30) : Number(advised)
      const wait = Math.min(base, 60) + Math.random()
      console.warn(`The account queue is busy, retry in ${Math.round(wait)} s`)
      await new Promise(resolve => setTimeout(resolve, wait * 1000))
      continue
    }

    if (!body?.success) throw new Error(body?.error?.message ?? `request rejected (${res.status})`)
    return body.data
  }
}

// Which task to create when a deal moves to a stage
const STAGE_TASKS = {
  EXECUTING: {
    title: 'Prepare documents',
    description: 'The deal moved to in progress. Prepare the set of documents and start the work.',
    deadlineDays: 5,
    priority: 2,
  },
  PREPAYMENT_INVOICE: {
    title: 'Issue the invoice and track the payment',
    description: 'Issue a prepayment invoice and track the incoming funds.',
    deadlineDays: 3,
    priority: 2,
  },
  FINAL_INVOICE: {
    title: 'Final reconciliation and closing',
    description: 'Prepare the closing documents for the deal.',
    deadlineDays: 7,
    priority: 1,
  },
}

function readCursor() {
  try {
    return JSON.parse(readFileSync(CURSOR_FILE, 'utf8')).movedAfter ?? null
  } catch {
    return null
  }
}

function writeCursor(movedAfter) {
  writeFileSync(`${CURSOR_FILE}.tmp`, JSON.stringify({ movedAfter }))
  renameSync(`${CURSOR_FILE}.tmp`, CURSOR_FILE)
}

async function fetchStages() {
  const entityId = CATEGORY_ID === 0 ? 'DEAL_STAGE' : `DEAL_STAGE_${CATEGORY_ID}`
  const data = await readData(`${VIBE_URL}/v1/statuses?filter[entityId]=${entityId}`, { headers })
  return new Map(data.map(stage => [stage.statusId, stage.name]))
}

async function fetchMovedDeals(cursor) {
  return readData(`${VIBE_URL}/v1/deals/search`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      filter: { movedTime: { $gte: cursor }, categoryId: CATEGORY_ID },
      select: ['id', 'title', 'stageId', 'previousStageId', 'movedTime', 'assignedById'],
      sort: { movedTime: 'asc' },
      limit: 50,
    }),
  })
}

async function taskExists(dealId, title) {
  const data = await readData(`${VIBE_URL}/v1/tasks/search`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      filter: { ufCrmTask: `D_${dealId}` },
      select: ['id', 'title'],
      limit: 50,
    }),
  })
  return data.some(task => task.title === title)
}

async function createTask(deal, config, title) {
  const deadline = new Date(Date.now() + config.deadlineDays * 86400_000).toISOString()
  const data = await readData(`${VIBE_URL}/v1/tasks`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      title,
      description: [config.description, '', `Deal: ${deal.title} (${deal.id})`].join('\n'),
      responsibleId: deal.assignedById,
      priority: config.priority,
      deadline,
      ufCrmTask: [`D_${deal.id}`],
    }),
  })
  return data.id
}

async function tick(stageNames) {
  const cursor = readCursor()
  const deals = await fetchMovedDeals(cursor)

  for (const deal of deals) {
    if (!deal.previousStageId || deal.previousStageId === deal.stageId) continue

    const config = STAGE_TASKS[deal.stageId]
    if (!config) continue

    const title = `${config.title} — ${deal.title}`
    if (await taskExists(deal.id, title)) continue

    const taskId = await createTask(deal, config, title)
    const from = stageNames.get(deal.previousStageId) ?? deal.previousStageId
    const to = stageNames.get(deal.stageId) ?? deal.stageId
    console.log(`Deal ${deal.id}: ${from} → ${to}, task ${taskId}`)
  }

  const last = deals.at(-1)
  if (last) writeCursor(last.movedTime)
}

async function main() {
  if (!readCursor()) writeCursor(new Date().toISOString())

  const stageNames = await fetchStages()
  console.log(`Tracked stages: ${Object.keys(STAGE_TASKS).join(', ')}`)

  await tick(stageNames)
  setInterval(() => tick(stageNames).catch(console.error), INTERVAL_MS)
}

main().catch(console.error)
```

## See also

- [Create a task](/docs/entities/tasks/create)
- [Search tasks](/docs/entities/tasks/search)
- [Task checklist](/docs/entities/tasks/checklist)
- [Search deals](/docs/entities/deals/search)
- [Stages and statuses](/docs/entities/statuses)
- [Stage history](/docs/stage-history)
- [Telegram bot for CRM](/docs/recipes/telegram-bot)
