For AI agents: markdown of this page — /docs-content-en/recipes/task-automation.md documentation index — /llms.txt
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
crmandtaskscopes - 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
- Fetch the list of pipeline stages — identifiers and names are defined by the Bitrix24 account.
- Once a minute, request the deals whose stage-change moment is newer than the stored cursor.
- The response carries the current and the previous stage — the configuration uses them to decide which task to create.
- Before creating, check whether the deal already has such a task.
- 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 with a filter on DEAL_STAGE.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" \
"$VIBE_URL/v1/statuses?filter[entityId]=DEAL_STAGE"
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()
{
"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 uses movedTime to select everything that has moved since the previous poll.
cURL
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
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()
{
"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 finds all tasks of the deal by that same field — before creating one, compare the titles.
cURL
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
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)
}
{
"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 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
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
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()
{
"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 — 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.
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.
{
"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.
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 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.
Full code
// 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)