---
title: "CRM analytics — sales funnel"
description: "Stream all deals via actions.v2.fetchList.make, group by stage, print a funnel report (counts, conversion %, avg ticket, win rate)."
canonical_url: "https://bitrix24.github.io/b24jssdk/docs/examples/crm-analytics"
last_updated: "2026-06-02"
---
# CRM analytics — sales funnel

> Stream all deals via actions.v2.fetchList.make, group by stage, print a funnel report (counts, conversion %, avg ticket, win rate).

## What it does

Loads every deal on the portal in chunks (no loading all rows into RAM), groups them by stage, and prints a console funnel report: counts, conversion percentages, average ticket, total revenue, win rate.

## Stack

Node.js 18+. No external dependencies.

## Environment

```bash
export B24_HOOK='https://your.bitrix24.com/rest/1/secret'
```

## Run

```bash
npx tsx 01-crm-analytics.ts
```

## Code

```ts [01-crm-analytics.ts]
import {
  B24Hook,
  ConsoleV2Handler,
  EnumCrmEntityTypeId,
  LogLevel,
  Logger,
  type TypeB24
} from '@bitrix24/b24jssdk'

// New SDK Logger. See /docs/working-with-the-rest-api/logger/.
const logger = Logger.create('CrmAnalytics')
logger.pushHandler(new ConsoleV2Handler(LogLevel.INFO, { useStyles: false }))

function bootB24(): TypeB24 {
  const url = process.env.B24_HOOK
  if (!url) throw new Error('B24_HOOK env var is required')
  const $b24 = B24Hook.fromWebhookUrl(url)
  $b24.offClientSideWarning?.()
  return $b24
}

const STAGE_NAMES: Record<string, string> = {
  NEW: 'New',
  PREPARATION: 'Preparation',
  PREPAYMENT_INVOICE: 'Prepayment invoice',
  EXECUTING: 'Executing',
  FINAL_INVOICE: 'Final invoice',
  WON: 'Won',
  LOSE: 'Lost'
}

const baseStage = (s: string) => (s.includes(':') ? s.split(':')[1] : s)

interface DealRow {
  id: number
  stageId: string
  opportunity: number
  currencyId: string
}

async function loadAllDeals($b24: TypeB24): Promise<DealRow[]> {
  const out: DealRow[] = []
  // Heartbeat every N items so a long load on a large portal doesn't look
  // like a hang. Drop on small portals where the runtime is sub-second.
  const progressEvery = 500
  let nextProgressAt = progressEvery
  const startedAt = Date.now()

  const generator = $b24.actions.v2.fetchList.make<DealRow>({
    method: 'crm.item.list',
    params: {
      entityTypeId: EnumCrmEntityTypeId.deal,
      select: ['id', 'stageId', 'opportunity', 'currencyId']
    },
    idKey: 'id',
    customKeyForResult: 'items',
    requestId: 'load-deals'
  })

  for await (const chunk of generator) {
    for (const it of chunk) {
      out.push({
        id: Number(it.id),
        stageId: it.stageId,
        opportunity: Number(it.opportunity ?? 0),
        currencyId: it.currencyId ?? 'RUB'
      })
    }
    if (out.length >= nextProgressAt) {
      const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1)
      logger.info(`  …${out.length} deals so far (${elapsed}s)`)
      nextProgressAt += progressEvery
    }
  }
  return out
}

interface StageStat { count: number; total: number }

function analyseFunnel(deals: DealRow[]): Map<string, StageStat> {
  const stages = new Map<string, StageStat>()
  for (const d of deals) {
    const s = stages.get(d.stageId) ?? { count: 0, total: 0 }
    s.count += 1
    s.total += d.opportunity
    stages.set(d.stageId, s)
  }
  return stages
}

function printFunnel(stages: Map<string, StageStat>, totalDeals: number) {
  const order = ['NEW', 'PREPARATION', 'PREPAYMENT_INVOICE', 'EXECUTING', 'FINAL_INVOICE', 'WON', 'LOSE']
  console.log(`Total deals: ${totalDeals}`)
  for (const baseId of order) {
    let count = 0
    let total = 0
    for (const [sid, s] of stages) {
      if (baseStage(sid) === baseId) { count += s.count; total += s.total }
    }
    if (count === 0) continue
    const conversion = totalDeals > 0 ? (count / totalDeals) * 100 : 0
    const avg = count > 0 ? total / count : 0
    console.log(`  ${STAGE_NAMES[baseId].padEnd(25)} ${String(count).padStart(8)} ${conversion.toFixed(1).padStart(9)}%  avg=${avg.toFixed(0)}`)
  }
}

async function main() {
  const $b24 = bootB24()
  const deals = await loadAllDeals($b24)
  logger.info(`Loaded ${deals.length} deals`)
  printFunnel(analyseFunnel(deals), deals.length)
}

main().catch((e: unknown) => {
  console.error('\n[recipe failed]', e instanceof Error ? `${e.name}: ${e.message}` : String(e))
  if (e instanceof Error && e.stack) console.error(e.stack)
  process.exit(1)
})
```

## Notes

- Multi-funnel portals: stage IDs come prefixed (`C2:WON`). The `baseStage()` helper aggregates them under the base name.
- `fetchList.make` keeps memory bounded — it streams 50-row chunks internally using a keyset cursor on `idKey`.
- The action **strips any user-supplied order** because keyset pagination requires `idKey ASC` — narrow with `filter` instead.
- For a more efficient revenue roll-up an `actions.v3.aggregate.make` would issue a single `sum` call, but that action surface is not yet exposed in the SDK.

## Sitemap

See the full [sitemap](/b24jssdk/sitemap.md) for all pages.
