---
title: "Behaviour changes inside 2.x"
description: "Changes within the 2.x line that need a change on your side even though no API was removed — what breaks, how to tell, and what to do."
canonical_url: "https://bitrix24.github.io/b24jssdk/docs/getting-started/migration/within-2x"
last_updated: "2026-09-14"
---
# Behaviour changes inside 2.x

> Changes within the 2.x line that need a change on your side even though no API was removed — what breaks, how to tell, and what to do.

The other two guides on this page are about symbols that are removed. This one
is about the smaller set of changes that removed nothing and still need
something from you: a call that used to return now throws, or a value that used
to be ignored now means something.

> [!NOTE]
> Everything here ships inside the `2.x` line, so `pnpm up @bitrix24/b24jssdk`
> picks it up without a major-version step. That is exactly why it is worth
> reading — nothing about the upgrade signals that behaviour moved.

## 2.3.0 — a `restApi:v3` batch sends a different request body

**What changed.** A v3 batch now puts its commands on the wire as a bare
top-level JSON array. It used to send an object with numeric keys, and on an
OAuth transport it added the access token as one more top-level entry:

```text
before (B24Hook)   {"0":{…},"1":{…}}
before (B24OAuth)  {"0":{…},"1":{…},"auth":"<access token>"}
now                [{…},{…}]
```

On a server, `B24OAuth` sends the token in an `Authorization: Bearer` header
instead of in the body.

**What it fixes.** A v3 batch from `B24OAuth` never worked at all. The portal
reads every top-level entry of a batch body as a command and requires `method`
and `query` on each, so the `auth` entry failed the whole batch with
`BITRIX_REST_V3_EXCEPTION_INVALIDSELECTEXCEPTION` before a single command ran. A
webhook survived only because PHP cannot tell a JSON list from a map with
sequential integer keys.

The same rule had a second victim, invisible until the first was fixed: a command
written without `params` — `{ method: 'rest.scope.list' }`, or the tuple
`['rest.scope.list']` — went out with no `query` key, because `JSON.stringify`
drops an `undefined` value. That is refused under the same code, and one such
entry takes the whole batch down with it. Commands now always carry a `query`,
empty if there is nothing to put in it.

**Who is affected.**

- **`B24OAuth` on a server** — `actions.v3.batch.make` and
`actions.v3.batchByChunk.make` start working. Any workaround that looped
single calls can go.
- **A browser — `B24Frame`, or `B24OAuth` used client-side** — the same, with
the credential in the query string rather than a header. See the note below.
- **Anything that inspects the request** — a reverse proxy, a WAF body rule, an
egress gateway, a recorded HTTP fixture (nock / msw / VCR), or a test that
asserts on the request body. The body is an array now for **every** transport,
`B24Hook` included, even though a webhook batch already worked; and a
server-side OAuth batch carries a header it did not carry before. Re-record
fixtures, and let `Authorization` through the proxy.
- **Log readers** — the `post/send` line logs the body it sends, so a v3 batch
now writes `[{…},{…}]` where it wrote
`{"0":{…},…,"auth":"***REDACTED***"}`.

**What to do.** Nothing in your own code. The action arguments and the return
shape are unchanged, and so is `AjaxError.requestInfo.params` — that has always
carried the commands array, not the formatted body.

