# CRM analytics: sales funnel

**Difficulty:** beginner | **Scopes:** crm | **Stack:** cURL / JavaScript

Calculate the distribution of deals across funnel stages, the conversion to won and the average deal size. A single aggregation request returns the breakdown — there is no need to download the deals themselves.

## What you need

- A Vibecode API key with the `crm` scope
- Node.js 18 or newer for the JavaScript examples, `jq` for the cURL one

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. Get the names and the order of the funnel stages — they are configured in your Bitrix24 account and must not be hardcoded.
2. A single aggregation request returns the number of deals and the amount for every stage.
3. Compute the conversion and the average deal size from the returned groups.

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

## Step 1. Stage names and order

Stage identifiers (`NEW`, `EXECUTING`, `WON`) are not the same in every Bitrix24 account, and an administrator can rename them freely. The list of stages of the main funnel 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": "WON", "name": "Won", "sort": 60, "semantics": "S" }
  ],
  "meta": { "total": 2, "hasMore": false }
}
```

The `sort` field sets the order of stages in the funnel — the report follows it. Additional funnels use a different filter identifier: `DEAL_STAGE_{categoryId}`, where `categoryId` is the funnel number.

## Step 2. Breakdown by stage

[`POST /v1/deals/aggregate`](/docs/entities/deals/aggregate) grouped by `stageId` returns the number of deals and the requested aggregates for every stage. The `categoryId: 0` filter limits the selection to the main funnel.

### cURL

```bash
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

```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()
```

In the response `data.count` is the total number of deals matching the filter, `data.groups` is the breakdown by stage.

```json
{
  "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 }
  }
}
```

The `meta.truncated` field shows whether the whole selection fit, and `meta.groupsTruncated` shows whether all stages made it into the response.

To limit the report to a period, add a condition on the creation date to the same `filter`.

```json
{ "filter": { "categoryId": 0, "createdAt": { "$gte": "2026-01-01" } } }
```

## Step 3. Conversion and average deal size

Both metrics are computed from the groups, no extra requests are needed. Conversion to won is the share of deals in the `WON` stage out of all deals in the funnel. The average deal size per stage is returned directly in `aggregates.amount.avg`.

### cURL

```bash
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
  | "Total deals: \($d.count), won: \($won), conversion: \((100 * $won / $d.count) | floor) %",
    ($d.groups[] | "\(.stageId): \(.count), average deal \(.aggregates.amount.avg // 0 | floor)")'
```

### JavaScript

```javascript
const byStage = new Map(data.groups.map(g => [g.stageId, g]))

// In additional pipelines the stage carries a prefix: C1:WON instead of 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} deals, average deal ${Math.round(avg)}`)
}

console.log(`Conversion to won: ${conversion.toFixed(1)} %`)
```

## Limitations

**Stages belong to their funnel.** Stages are named differently in different funnels. In the main funnel the identifier looks like `NEW`, in additional ones like `C1:NEW` with the funnel number as a prefix. So comparing `stageId` with a bare `NEW` only works inside a single funnel, and a report across all funnels is assembled from separate requests with different `categoryId`.

**Empty stages.** Stages with no deals at all do not appear in `groups`. The report is built from the list of stages from step 1, and a missing group means zero.

**Currencies are not converted.** The amount arrives in the currency of the deal itself, and the `amount` field does not convert them to a single currency. If your Bitrix24 account has deals in different currencies, summing per stage mixes them — filter the selection by the required currency to get a correct total.

**The account queue.** When the account queue is full the request is rejected before it reaches Bitrix24 — repeating it is safe.

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

The recommended pause is duplicated in the `Retry-After` header. The full list of error codes — [Errors](/docs/errors).

## Full code

```javascript
// funnel-report.js — sales funnel report
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('Environment variable VIBE_API_KEY is not set')
const CATEGORY_ID = 0
// In the main pipeline the won stage is 'WON'; in additional ones the
// pipeline prefix is added: C1:WON. Without this a report on any pipeline
// other than the main one would silently show zero conversion.
const WON_STAGE = CATEGORY_ID === 0 ? 'WON' : `C${CATEGORY_ID}:WON`

const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
// The account and the platform report errors in the body, not only in the status.
// Without this check destructuring yields undefined and the script dies on the first
// field access — a TypeError instead of a readable 402 on an empty balance.
// Any 429 refusal (QUEUE_OVERFLOW, QUEUE_TIMEOUT, RATE_LIMITED) means the request never reached
// Bitrix24 — repeating it is safe. How long to wait is stated by the Retry-After
// header, and when it is absent by the error.retryAfter field. A random fraction
// of a second is added to the pause so that parallel copies of the script 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, retrying 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
  }
}

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(`Total deals in the funnel: ${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)} %  ` +
      `average deal ${Math.round(avg)}`,
    )
  }

  const won = byStage.get(WON_STAGE)?.count ?? 0
  const revenue = byStage.get(WON_STAGE)?.aggregates.amount.sum ?? 0
  console.log(`Conversion to won: ${(funnel.count ? (won / funnel.count) * 100 : 0).toFixed(1)} %`)
  console.log(`Revenue from won: ${Math.round(revenue)}`)
}

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

## See also

- [Deal aggregation](/docs/entities/deals/aggregate)
- [Deals](/docs/entities/deals)
- [Filtering syntax](/docs/filtering)
- [Limits and optimization](/docs/optimization)
