Для AI-агентов: markdown этой страницы — /docs-content/recipes/crm-analytics.md индекс документации — /llms.txt
CRM-аналитика: воронка продаж
Сложность: начальный | Скоупы: crm | Стек: cURL / JavaScript
Считаем распределение сделок по стадиям воронки, конверсию в успешные и средний чек. Разбивку отдаёт один запрос агрегации — выгружать сами сделки не нужно.
Что понадобится
- API-ключ Вайбкод со скоупом
crm - Node.js 18 или новее для примеров на JavaScript,
jq— для примера на cURL
Во всех примерах $VIBE_URL — базовый адрес https://vibecode.bitrix24.tech, $VIBE_API_KEY — ваш API-ключ.
Как устроено решение
- Получаем названия и порядок стадий воронки — они задаются на портале и в коде их держать нельзя.
- Одним запросом агрегации получаем по каждой стадии количество сделок и сумму.
- Считаем конверсию и средний чек из полученных групп.
Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».
Шаг 1. Названия и порядок стадий
Идентификаторы стадий (NEW, EXECUTING, WON) одинаковы не на всех порталах, а их названия администратор меняет свободно. Список стадий основной воронки возвращает GET /v1/statuses с фильтром по 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": "Новая", "sort": 10, "semantics": null },
{ "statusId": "WON", "name": "Сделка успешна", "sort": 60, "semantics": "S" }
],
"meta": { "total": 2, "hasMore": false }
}
Поле sort задаёт порядок стадий в воронке — по нему выстраивается отчёт. Для дополнительных воронок идентификатор фильтра другой: DEAL_STAGE_{categoryId}, где categoryId — номер воронки.
Шаг 2. Разбивка по стадиям
POST /v1/deals/aggregate с группировкой по stageId возвращает по каждой стадии количество сделок и запрошенные агрегаты. Фильтр categoryId: 0 ограничивает выборку основной воронкой.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/deals/aggregate" \
-d '{
"aggregate": [
{ "field": "amount", "function": "sum" },
{ "field": "amount", "function": "avg" }
],
"filter": { "categoryId": 0 },
"groupBy": "stageId"
}'
JavaScript
const res = await fetch(`${VIBE_URL}/v1/deals/aggregate`, {
method: 'POST',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
aggregate: [
{ field: 'amount', function: 'sum' },
{ field: 'amount', function: 'avg' },
],
filter: { categoryId: 0 },
groupBy: 'stageId',
}),
})
const { data } = await res.json()
В ответе data.count — всего сделок под фильтр, data.groups — разбивка по стадиям.
{
"success": true,
"data": {
"count": 1217,
"aggregates": { "amount": { "sum": 2337719.23, "avg": 1920.88 } },
"groups": [
{ "stageId": "NEW", "count": 338, "aggregates": { "amount": { "sum": 144225.29, "avg": 426.7 } } },
{ "stageId": "EXECUTING", "count": 16, "aggregates": { "amount": { "sum": 1153146.44, "avg": 72071.65 } } },
{ "stageId": "WON", "count": 19, "aggregates": { "amount": { "sum": 2216, "avg": 116.63 } } }
],
"meta": { "totalRecords": 1217, "recordsProcessed": 1217, "truncated": false, "groupTotal": 7, "groupsTruncated": false }
}
}
Поле meta.truncated показывает, уложилась ли выборка целиком, а meta.groupsTruncated — попали ли в ответ все стадии.
Чтобы ограничить отчёт периодом, добавьте условие по дате создания в тот же filter.
{ "filter": { "categoryId": 0, "createdAt": { "$gte": "2026-01-01" } } }
Шаг 3. Конверсия и средний чек
Обе метрики считаются из групп, дополнительные запросы не нужны. Конверсия в успешные — доля сделок в стадии WON от всех сделок воронки. Средний чек по стадии приходит готовым в aggregates.amount.avg.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/deals/aggregate" \
-d '{"aggregate":[{"field":"amount","function":"avg"}],"filter":{"categoryId":0},"groupBy":"stageId"}' \
| jq -r '
.data as $d
| ($d.groups[] | select(.stageId == "WON") | .count) as $won
| "Всего сделок: \($d.count), выиграно: \($won), конверсия: \((100 * $won / $d.count) | floor) %",
($d.groups[] | "\(.stageId): \(.count), средний чек \(.aggregates.amount.avg // 0 | floor)")'
JavaScript
const byStage = new Map(data.groups.map(g => [g.stageId, g]))
// В дополнительных воронках стадия несёт префикс: C1:WON вместо WON.
const WON_STAGE = CATEGORY_ID === 0 ? 'WON' : `C${CATEGORY_ID}:WON`
const won = byStage.get(WON_STAGE)?.count ?? 0
const conversion = data.count ? (won / data.count) * 100 : 0
for (const stage of stages.sort((a, b) => a.sort - b.sort)) {
const group = byStage.get(stage.statusId)
if (!group) continue
const avg = group.aggregates.amount.avg ?? 0
console.log(`${stage.name}: ${group.count} сделок, средний чек ${Math.round(avg)} ₽`)
}
console.log(`Конверсия в успешные: ${conversion.toFixed(1)} %`)
Ограничения
Стадии привязаны к воронке. Стадии в разных воронках называются по-разному. В основной воронке идентификатор выглядит как NEW, в дополнительных — как C1:NEW с номером воронки в префиксе. Поэтому сравнивать stageId с голым NEW можно только внутри одной воронки, а отчёт по всем воронкам сразу собирается отдельными запросами с разными categoryId.
Пустые стадии. Стадии, в которых нет ни одной сделки, в groups не попадают. Отчёт строится по списку стадий из шага 1, а отсутствующая группа означает ноль.
Валюта не приводится. Сумма приходит в валюте самой сделки, а поле amount их не приводит к одной. Если на портале есть сделки в разных валютах, суммирование по стадии смешает их — для корректного итога отфильтруйте выборку по нужной валюте.
Очередь портала. При переполненной очереди портала запрос отклоняется до отправки в Битрикс24 — повторить его безопасно.
{
"success": false,
"error": {
"code": "QUEUE_OVERFLOW",
"message": "Portal queue overloaded — 128 Bitrix24 calls already pending",
"userMessage": "Слишком много одновременных запросов к Bitrix24 — повторите через несколько секунд.",
"hint": "Honor the Retry-After header. Use exponential backoff with jitter for repeated failures.",
"retryAfter": 3
}
}
Рекомендованная пауза дублируется заголовком Retry-After. Полный перечень кодов — Ошибки.
Полный код
// funnel-report.js — отчёт по воронке продаж
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 CATEGORY_ID = 0
// В основной воронке стадия успеха — 'WON', в дополнительных к ней
// добавляется префикс воронки: C1:WON. Без этого отчёт по любой
// воронке кроме основной молча показал бы нулевую конверсию.
const WON_STAGE = CATEGORY_ID === 0 ? 'WON' : `C${CATEGORY_ID}:WON`
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
// Портал и платформа отвечают ошибкой в теле, а не только статусом. Без этой
// проверки деструктуризация даёт undefined и скрипт падает на первом обращении
// к полю — например TypeError вместо понятного 402 при нулевом балансе.
// Любой отказ 429 (QUEUE_OVERFLOW, QUEUE_TIMEOUT, RATE_LIMITED) означает,
// что запрос до Битрикс24 не дошёл, — повторить его безопасно. Сколько ждать, говорит заголовок
// Retry-After, а при его отсутствии поле error.retryAfter. К паузе добавляется
// случайная доля секунды, чтобы
// параллельные копии скрипта не пошли на повтор одновременно.
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) {
// Рекомендованную паузу берём как есть: умножать её на номер попытки
// нельзя — из Retry-After: 3 на пятой попытке вышло бы 48 секунд.
// Экспонента нужна только когда сервер паузу не назвал.
const advised = res.headers.get('Retry-After') ?? body?.error?.retryAfter ?? null
// Потолок в 60 с: рекомендованную паузу мы уважаем, но неожиданно
// большое значение от промежуточного прокси не должно вешать цикл.
const base = advised === null ? Math.min(2 ** attempt, 30) : Number(advised)
const wait = Math.min(base, 60) + Math.random()
console.warn(`Очередь портала занята, повтор через ${Math.round(wait)} с`)
await new Promise(resolve => setTimeout(resolve, wait * 1000))
continue
}
if (!body?.success) throw new Error(body?.error?.message ?? `запрос отклонён (${res.status})`)
return body.data
}
}
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 data.sort((a, b) => a.sort - b.sort)
}
async function fetchFunnel() {
return readData(`${VIBE_URL}/v1/deals/aggregate`, {
method: 'POST',
headers,
body: JSON.stringify({
aggregate: [
{ field: 'amount', function: 'sum' },
{ field: 'amount', function: 'avg' },
],
filter: { categoryId: CATEGORY_ID },
groupBy: 'stageId',
}),
})
}
async function main() {
const [stages, funnel] = await Promise.all([fetchStages(), fetchFunnel()])
const byStage = new Map(funnel.groups.map(g => [g.stageId, g]))
console.log(`Всего сделок в воронке: ${funnel.count}`)
for (const stage of stages) {
const group = byStage.get(stage.statusId)
const count = group?.count ?? 0
const share = funnel.count ? (count / funnel.count) * 100 : 0
const avg = group?.aggregates.amount.avg ?? 0
console.log(
`${stage.name.padEnd(26)} ${String(count).padStart(6)} ${share.toFixed(1).padStart(5)} % ` +
`средний чек ${Math.round(avg)} ₽`,
)
}
const won = byStage.get(WON_STAGE)?.count ?? 0
const revenue = byStage.get(WON_STAGE)?.aggregates.amount.sum ?? 0
console.log(`Конверсия в успешные: ${(funnel.count ? (won / funnel.count) * 100 : 0).toFixed(1)} %`)
console.log(`Выручка по успешным: ${Math.round(revenue)} ₽`)
}
main().catch(console.error)