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:
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 }, orundefinedwhen the request failed. Check.isSuccessfirst.timeis 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:
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.
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
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
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:
batchcarries 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:v3methods. 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: falseon 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:v2ignores 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.
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.