v3.0.0

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.

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:

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 if you need to change it.

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 naming the method. The same walkers also take an AbortSignal 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.

The cycling case was taken off the ceiling in 3.0.0, which reports it as JSSDK_ACTION_CURSOR_WENT_BACKWARDS 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.

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.

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 rather than coerced.

3.0.0 — a batch is throttled on the budget the portal bills it against

What changed. The SDK tracks Bitrix24's operating-time budget per method, to hold a request back before the portal refuses it. For a batch it was tracking the wrong thing: the restApi:v2 path recorded each sub-result's time under a synthetic batch::<method> key, and both the operating limiter and the adaptive delay read only those. The portal keeps no such budget — measured on a live portal, it charges a batch to the method batch and leaves the methods inside it untouched. That real entry was recorded on every batch call and never read.

Now batch is read like any other method, and the synthetic keys are gone.

Who is affected. Anyone sending batches, on either API version — but you will only notice near the budget.

On restApi:v2 the budget was already consulted, just via numbers that described the whole batch while claiming to describe one method. On restApi:v3 a batch was subject to nothing: the old code needed a v2-shaped command list to find its keys, and a v3 batch does not carry one. Three things change there:

beforeafter
wait before sending, budget spentnoneuntil the reported reset
delay once past the adaptive thresholdnonea few seconds per batch
wait before retrying an OPERATION_TIME_LIMITflat 10 secondsuntil the reported reset

batchByChunk meets all three on every chunk, so a long chunked walk can take noticeably longer under load. That is the intent: those calls were heading for a refusal.

What to do. Nothing. No signature, type or export changed, and nothing stops compiling. If a chunked migration now runs slower than you expect, look at the budget rather than at the SDK — getStats().operatingStats reports it under batch, and it is per credential and per method, so it is not shared with the methods you call singly.

If you were reading operatingStats yourself and keying off batch::<method> entries, they no longer appear. Use batch.

import { ApiVersion } from '@bitrix24/b24jssdk'

const stats = $b24.getHttpClient(ApiVersion.v2).getStats()

// Before: { 'batch::crm.item.add': …, 'batch::crm.item.update': … }
// Now:    { batch: … }
console.log(stats.operatingStats)
On a self-hosted portal none of this engages unless its operating limiter has been switched on and given storage — by default it sends no counters and enforces no budget. See Limiters.

2.3.0 — a stalled pagination cursor now throws

What changed. callList / fetchList (both API versions) and callTail / fetchTail (v3) now raise SdkError 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.

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.

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:

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
  }
}
callList and callTail are documented elsewhere as methods that return a Result 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 was Record<string, number> and is now Partial<Record<AggregateFunctionV3, Partial<Record<string, string | number | null>>>>. The SDK does not convert the values.

before  { count: { id: 27 },    sum: { amount: 12345.67 } }   // what the type claimed
now     { count: { id: '27' },  sum: { amount: '12345.6700' } } // what a portal sends
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:

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 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.

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.

3.0.0 — concurrent token refreshes in a frame collapse into one

What changed. AuthManager.refreshAuth() now coalesces: while a refresh is in flight, every further call gets that same promise back instead of sending a second refreshAuth message to the parent window. The signature is unchanged — it still returns Promise<AuthData> and still rejects the same way.

What it fixes. The HTTP layer already coalesced, but per transport, and a B24Frame builds two of them — restApi:v2 and restApi:v3 — over a single AuthManager. A v2 call and a v3 call that hit the expiry together therefore sent two refreshes for one token. Bitrix warns that refreshing too often risks an application being auto-blocked, so that was worth closing on its own; it also makes the new keep-alive below safe to add as a third caller.

Who is affected. B24Frame only. B24Hook has no refresh, and B24OAuth refreshes over its own path.

What to do. Nothing, unless you counted refreshes. If you have a test that asserts "two transports, two refreshAuth messages", it now sees one — which is the fix, not a regression.

