v2.2.0

Recipe: Export deals to CSV

Stream every CRM deal that matches a date filter into a CSV file using a webhook and FetchListV2.

Goal

Pull every CRM deal closed in a date window from a Bitrix24 portal and write it into a CSV file. Memory stays flat regardless of how many deals match — FetchListV2.make() streams pages of 50 records at a time.

Stack & Prerequisites

  • Node.js 20+, ESM ("type": "module" in package.json)
  • @bitrix24/b24jssdk@^1.0.0
  • A Bitrix24 inbound webhook URL with the crm scope. Set it as B24_HOOK in your environment.
pnpm add @bitrix24/b24jssdk
export B24_HOOK="https://your-portal.bitrix24.com/rest/1/xxxxxxxxxxxxxxxx/"

Full Example

scripts/deals-to-csv.mjs:

import { writeFile } from 'node:fs/promises'
import {
  B24Hook,
  EnumCrmEntityTypeId,
  ParamsFactory,
  Text
} from '@bitrix24/b24jssdk'

const hookUrl = process.env.B24_HOOK
if (!hookUrl) {
  console.error('B24_HOOK is not set')
  process.exit(1)
}

// Closed in the previous calendar month.
const now = new Date()
const from = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const to = new Date(now.getFullYear(), now.getMonth(), 1)

const b24 = B24Hook.fromWebhookUrl(hookUrl, {
  // Lower per-second pressure than `getDefault()` — good for reporting jobs
  // that run alongside live user traffic on the same portal.
  restrictionParams: ParamsFactory.getBatchProcessing()
})

const generator = b24.actions.v2.fetchList.make({
  method: 'crm.item.list',
  params: {
    entityTypeId: EnumCrmEntityTypeId.deal,
    filter: {
      '>=closedAt': Text.toB24Format(from),
      '<closedAt': Text.toB24Format(to),
      stageSemanticId: 'S' // closed-won
    },
    select: ['id', 'title', 'opportunity', 'currencyId', 'closedAt', 'assignedById']
  },
  idKey: 'id',
  customKeyForResult: 'items',
  requestId: 'deals-to-csv'
})

const rows = ['id;title;amount;currency;closedAt;assignedById']
let count = 0
for await (const chunk of generator) {
  for (const deal of chunk) {
    rows.push([
      deal.id,
      JSON.stringify(deal.title ?? ''),
      deal.opportunity ?? '',
      deal.currencyId ?? '',
      deal.closedAt ?? '',
      deal.assignedById ?? ''
    ].join(';'))
  }
  count += chunk.length
  process.stderr.write(`fetched: ${count}\r`)
}

const file = `deals-${from.toISOString().slice(0, 7)}.csv`
await writeFile(file, rows.join('\n'), 'utf8')
console.log(`\nwrote ${count} rows to ${file}`)

Run It

node scripts/deals-to-csv.mjs
# fetched: 4350
# wrote 4350 rows to deals-2026-04.csv

How It Works

  • B24Hook.fromWebhookUrl() parses the webhook URL and gives you a configured TypeB24 instance — no await initialization needed.
  • ParamsFactory.getBatchProcessing() swaps the default rate-limit profile for one tuned for long-running scans, so the script doesn't fight live UI requests for the portal queue.
  • actions.v2.fetchList.make() returns an AsyncGenerator<T[]>. Each for await iteration yields up to limit records (default 50) and uses cursor-based pagination on cursorIdKey (which defaults to idKey) ascending — the SDK takes care of stitching pages together.
  • Text.toB24Format(date) converts a JS Date to the ISO format Bitrix24 expects in crm.item.list filters; passing a raw Date would not work.

Limitations

  • crm.item.list returns up to 50 records per page; this is a server-side limit, not an SDK setting.
  • The generator does not support a custom order parameter — cursor pagination requires sorting by cursorIdKey (default idKey) ascending. Specifying order in params triggers a runtime warning and is stripped.
  • The walk stops after 10 000 pages — 500 000 deals at 50 a page — and throws JSSDK_ACTION_MAX_PAGES_EXCEEDED. That is a backstop against a runaway, not a cap on this export; pass a higher maxPages if a portal genuinely holds more. Because fetchList streams, every page written to the CSV before that point is already on disk.
  • idKey defaults to 'ID' (uppercase) for v2 endpoints. CRM item methods return id (lowercase), so override it explicitly as in the example. If a method sorts/filters by a different field name than it returns (e.g. tasks.task.list sorts by ID but returns id), also set cursorIdKeycrm.item.list accepts the lowercase name in both places, which is why this script needs only idKey. Get that pair wrong and the walk throws JSSDK_ACTION_CURSOR_STALLED rather than looping: see Errors.
  • One B24Hook instance corresponds to a single user account on the portal. If the deals you need are owned by another user, the webhook must be created by that user (or by an account with cross-user access).
  • For very large windows that exhaust the per-day method-call budget, split the date range into smaller chunks and run them sequentially.

See also