v2.2.0

CallV3.make

Method for making Bitrix24 REST API version 3 calls.

Overview

Use CallV3.make() to call REST API version 3 methods.

The method returns a Promise with an AjaxResult object containing response data, status, and error handling methods.

// Basic usage
const response = await $b24.actions.v3.call.make({
  method: 'tasks.task.get',
  params: {
    id: 123
  },
  requestId: 'unique-request-id'
})

Method Signature

make<T = unknown>(
  options: ActionCallV3
): Promise<AjaxResult<T>>

Parameters

The options object contains the following properties:

ParameterTypeRequiredDescription
methodstringYesREST API method name (e.g., tasks.task.get, tasks.task.add).
paramsTypeCallParamsNoObject with parameters to pass to the REST API method.
requestIdstringNoUnique request identifier for tracking and debugging — sent as the bx24_request_id query parameter. It does not deduplicate anything; for that see idempotencyKey (restApi:v3).

Return Value

Promise<AjaxResult<T>> — a promise that resolves to an AjaxResult object.

This object provides:

  • .getData(): SuccessPayload<T> | undefined — returns the success envelope { result: T, time?: PayloadTime }, or undefined when the request failed. Check .isSuccess first. time is optional — not every response carries one, and the SDK does not invent it.
  • .isSuccess: boolean — flag indicating successful request execution.
  • .getErrorMessages(): string[] — array of error messages.

Error Handling