3.0.0 — sending through PullClient reports its failures instead of hiding them

What changed. sendMessage() and sendMessageToChannels() now reject on every failure they previously hid, and resolve true only once the transport has accepted the frame. Three codes:

CodeWhen
JSSDK_PULL_PUBLIC_IDS_UNAVAILABLEThe recipients' channels could not be resolved, so there is nobody to send to. sendMessage() only.
JSSDK_PULL_SEND_REFUSEDThe transport would not take the frame — the socket is not open, or long-polling has no publication path.
JSSDK_PULL_PUBLISHING_DISABLEDThe portal has not enabled client publishing.

What it replaces. Four separate ways for a send to go nowhere quietly:

  1. sendMessageBatch started the channel lookup and dropped the promise, so the method resolved before the lookup had answered.
  2. The lookup caught the portal's refusal and resolved an empty channel map — a valid input to the encoder, which then built a message addressed to nobody.
  3. The connector's send() returns false when the frame does not leave, and that boolean was passed to the caller as a resolved value.
  4. The publishing gate threw a bare Error with no code.

Measured on a live portal: the caller was told the message was accepted, nothing arrived, and nothing said why.

Who is affected. Anyone calling either method. In an application that is everyone who calls sendMessage(), because the underlying cause is not a fault: pull.channel.public.list is not part of the application REST surface. An application's Pull client is documented as receive-only — the back end publishes with pull.application.event.add.

Not every application reaches that rejection, though, and it is worth knowing why before reading one outcome as the only one. PullClient seeds the channel cache from publicChannels, returned by the config call it makes at startup — pull.application.config.get in an application. That map carries the current user's own channel, and the lookup is skipped whenever every recipient is already cached and unexpired. So a send addressed to the current user often goes out with no lookup and no rejection; a send to another user normally still needs the lookup, and still gets the rejection.

Where the send does go out, the outcomes are the ordinary ones: it may arrive, or it may not. Reaching the transport is not a delivery receipt — both connectors return once they have handed the bytes over, and long-polling never reads the response — and the push server drops a frame it cannot parse or address without a word. Note also that a message published this way comes back to subscribers of SubscriptionType.Client, not of Server, which is the default subscribe() gives you.

What is gone is the outcome that could not be told apart from any of these: a resolved call that never sent anything at all.

A loop with no catch can now take the process down. A heartbeat calling sendMessage() on a timer without handling the promise, on a portal where the lookup fails transiently — a 503, a rate limit — used to lose that one message and carry on. Now the call rejects, and an unhandled rejection terminates the process by default in Node 15 and later.For that caller the old behaviour was better. It was not better for anyone who needed to know their messages were not arriving, which is why the change stands — but a program that worked can stop working. Add the catch.

What to do

1. In an application, publish from the back end. This is the documented path, and the one to use. It is not that the others are blocked — nothing in the SDK gates publishing on being an application — but the channel lookup sendMessage() needs is outside the application REST surface, so it works only where the channel happened to be cached already.

import { B24Hook } from '@bitrix24/b24jssdk'

declare const b24: B24Hook

await b24.actions.v2.call.make({
  method: 'pull.application.event.add',
  params: {
    COMMAND: 'optionsChanged',
    PARAMS: { theme: 'dark' },
    MODULE_ID: 'application'
  }
})

2. Where the methods are genuinely available, handle the rejection. Branch on the code — the three mean different things, and only one is worth retrying.

import { SdkError } from '@bitrix24/b24jssdk'

declare const pull: {
  sendMessage: (users: number[], moduleId: string, command: string, params: unknown) => Promise<true>
}