One thing to know if you sit behind a redirect. The header-carrying request is
sent with `maxRedirects: 0`, so it will not follow a 301/302 while holding the
token — `follow-redirects` keeps `Authorization` across a same-host or subdomain
hop, and the credential in the body was never exposed that way because a
redirect drops the body. Only that one request is constrained; see
[configuring the axios instance](https://bitrix24.github.io/b24jssdk/raw/docs/working-with-the-rest-api/core-http.md#configuring-the-axios-instance)
if you need to change it.

> [!NOTE]
> **In a browser the token travels in the query string.** The portal answers the
> CORS preflight with `Access-Control-Allow-Headers: origin, content-type, accept`
> — no `authorization` — so a browser cannot send the header at all; such a
> request would never leave. A v3 batch body is entirely commands, with nowhere
> for a credential, so the remaining place is the URL: the SDK appends
> `?auth=<token>` on this one request shape. The portal reads it through the same
> dictionary as a body `auth` (`CRestUtil::getRequestData()` merges GET over
> POST), which is why it works at all.
> This is the only place the SDK puts an OAuth token in a URL, and it is worth
> knowing what that means: the value reaches the portal's web-server access log
> and, while an administrator has the REST module's diagnostic logger switched on,
> its `REQUEST_URI` field. It does not become visible to anyone who could not
> already read it — in a frame app the token is in the page's own JavaScript, and
> every non-batch call carries it in the body — but it does leave a server-side
> record that a body would not.
> `B24Hook` is unaffected: its secret is already in the URL path where the portal
> puts it, so nothing is appended.
> The day `authorization` appears in the portal's allow-list, a browser takes the
> same header path a server takes today and this goes away.

## 3.0.0 — every keyset walk now stops after 10 000 pages

**What changed.** `callList` / `fetchList` (both API versions) and `callTail` /
`fetchTail` (v3) take a `maxPages` ceiling, and it **defaults to 10 000**. A walk
that reaches it stops with `JSSDK_ACTION_MAX_PAGES_EXCEEDED`{className="language-ts-type shiki shiki-themes material-theme-lighter material-theme material-theme-palenight" language="ts-type" style=""}
naming the method. The same walkers also take an `AbortSignal`{className="language-ts-type shiki shiki-themes material-theme-lighter material-theme material-theme-palenight" language="ts-type" style=""}
as `signal`, and the eager ones take a `progress` callback.

**What it replaces.** An unbounded `while (true)`. The stall guard added in 2.3.0
catches a cursor that stops moving, but not one that *keeps* moving and never
ends — a filter that matched far more than intended, or a cursor cycling between
two values, which never repeats the immediately preceding one.

> [!NOTE]
> The cycling case was taken off the ceiling in 3.0.0, which reports it as
> `JSSDK_ACTION_CURSOR_WENT_BACKWARDS`{className="language-ts-type shiki shiki-themes material-theme-lighter material-theme material-theme-palenight" language="ts-type" style=""} on the first page that
> steps backwards instead of after ten thousand. What remains for the ceiling is a walk that is genuinely
> larger than expected, or one whose cursor values the SDK cannot order. See
> [Behaviour: a cycling or backwards cursor now stops the walk](https://bitrix24.github.io/b24jssdk/raw/docs/getting-started/migration/v3.md#behaviour-a-cycling-or-backwards-cursor-now-stops-the-walk).

**Who is affected.** Anyone whose walk legitimately reads more than 10 000 pages
— 500 000 rows at the default page size of 50. That is past the point where the
eager helpers, which hold every row in memory, are the right tool; but a
*streaming* walk over a table that large used to complete and now stops.

**What to do.** Nothing, unless you read more than 10 000 pages. If you do, pass
a higher `maxPages`. Note that reaching the ceiling does **not** throw away what
was read: the eager walkers resolve with the rows they collected and the error
attached, so the shape to check is `isSuccess`, not a `catch`.

```ts
import { B24Hook } from '@bitrix24/b24jssdk'

const b24 = B24Hook.fromWebhookUrl('https://your-portal.bitrix24.ru/rest/1/SECRET')

const response = await b24.actions.v2.callList.make({
  method: 'crm.item.list',
  params: { entityTypeId: 2 },
  idKey: 'id',
  customKeyForResult: 'items',
  maxPages: 40_000
})

if (!response.isSuccess) {
  // The rows are still here — they are correct, merely incomplete.
  console.warn(response.getErrorMessages(), response.getData().length)
}
```

A ceiling that is not a positive integer is refused at call time with
`JSSDK_ACTION_INVALID_MAX_PAGES`{className="language-ts-type shiki shiki-themes material-theme-lighter material-theme material-theme-palenight" language="ts-type" style=""} rather than coerced.

## 2.3.0 — a stalled pagination cursor now throws

**What changed.** `callList` / `fetchList` (both API versions) and `callTail` /
`fetchTail` (v3) now raise `SdkError`{className="language-ts-type shiki shiki-themes material-theme-lighter material-theme material-theme-palenight" language="ts-type" style=""} with code
`JSSDK_ACTION_CURSOR_STALLED` when a full page comes back and the cursor read
from it equals the one just sent. That means the server did not apply the page
condition, so the same page will keep arriving.

**What it replaces.** Not an error — a hang. The walk had no way to end: the
page is full, so no end-of-data check fires. `fetchList` / `fetchTail` yielded
the same rows for ever; `callList` / `callTail` grew an array until the process
ran out of memory.

> [!NOTE]
> **Extended in `3.0.0`.** This check compares the cursor against the one
> *immediately* preceding it, so a server alternating between two pages slipped
> past it, and a stalled page that happened to be short ended the walk silently.
> Both are closed by a companion code — see
> [the 3.0.0 guide](https://bitrix24.github.io/b24jssdk/raw/docs/getting-started/migration/v3.md#behaviour-a-cycling-or-backwards-cursor-now-stops-the-walk).

**Who is affected.** Anyone whose `idKey` / `cursorIdKey` pair does not match
the method. The measured case is `restApi:v2` `tasks.task.list` with
`idKey: 'id'` and no `cursorIdKey`: the response spells the id lowercase, the
filter accepts it uppercase, so `>id` matches nothing and is dropped.

**What to do.** Fix the configuration — that is what the error text tells you —
and, if you call the eager helpers, add a `try`/`catch`:

```ts
import { B24Hook, SdkError } from '@bitrix24/b24jssdk'

const b24 = B24Hook.fromWebhookUrl('https://your-portal.bitrix24.ru/rest/1/SECRET')

try {
  const response = await b24.actions.v2.callList.make({
    method: 'tasks.task.list',
    idKey: 'id', // the id as the response spells it
    cursorIdKey: 'ID', // the field the request sorts and filters by
    customKeyForResult: 'tasks'
  })
  console.log(response.getData())
} catch (error) {
  if (error instanceof SdkError && error.code === 'JSSDK_ACTION_CURSOR_STALLED') {
    // the walk was repeating one page — nothing collected is worth keeping
    console.error('pagination is broken for this method, check idKey/cursorIdKey')
  }
  else {
    throw error
  }
}
```

> [!CAUTION]
> `callList` and `callTail` are documented elsewhere as methods that return a
> `Result`{className="language-ts-type shiki shiki-themes material-theme-lighter material-theme material-theme-palenight" language="ts-type" style=""} rather than throwing. That is still true of **portal**
> errors — check `isSuccess` for those. It is not true of the guards that make the
> walk itself impossible, this one included: those reject the promise. If you have
> code that awaits `callList` without a `try`/`catch` because the docs said it
> never throws, that code needs the `catch` now.

**If you stream, the duplicates are already yours.** `fetchList` / `fetchTail`
yield each page as it arrives, so by the time this throws your consumer has
already seen the repeated page. A `catch` that resumes from the last saved id is
right for a failed page request and wrong here — roll the persisted chunks back
instead.

**One more shape.** A `cursorField` naming an object- or array-valued field is
now treated as no cursor at all: the walk logs a `warning` and stops, where
before it paged for ever (two distinct references are never equal, so the guard
alone would not have caught it). Point `cursorField` at a scalar.

## 2.3.0 — a v3 `aggregate` returns strings, and `null` over no rows

**What changed.** `AggregateResultV3`{className="language-ts-type shiki shiki-themes material-theme-lighter material-theme material-theme-palenight" language="ts-type" style=""} was
`Record<string, number>`{className="language-ts-type shiki shiki-themes material-theme-lighter material-theme material-theme-palenight" language="ts-type" style=""} and is now
`Partial<Record<AggregateFunctionV3, Partial<Record<string, string | number | null>>>>`{className="language-ts-type shiki shiki-themes material-theme-lighter material-theme material-theme-palenight" language="ts-type" style=""}.
The SDK does not convert the values.

```text
before  { count: { id: 27 },    sum: { amount: 12345.67 } }   // what the type claimed
now     { count: { id: '27' },  sum: { amount: '12345.6700' } } // what a portal sends
```

> [!NOTE]
> `AggregateV3` is `@experimental`, so this correction ships inside the `2.x` line
> rather than waiting for a major. In practice it is unlikely to have broken
> anyone: no shipped Bitrix24 module publishes an `*.aggregate` action on any of the
> four portals checked, so there is nothing on a real portal to call it on yet. It is listed here for the callers who
> wrote against the type anyway.

**Why.** The contract had never been measured — no shipped module publishes an
`*.aggregate` action on any of four portals — so it was verified against a module
written for the purpose, reaching the same `AggregateOrmActionTrait` /
`OrmRepository::getAllWithAggregate()` every future module will. Values arrive as
strings, with the scale the database chose. Over a filter matching no rows,
`count` is `'0'` and every other function is `null`, because SQL aggregates over
an empty set are null and only `count` has a zero.

**Who is affected.** Anyone doing arithmetic straight off the result.
`data.count.id + 1` concatenates now instead of adding, and a `?? 0` written
against a missing key does not catch an explicit `null`.

**What to do.** Convert deliberately, and pick the conversion per field:

```ts
import { Text } from '@bitrix24/b24jssdk'

const response = await $b24.actions.v3.aggregate.make({
  // @check-ignore: some.entity.aggregate is a placeholder — no shipped module publishes an *.aggregate action
  method: 'some.entity.aggregate',
  select: { count: ['id'], sum: ['amount'] }
})

if (response.isSuccess) {
  const data = response.getData()
  // A row count is safe through a float.
  const rows = Text.toNumber(data?.count?.id ?? 0)
  // A money total is not — `Text.toNumber` goes through `Number.parseFloat`,
  // so `'12345.6700'` loses precision exactly where a ledger cannot afford it.
  // Keep the string, or hand it to a decimal library.
  const total = data?.sum?.amount ?? null
}
```

**One request shape is now refused before it is sent.** A `select` naming no
aggregate column at all — `{}`, `{ count: [] }`, `{ count: {} }` — throws
`SdkError`{className="language-ts-type shiki shiki-themes material-theme-lighter material-theme material-theme-palenight" language="ts-type" style=""} with code `JSSDK_AGGREGATE_V3_EMPTY_SELECT`. The
portal answers such a request with a bare 500 carrying nothing to act on, after
the whole retry budget is spent on a call that was never going to work. An empty
list *beside* a non-empty one is still sent, because the portal answers it.

> [!NOTE]
> `AggregateV3` remains `@experimental`. The framework contract is pinned now, but
> nothing in the product exercises it, so the first module to ship an
> `*.aggregate` action may surface behaviour no synthetic caller could. Pin a
> version if you depend on the exact shape.

## See also

- [Error codes and handling](https://bitrix24.github.io/b24jssdk/raw/docs/working-with-the-rest-api/error-codes.md) — the
full table, including what each walker's remedy text names.
- [Discovering entity fields](https://bitrix24.github.io/b24jssdk/raw/docs/working-with-the-rest-api/discovering-entity-fields.md)
— before an aggregate call works at all, the field has to be `filterable`, which
is not the same as selectable.
- [CHANGELOG](https://github.com/bitrix24/b24jssdk/blob/main/CHANGELOG.md){rel="[\"nofollow\"]"} — every
change in the line, not only the ones that need action.

## Sitemap

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