The SDK does not keep a client-side list of v3 methods: CallV3.make() sends whatever method you pass to the v3 endpoint and the server decides. If the method is not a v3 method, the server replies with METHODNOTFOUNDEXCEPTION as a soft error (response.isSuccess === false, the message in getErrorMessages()) — there is no pre-flight SDK throw. Use CallV2.make() for legacy methods, or check apidocs.bitrix24.com (or the portal's own rest.documentation.openapi) for what exists in v3.

For successful requests, always check isSuccess and handle errors:

// @check-ignore: top-level return in error-handling illustration; some.method is a placeholder, not a portal method

const response = await $b24.actions.v3.call.make({
  method: 'some.method',
  params: { /* some_params */ },
  requestId: 'unique-request-id'
})

if (!response.isSuccess) {
  // Handling error
  console.error(new Error(`Error: ${response.getErrorMessages().join('; ')}`))
  return
}

// Working with a successful result
const data = response.getData()?.result

What a write returns

The shape is the module's choice. Three modules were measured and no two agree:

moduleaddupdate
stock ORM traits{ result: { id: 22 } }{ result: true }
tasks.task{ result: { item: {} } }{ result: { result: true } }
note.collection{ result: { item: {} } }{ result: { item: {} } }

The evidence behind each row is different in kind, which is worth knowing before leaning on any of them. The first is a module built for these probes with no custom action code, so it shows the framework default — and the framework's own response types say the same outright: AddResponse declares one property, public int $id, and UpdateResponse extends BooleanResponse. The second was measured here. The third is as the reporter of #465 observed it, not re-measured on this build. The last two rows are modules declaring their own response, and tasks.task.update even nests its boolean one level deeper than the trait does.

So there is no rule to learn, only a per-method contract to read. A caller reaching for result.id after an add may get an id, an object, or undefined — check what the method you are calling documents, and type the call accordingly.

*.delete answered { result: true } and *.get answered { result: { item: {} } } everywhere it was measured — the table above shows the whole envelope, so getData() on an add through the trait hands you { id: 22 }, not the 22 itself.

A wrong type is not always refused. Measured on an on-premise build: select sent as a string where an array is declared answered HTTP 200, and a number sent where a string is declared was accepted and stored. A string that cannot become a number is refused — with BITRIX_REST_V3_EXCEPTION_INVALIDJSONEXCEPTION. So validation is uneven rather than absent, and a type bug can surface later as odd data instead of at the call.

Examples

Getting a Task

import { B24Hook, LoggerFactory, SdkError, AjaxError } from '@bitrix24/b24jssdk'

type Task = {
  id: number
  title: string
  autocompleteSubTasks: boolean
}

const devMode = typeof import.meta !== 'undefined' && (import.meta?.dev || import.meta.env?.DEV)
const $logger = LoggerFactory.createForBrowser('Example:taskGet', devMode)
const $b24 = B24Hook.fromWebhookUrl('https://your_domain.bitrix24.com/rest/1/webhook_code/')

async function getTask(itemId: number, requestId: string): Promise<Task> {
  const response = await $b24.actions.v3.call.make<{ item: Task }>({
    method: 'tasks.task.get',
    params: {
      id: itemId,
      select: ['id', 'title', 'autocompleteSubTasks']
    },
    requestId
  })

  if (!response.isSuccess) {
    throw new SdkError({
      code: 'MY_APP_GET_PROBLEM',
      description: `Problem ${response.getErrorMessages().join('; ')}`,
      status: 404
    })
  }

  return (response.getData()!.result as { item: Task }).item
}

// Usage
const itemId = 2
const requestId = `task-${itemId}`
try {
  const entity = await getTask(itemId, requestId)
  $logger.info(`Entity [${entity.id}]`, { entity })
} catch (error) {
  if (error instanceof AjaxError) {
    $logger.critical(error.message, { requestId, code: error.code })
  } else {
    $logger.alert('Problem', { requestId, error })
  }
}

Long-Running Requests

Non-idempotent methods that create or persist state — for example tasks.task.add — can exceed the default 30-second axios timeout on a busy portal. On a timeout the SDK retries by default, which can create duplicate entities server-side (see issue #24).

For any method that creates or persists state, raise the axios timeout and disable retries on transport errors:

import { ApiVersion, ParamsFactory } from '@bitrix24/b24jssdk'

// const $b24 = ...
$b24.getHttpClient(ApiVersion.v3).ajaxClient.defaults.timeout = 120_000
await $b24.setRestrictionManagerParams({
  ...ParamsFactory.getDefault(),
  retryOnNetworkError: false
})

await $b24.actions.v3.call.make({
  method: 'tasks.task.add',
  params: {/*…*/}
})

See Limiters → Long-Running Requests & Non-idempotent Calls for the full explanation and alternative configurations.

Idempotency-Key

The settings above stop the SDK from creating a duplicate. Idempotency-Key lets the portal stop it instead, which also covers the cases the SDK cannot see: your own code, another process, or your user running the same operation twice.

The four ways a duplicate actually happens

What the user or the system doesWithout a keyWith a key
Double-clicks Create deal, or an impatient second tap on a slow mobile formTwo dealsThe second call replays the first response — same id
The request times out, the SDK retries, but the portal had already written the record (issue #24)Two records, and the caller sees only the secondOne record; the retry replays it
A queue worker (BullMQ, cron, a Lambda) crashes after the write but before it marked the job done, so the job runs againTwo recordsOne record
Bitrix24 delivers an outbound event twice — event delivery is at-least-once — and your handler creates something per eventTwo recordsOne record
An import from an accounting system, a shop or a telephony log is re-run after a partial failure, over rows it already loadedEvery already-loaded row againEach row once, however many times the import runs

The first two are what retryOnNetworkError: false and a raised timeout also address. The last three they cannot touch at all: the duplicate comes from a second process or a second run, which knows nothing about the first one's retry policy. That is the gap the key closes.

Bulk imports and re-syncs

This is where the key pays for itself, because an import already has the one thing that is otherwise hard to invent: the source system's own identifier. A contract from an accounting system has a GUID, a shop order has an order number, a call has a call id. That identifier is the key, so a re-run of the import is free of duplicates without any bookkeeping on your side:

declare const rows: Array<{ externalId: string, title: string }>
declare const responsibleId: number
// const $b24 = ...

for (const row of rows) {
  await $b24.actions.v3.call.make({
    method: 'tasks.task.add',
    // `creatorId` and `responsibleId` are required by tasks.task.add — a
    // fields object with only a title is refused by validation.
    params: { fields: { title: row.title, creatorId: responsibleId, responsibleId } },
    // The source system's id, prefixed by the operation. Re-run the import
    // tomorrow and every row that already loaded is replayed, not rewritten.
    idempotencyKey: `import-task-${row.externalId}`
  })
}

The pattern this replaces is a read before every write — search for a row carrying the external id, create only if nothing came back. That costs one extra call per row, needs a custom field to search on, and still races: two runs overlapping both see "not there" and both create. The key moves the decision to the portal, where it is made once.

Two limits decide whether you can actually use this, and both are easy to miss:

  • batch carries no key. So a bulk load that must be duplicate-free is a loop of single calls, not a batch — you trade throughput for the guarantee. Size the run accordingly, and see Limiters.
  • Only restApi:v3 methods. The portal honours the header under /rest/api/ alone, so the write has to exist on v3 — see below.

What this covers today

The v3 endpoint is not a copy of v2; it publishes its own modules, and the set of things you can create through it is still small. On a cloud portal measured 2026-09-05 (245 methods, 14 modules) the creating methods were tasks.task.add, tasks.task.result.add, note.collection.add, note.document.add, note.file.add, humanresources.node.add and a handful under rest.* / main.*.

There is no crm.* create on v3 — no lead, deal, contact or company. So the classic integration write (an accounting system creating counterparties, a shop creating leads, telephony creating call records) runs through crm.*, which is restApi:v2, where the header is ignored. The v2 transport drops the key and logs a warning rather than letting you believe otherwise.

For those writes the answer is still the one this feature replaces elsewhere: store the source system's identifier in a field on the entity and check it before inserting — which is what the ERP sync recipe does. Ask the portal what it publishes rather than assuming, with rest.documentation.openapi.

Choosing the key

This is the whole of the practice, and getting it wrong makes the feature decorative:

The key must identify the operation, not the attempt. Two attempts at the same business operation must carry the same key, or nothing is deduplicated.

The easy way to get this wrong is crypto.randomUUID() written at the call site. That works for a double-click inside one running process, and for the SDK's own retry — both reuse the variable. It does nothing for the crashed worker or the redelivered event, because the restarted process mints a fresh UUID and writes a second record.

Two shapes that hold up:

interface QueueJob { orderId: number, idempotencyKey?: string }
declare const job: QueueJob
declare const jobs: { save: (job: QueueJob) => Promise<void> }

// A. Derive the key from the operation. Nothing to store: the same inputs
//    always produce the same key, in any process, after any restart.
const keyFromOperation = `deal-${job.orderId}-create`

// B. Mint a random key once, and persist it *before* the call, alongside the
//    job. A retry of that job reads the key back rather than making a new one.
job.idempotencyKey ??= crypto.randomUUID()
await jobs.save(job)

Shape A is usually simpler and is what an event handler wants: an event delivered twice carries the same event id both times, so `deal-from-event-${event.id}` is already the right key.

Two more constraints on the string itself: it must be 1–255 printable ASCII characters — the SDK throws JSSDK_HTTP_INVALID_IDEMPOTENCY_KEY before the request goes out if it is not — and it must be unique per operation, since a key reused for a different write is rejected rather than deduplicated. Prefix keys with the operation so deal-42-create and deal-42-close cannot collide.

Using it

// const $b24 = ...
declare const orderId: number
const idempotencyKey = `deal-${orderId}-create`

const response = await $b24.actions.v3.call.make<{ item: { id: number } }>({
  method: 'tasks.task.add',
  params: { fields: { title: 'Ship it', creatorId: 1, responsibleId: 1 } },
  idempotencyKey
})

if (response.isIdempotentReplay()) {
  // The portal had already executed this operation: nothing new was written,
  // and the body below is the stored response from the first time. Useful for
  // deciding whether to send the "task created" notification a second time.
}

// The key the portal echoed back — a confirmation for your log, not a
// discovery: you already know the key you sent.
console.log(response.getIdempotencyKey())

console.log(response.getData()?.result.item.id)

The portal stores the successful response against the key for 24 hours. A repeat with the same key and the same body returns that stored response instead of executing the method again, and marks it with Idempotent-Replayed: true — which is what isIdempotentReplay() reads. The same key sent with a different body is refused with HTTP 422 BITRIX_REST_V3_EXCEPTION_IDEMPOTENCYKEYREUSEDEXCEPTION.

The rules below come from the portal's reference, not from this SDK's test suite: what we exercise is that the header goes out and that a replay is read back. The 24-hour retention, the exact scope of a key and the behaviour on error are the portal's contract.

Three things worth knowing before you rely on it:

  • The SDK never generates a key. The portal scopes deduplication to webhook or application × user × method, so a key minted inside one process means nothing to another process retrying the same operation. Only your code knows which operation a call belongs to — so only your code can name it.
  • The response is stored only on success. After an error, a repeat with the same key executes the method again. The key is not a substitute for retryOnNetworkError: false on a long write; it is a second line of defence. Use both on a write you care about.
  • It expires after 24 hours. Deduplication protects a retry minutes or hours later, not a job replayed next week. For a longer window you still need a record on your side that the operation was done.
  • restApi:v2 ignores the header. The portal honours it only under /rest/api/, so the v2 transport drops the key and logs a warning rather than sending one you would believe protects you.

Single calls only: Batch takes no key. The mechanism names one operation, a batch is many, and whether a key on a v3 batch replays the whole batch has not been measured — so the SDK does not offer it rather than guess.

The mechanism is the portal's, not the SDK's: see Bitrix24 REST v3 → «Повторный вызов без дублей» for the reference description.

Which HTTP adapter runs

In a browser the SDK asks axios for the fetch adapter. Left to itself axios walks ['xhr', 'http', 'fetch'] and takes the first supported entry, so anywhere XMLHttpRequest exists — a window, a dedicated worker, a shared worker — it picks XHR and never reaches fetch. That is selection by list order rather than by merit.

Outside a browser nothing is asked for and axios decides: in Node there is no XMLHttpRequest, so http is already what gets picked, and it is the right one there.

Name one yourself at construction if you would rather:

import { B24Hook } from '@bitrix24/b24jssdk'

const b24 = B24Hook.fromWebhookUrl(
  'https://your-portal.bitrix24.com/rest/1/SECRET',
  { httpOptions: { adapter: 'xhr' } }
)

A frame app passes it to initializeB24Frame(), which forwards it unchanged — and a browser is where this matters most, since that is where the adapter changed.

httpOptions is merged over the SDK's own axios defaults, so any key you name wins — adapter is simply the one this exists for. It offers a narrow slice of AxiosRequestConfig (TypeHttpOptions), enforced at construction rather than only by the compiler — the keys the SDK needs to reach the portal — baseURL, transformRequest, paramsSerializer and validateStatus — are not among them, and a key outside the list is dropped at construction with its name logged. headers is not offered either — the type rejects it — though the transport still merges one that reaches it from untyped code, as it has since #144. They stay reachable through ajaxClient.defaults.

One behaviour moves with the adapter, on one narrow path.maxRedirects is read by fetch (as redirect: 'manual') and ignored outright by XHR. The SDK sets it where an OAuth access token rides in the query string, to stop that token following a redirect — which in a browser is only a restApi:v3 batch on a non-hook transport, not an ordinary call — unless you set maxRedirects: 0 yourself in httpOptions, which puts every status-0 answer in the same bucket. On fetch that guard bites where it used to be inert: a redirecting deployment gets an opaque response — status 0, empty body — instead of silently completing the hop with the credential attached. Axios resolves a response like that rather than rejecting it, so the SDK raises JSSDK_HTTP_REDIRECT_BLOCKED itself; without that the refused request would read as an empty success. That is the guard working, but it is a change of behaviour, not a no-op.

Alternatives and Recommendations

  • For working with lists: Instead of manually managing pagination use:
    • CallList — automatically retrieves all pages and returns a single result.
    • FetchList — returns an async generator for step-by-step processing of large lists.
  • For batch operations: Use Batch to execute up to 50 commands in a single request.
  • On the client-side (browser): Use the built-in B24Frame object.