try {
  await pull.sendMessage([42], 'application', 'optionsChanged', { theme: 'dark' })
} catch (error) {
  if (!(error instanceof SdkError)) {
    throw error
  }

  switch (error.code) {
    case 'JSSDK_PULL_PUBLIC_IDS_UNAVAILABLE': {
      // Usually permanent: the lookup method is not in this REST surface.
      // `error.originalError` carries the portal's own answer, which is how you
      // tell that apart from a transient 503.
      break
    }
    case 'JSSDK_PULL_SEND_REFUSED': {
      // The socket was not open. Worth retrying once reconnected.
      break
    }
    case 'JSSDK_PULL_PUBLISHING_DISABLED': {
      // The portal will not accept client publishing at all. Do not retry.
      break
    }
  }
}

3. In a timer or a loop, attach the catch at the call site. This is the case the caution above is about — the rejection must not escape.

declare const pull: {
  sendMessage: (users: number[], moduleId: string, command: string, params: unknown) => Promise<true>
}

setInterval(() => {
  void pull
    .sendMessage([42], 'application', 'heartbeat', {})
    .catch((error: unknown) => {
      // Log it and keep the timer alive, which is what the old silent resolve
      // did by accident.
      console.warn('pull heartbeat did not go out', error)
    })
}, 30_000)

4. Stop reading the resolved value. It used to be undefined — returned immediately, before anything had happened. It is now true, and only after the transport has accepted the frame.

true is not a delivery receipt. send() hands the frame to the socket, and long-polling fires its request without awaiting the response, so an accepted frame can still be lost in flight. The contract is "the transport took it".

Also changed: the call is no longer fire-and-forget. sendMessage() used to return before the channel lookup had even been issued. It now awaits the lookup and the send, so code that relied on the immediate return is affected even when nothing fails.

3.0.0 — a frame refreshAuth() rejects a malformed answer instead of resolving it

What changed. AuthManager.refreshAuth() now validates the parent window's answer before using it. If AUTH_EXPIRES is missing, empty or not a positive number, the call rejects with JSSDK_FRAME_REFRESH_AUTH_BAD_RESPONSE and the previous token is left intact. On success expires_in is updated too, which it previously never was — it kept the value from the initial handshake while expires moved.

What it fixes. The old code wrote the parsed value straight into the expiry. Number.parseInt('') is NaN, Date.now() + NaN is NaN, and NaN > Date.now() is false — so getAuthData() began returning false permanently, and refreshAuth() handed that false back to the caller typed as AuthData. One malformed answer therefore destroyed a still-valid token and left the frame unauthenticated for the rest of the page's life, with the type system asserting it could not happen.

Who is affected. B24Frame apps that call auth.refreshAuth() themselves and check the result. The SDK's own request path is unaffected — it already treats a rejected refresh as a failed request.

What to do. If you wrote const data = await $b24.auth.refreshAuth() and tested data for falsiness, that branch is now unreachable; handle a rejection instead. The error message names the missing field and never its value.

3.0.0 — a frame app can keep its token alive without making REST calls

What changed. initializeB24Frame() takes a new keepAuthFresh option. It is off by default, so nothing changes unless you ask for it:

const $b24 = await initializeB24Frame({ keepAuthFresh: true })

What it fixes. The SDK's automatic refresh only runs on the request path: before a call, and again when the portal answers 401. An app that reads the token with auth.getAuthData() and passes it to its own backend never makes a $b24 call, so neither path ever fires. After AUTH_EXPIRES seconds of an idle tab the token is simply gone, and the app's own requests start answering 401 — from the outside it looks like the app broke, though nothing changed.

Who is affected. Frame apps that use the token outside the SDK, and frame apps that sit open and idle. An app that makes its calls through $b24 was already covered.

What to do. Turn it on if either describes you. See Keeping the token alive for the schedule and the tuning knobs. Leave it off otherwise: it changes when your app talks to the parent window, and refreshing too often is what Bitrix warns can get an application auto-blocked.

See also

  • Error codes and handling — the full table, including what each walker's remedy text names.
  • Discovering entity fields — before an aggregate call works at all, the field has to be filterable, which is not the same as selectable.
  • CHANGELOG — every change in the line, not only the ones that need action.