b24jssdk REST patterns (actions API)
Every example uses $b24 of type TypeB24, so the same code runs on B24Hook, B24Frame, and B24OAuth. The actions surface is published per API version under $b24.actions.v2.* and $b24.actions.v3.*.
The previous SDK surface —
callMethod,callBatch,callBatchByChunk,callListMethod,fetchListMethod— was removed in3.0.0and no longer exists (seepackages/jssdk/README-AI.md, "Removed in 3.0.0"). Code that calls it does not compile.The
AjaxResultpaging members —isMore(),hasMore(),getTotal(),getNext(),fetchNext()— are not in that set and are not deprecated. They arerestApi:v2-only; see "restApi:v2 paging members" below.
Pick the API version
The SDK exposes both v2 and v3 under $b24.actions. The SDK no longer keeps a hardcoded v3 method allowlist — the server is the source of truth for which methods exist on a portal (the authoritative list is the portal's own OpenAPI document, rest.documentation.openapi). So $b24.actions.v3.* will send any method to the v3 endpoint; if the method isn't a v3 method, the server returns a METHODNOTFOUNDEXCEPTION (a soft error on the AjaxResult, not an SDK throw).
Method families that are known to exist on v3 today (non-exhaustive): tasks.task.* (incl. list), mail.*, humanresources.*, timeman.record.* (read-only), main.eventlog.* (incl. native tail), note.*, rest.application.*, rest.incomingwebhook.*, plus infrastructure (batch, scopes, rest.scope.list, rest.documentation.openapi).
Rule of thumb:
- Default to
$b24.actions.v2.*— it works for every classic method. - Use
$b24.actions.v3.*when you specifically want the v3 representation of a method (camelCase fields, the unified{result}envelope, nativetail/cursor, dotted relation select). Confirm a method exists on this portal's v3 viarest.documentation.openapiif unsure. - There is no version auto-detection: the legacy
callMethod/callBatchshims that defaulted to v2 are gone, so the protocol is always whicheveractions.v2.*/actions.v3.*you name.
Decision tree
| Goal | Use |
|---|---|
| Single REST call | actions.v{2,3}.call.make |
| 2–50 related calls in one HTTP round-trip | actions.v{2,3}.batch.make |
| Many independent calls (>50) | actions.v{2,3}.batchByChunk.make |
| Read a list small enough to hold in memory and process it there | actions.v{2,3}.callList.make |
| Read a large list with low memory footprint | actions.v{2,3}.fetchList.make (async iterator) |
Read a v3 method that exposes a native tail (keyset) action — e.g. main.eventlog.tail |
actions.v3.callTail.make / actions.v3.fetchTail.make (v3 only) |
Aggregate (sum/avg/min/max/count/countDistinct) on a v3 method that exposes an *.aggregate action |
actions.v3.aggregate.make (v3 only, @experimental — the contract is measured, but no shipped module publishes *.aggregate on any portal yet measured, so fall back to callList + reduce when the endpoint isn't there. Values come back as strings, and null when the filter matched nothing) |
There is no manual-pagination path any more — the list helpers page for you. Two mechanisms exist, and they are not interchangeable:
callList/fetchListemulate a cursor on top of thelistaction by injecting a[idField, '>', n]condition intofilterand forcingorder. Works for any*.listmethod (v2 and v3).callTail/fetchTail(v3 only) drive the server's nativetailaction via itscursor: { field, value, order, limit }parameter. Use these when a method publishes a*.tailendpoint. The cursor field must not appear infilter(the server rejects it), is auto-added toselect, andorder: 'DESC'requires an explicitinitialValue.
Every example uses $b24 of type TypeB24, but actions.v3.batch is the one place the three entry points are not interchangeable — it does not work under B24Frame. See the anti-patterns at the end.
filter on v3 is the array form or a FilterV3 logic group — never the v2 object dialect ({ '>id': 100 }), which the portal rejects on every v3 method. All four walkers refuse it client-side. callList / fetchList additionally require an array, because they extend it with the page condition, and throw JSSDK_ACTION_V3_LIST_FILTER_NOT_ARRAY; callTail / fetchTail forward filter untouched, so a bare group is fine there and only the v2 dialect throws — JSSDK_ACTION_V3_TAIL_FILTER_INVALID.
All of them — and the two restApi:v2 list walkers — also throw JSSDK_ACTION_CURSOR_STALLED when a full page comes back and the cursor read from it equals the one just sent. That means the page condition was dropped, so the walk can never end; it rejects rather than collecting the same page for ever.
import { B24Hook, FilterV3 } from '@bitrix24/b24jssdk'
const b24 = B24Hook.fromWebhookUrl('https://your-portal.bitrix24.ru/rest/1/SECRET')
// list: the page condition is appended to `filter`, so wrap the group
await b24.actions.v3.callList.make({
method: 'main.eventlog.list',
params: { select: ['id'], filter: [FilterV3.or(['severity', '=', 'ERROR'])] },
idKey: 'id',
customKeyForResult: 'items'
})
// tail: `filter` is forwarded verbatim, so a bare group is accepted as-is
await b24.actions.v3.callTail.make({
method: 'main.eventlog.tail',
cursorField: 'id', // must NOT appear in `filter` — the SDK warns if it does
params: { select: ['id'], filter: FilterV3.or(['severity', '=', 'ERROR']) },
customKeyForResult: 'items'
})
call.make — single call
import { EnumCrmEntityTypeId } from '@bitrix24/b24jssdk'
interface CrmItem { id: number; title: string; stageId: string }
const response = await $b24.actions.v2.call.make<{ item: CrmItem }>({
method: 'crm.item.get',
params: {
entityTypeId: EnumCrmEntityTypeId.deal,
id: 42
},
requestId: 'deal-42'
})
if (!response.isSuccess) {
throw new Error(response.getErrorMessages().join('; '))
}
const deal = response.getData()!.result.item
For a v3 method:
interface TaskItem { id: number; title: string }
const response = await $b24.actions.v3.call.make<{ item: TaskItem }>({
method: 'tasks.task.get',
params: { id: 1, select: ['id', 'title'] },
requestId: 'task-1'
})
// v3 wraps a single entity as `result.item` — including tasks, where the v2
// method answered `result.task`.
const task = response.getData()!.result.item
Writes that must not double up (restApi:v3)
For a v3 method that creates, changes or deletes, pass idempotencyKey.
The portal stores the successful response against the key for 24 hours and
replays it instead of writing twice — which covers the duplicate a retry
policy cannot: a crashed worker rerunning its job, or an outbound event
delivered twice.
// The key names the OPERATION, not the attempt: two attempts at the same
// business operation must produce the same string, in any process, after any
// restart. Derive it from your own identifiers rather than minting a UUID at
// the call site — a restarted worker would mint a different one and write a
// second record.
const orderId = 1042
const responsibleId = 1
const response = await $b24.actions.v3.call.make<{ item: { id: number } }>({
method: 'tasks.task.add',
// creatorId and responsibleId are required by tasks.task.add
params: { fields: { title: 'Ship it', creatorId: responsibleId, responsibleId } },
idempotencyKey: `task-from-order-${orderId}`
})
if (response.isIdempotentReplay()) {
// Already done earlier — nothing new was written; skip the side effects
// (the notification, the counter, the outbound webhook) you would fire on a
// real create.
}
The integration case — a re-run of an import. An import already carries the key it needs: the source system's own identifier. Use it, and re-running the import stops creating duplicates without a read-before-write per row.
declare const rows: Array<{ externalId: string, title: string }>
declare const ownerId: number
for (const row of rows) {
await $b24.actions.v3.call.make({
method: 'tasks.task.add',
params: { fields: { title: row.title, creatorId: ownerId, responsibleId: ownerId } },
idempotencyKey: `import-task-${row.externalId}`
})
}
But check the method exists on v3 first. There is no crm.* create on
v3 — no lead, deal, contact or company (245 methods / 14 modules measured
2026-09-05; the creating ones are tasks.task.add, tasks.task.result.add,
note.{collection,document,file}.add, humanresources.node.add and a few
under rest.* / main.*). So the usual integration write goes through crm.*
on restApi:v2, where the header is ignored — for those, keep the source id in
a field and check it before inserting. Ask the portal with
rest.documentation.openapi rather than assuming.
Constraints: 1–255 printable ASCII (otherwise the SDK throws
JSSDK_HTTP_INVALID_IDEMPOTENCY_KEY before sending); the same key with a
different body is refused, not deduplicated; the response is stored only on
success, so keep retryOnNetworkError: false on long writes as well; batch
takes no key; and restApi:v2 ignores the header — the v2 transport drops it
and warns. Full guide:
Idempotency-Key.
batch.make — array form
import { EnumCrmEntityTypeId } from '@bitrix24/b24jssdk'
import type { AjaxResult } from '@bitrix24/b24jssdk'
interface Contact { id: number; name: string }
const response = await $b24.actions.v2.batch.make<{ item: Contact }>({
calls: [
['crm.item.get', { entityTypeId: EnumCrmEntityTypeId.contact, id: 1 }],
['crm.item.get', { entityTypeId: EnumCrmEntityTypeId.contact, id: 2 }]
],
options: {
isHaltOnError: true,
returnAjaxResult: true,
requestId: 'batch-1'
}
})
if (!response.isSuccess) throw new Error(response.getErrorMessages().join('; '))
// When returnAjaxResult: true, results are an array of AjaxResult<T>
const results = response.getData()! as AjaxResult<{ item: Contact }>[]
for (const r of results) {
if (r.isSuccess) console.log(r.getData()!.result.item)
}
batch.make — named object form
import type { AjaxResult } from '@bitrix24/b24jssdk'
interface Contact { id: number; name: string }
interface Deal { id: number; title: string }
const response = await $b24.actions.v2.batch.make<{ item: Contact } | { item: Deal }>({
calls: {
Contact: { method: 'crm.item.get', params: { entityTypeId: 3, id: 1 } },
Deal: ['crm.item.get', { entityTypeId: 2, id: 2 }]
},
options: { isHaltOnError: true, returnAjaxResult: true, requestId: 'batch-named' }
})
const data = response.getData()! as Record<string, AjaxResult<{ item: Contact } | { item: Deal }>>
console.log(data.Contact.getData()!.result.item)
console.log(data.Deal.getData()!.result.item)
batch.make — partial errors (v2 only)
Set isHaltOnError: false to collect per-command failures. v3 batch is all-or-nothing — partial errors are not surfaced. If any command in a v3 batch fails, the whole batch fails (see README-AI.md "Limitations").
To feed one v3 command's output into a later one, give it an as alias and reference it with the BatchRefV3 markers (import { BatchRefV3 } from '@bitrix24/b24jssdk'): BatchRefV3.ref('alias.item.id') (single value) or BatchRefV3.refArray('alias.id') (a field collected across the alias's items[]). The server does the substitution.
import type { AjaxResult } from '@bitrix24/b24jssdk'
const response = await $b24.actions.v2.batch.make<{ item: Contact }>({
calls: arrayOfCalls,
options: { isHaltOnError: false, returnAjaxResult: true }
})
const items = response.getData()! as AjaxResult<{ item: Contact }>[]
const successes = items.filter((r) => r.isSuccess)
const failures = items.filter((r) => !r.isSuccess).map((r) => r.getErrorMessages().join('; '))
For object / named-command calls (calls: { name: { method, params } }), the outer Result keys each failure by the command name — use response.getErrorsByKey() / getErrorMessagesByKey() to get a { name: error } map instead of iterating per-item results.
batchByChunk.make — large batches
Chunk size is 50 per Bitrix24 batch limit. The action splits and re-aggregates:
import { EnumCrmEntityTypeId } from '@bitrix24/b24jssdk'
import type { BatchCommandsArrayUniversal } from '@bitrix24/b24jssdk'
const calls: BatchCommandsArrayUniversal = ids.map((id) =>
['crm.item.get', { entityTypeId: EnumCrmEntityTypeId.deal, id }] as const
)
const response = await $b24.actions.v2.batchByChunk.make<{ item: CrmItem }>({
calls,
options: { isHaltOnError: false, requestId: 'bulk-1' }
})
if (!response.isSuccess) throw new Error(response.getErrorMessages().join('; '))
const data = response.getData()! // Flat array of { item: CrmItem }
const items = data.map((row) => row.item)
callList.make — small lists in memory
Pages through the whole result set and returns it as one array — there is no item ceiling; limit sizes a page, not the total. Internally pages with a keyset cursor on cursorIdKey (which defaults to idKey).
On v3, limit is a request: each method applies its own maximum and a short page is not the end of the data. Measured — tasks.task.list answers 50 for limit 51, 100 and 1000 alike, with 60 rows available, and nothing in the response says it was capped. The walkers are cap-tolerant (they stop on the largest page the server returned); hand-rolled paging on call.make is not.
import { EnumCrmEntityTypeId } from '@bitrix24/b24jssdk'
import { Text } from '@bitrix24/b24jssdk'
interface CrmItem { id: number; title: string }
const sixMonthsAgo = new Date()
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)
const response = await $b24.actions.v2.callList.make<CrmItem>({
method: 'crm.item.list',
params: {
entityTypeId: EnumCrmEntityTypeId.company,
filter: {
'=%title': 'A%',
'>=createdTime': Text.toB24Format(sixMonthsAgo)
},
select: ['id', 'title']
},
idKey: 'id', // 'id' for crm.item.*; default is 'ID' for classic methods
customKeyForResult: 'items', // 'items' for crm.item.*; omit for classic methods
requestId: 'companies-1'
})
if (!response.isSuccess) throw new Error(response.getErrorMessages().join('; '))
const items = response.getData()! // CrmItem[]
orderis ignored bycallList.make. The action forcesorder: { [cursorIdKey]: 'ASC' }for cursor stability and logs a warning when you pass anorder(see theorderwarning inactions/v2/call-list.ts). Usefilterto narrow results.
Bounding a walk — maxPages, signal, progress
All six walkers (callList / fetchList on both versions, callTail / fetchTail on v3) accept:
| option | what it does |
|---|---|
maxPages?: number |
Ceiling. Raises JSSDK_ACTION_MAX_PAGES_EXCEEDED naming the method. Default 10 000 — a backstop, not a policy (500 000 rows at the default page size of 50; ~83 min at the default drain rate). Must be a positive integer, or JSSDK_ACTION_INVALID_MAX_PAGES is raised at call time. |
signal?: AbortSignal |
Throws JSSDK_ACTION_ABORTED. Checked at the top of each iteration, so an already-aborted signal costs no request. |
progress? |
({ pages, rows }) => void, eager walkers only (callList / callTail). Counts, not a percentage — cursor paging reads no total. The streaming walkers hand you each page instead. |
Neither error discards data. The eager walkers (callList / callTail) resolve with the rows they read and the error attached, so check isSuccess rather than assuming a returned list is whole — those rows are correct, merely incomplete. fetchList / fetchTail throw, having already yielded every page they read.
A stalled cursor is different and still throws everywhere: there the extra rows are duplicates of ones already held, so there is nothing worth handing back.
const controller = new AbortController()
const response = await $b24.actions.v3.callList.make({
method: 'main.eventlog.list',
customKeyForResult: 'items',
idKey: 'id',
maxPages: 200,
signal: controller.signal,
progress: ({ pages, rows }) => console.log(`${pages} pages, ${rows} rows`)
})
The default ceiling does not replace JSSDK_ACTION_CURSOR_STALLED, which fires far earlier and says something more specific: the cursor came back equal to the one just sent. The ceiling catches what that guard cannot — a cursor that moves but never ends, including one cycling between values (#495).
On restApi:v2, conditions go in lowercase filter — never FILTER
Applies to both callList.make and fetchList.make.
They page by writing their own lowercase filter, order and start, and the portal keeps only the later of two top-level keys that differ by case. Older list methods (user.get, task.item.list, …) are documented with uppercase FILTER / SORT / ORDER, so following that documentation here is the mistake.
// ❌ conditions silently dropped — this returns every user, not employees.
// Note there is no cast here: `TypeCallParams` carries an index signature, so
// this type-checks. Nothing but the runtime warning catches it.
await $b24.actions.v2.callList.make({ method: 'user.get', params: { FILTER: { USER_TYPE: 'employee' } }, idKey: 'ID' })
// ❌ worse — half-migrated. `FILTER` is now the later key, so it overwrites the
// walker's own `>ID` cursor: the same page arrives for ever and the walk throws
// JSSDK_ACTION_CURSOR_STALLED.
await $b24.actions.v2.callList.make({ method: 'user.get', params: { filter: { USER_TYPE: 'employee' }, FILTER: { ACTIVE: 'Y' } }, idKey: 'ID' })
// ✅ moved across, uppercase key removed
await $b24.actions.v2.callList.make({ method: 'user.get', params: { filter: { USER_TYPE: 'employee', ACTIVE: 'Y' } }, idKey: 'ID' })
Measured on user.get with four users: FILTER: { ID: 4 } returns all four, filter: { ID: 4 } returns one. SORT is worse and louder — on user.get it makes the walker's own injected order fail the method's validation, so the request throws ERROR_ARGUMENT / "Order must be a string". All of these are reported with a warning (#483), emitted through LoggerFactory.forcedLog, so it reaches console.warn even with the default logger. This is restApi:v2 only; v3 has no uppercase contract — its parameters are camelCase and its filter is the array form.
fetchList.make — large lists, streaming
Async iterator that yields chunks. Same shape as callList.make plus an optional limit for v3.
import { EnumCrmEntityTypeId } from '@bitrix24/b24jssdk'
const generator = $b24.actions.v2.fetchList.make<CrmItem>({
method: 'crm.item.list',
params: {
entityTypeId: EnumCrmEntityTypeId.deal,
filter: { '!stageId': ['WON', 'LOSE'] },
select: ['id', 'title', 'stageId']
},
idKey: 'id',
customKeyForResult: 'items'
})
for await (const chunk of generator) {
for (const deal of chunk) await processDeal(deal)
}
For v3:
import { Text } from '@bitrix24/b24jssdk'
const generator = $b24.actions.v3.fetchList.make<EventLogItem>({
method: 'main.eventlog.list',
params: {
filter: [['timestampX', '>=', Text.toB24Format(sixMonthsAgo)]],
select: ['id', 'userId']
},
idKey: 'id',
customKeyForResult: 'items',
limit: 100
})
Note the v2 vs v3 filter difference. v2 uses prefix-keyed objects; v3 uses arrays of
[field, op, value]triples — for nested groups build them with the typedFilterV3helper (import { FilterV3 } from '@bitrix24/b24jssdk'). See theb24jssdk-filteringskill.
idKey, cursorIdKey and customKeyForResult cheat sheet
The rule the defaults come from: restApi:v3 field names are camelCase, restApi:v2 names are UPPER_SNAKE. Measured across 108 descriptors from two entities (tasks.task 95, main.eventlog 13) — not one v3 name starts with an uppercase letter. That is why idKey defaults to 'id' on v3 and 'ID' on v2, and it is a rule rather than a per-method quirk: expect it to hold for the next method too.
idKey is the id field in the response (the cursor reads its value); cursorIdKey is the field in the request used for order and the > page filter, and it defaults to idKey. They differ only when a method spells the id one way in the request and another in the response — tasks.task.list on v2 is the known case (request ID, response id), and it breaks in two different ways depending on which half you get wrong. Leave idKey at its default 'ID' and the cursor reads a field the response does not carry: the walk warns and stops after the first 50 rows. Set idKey: 'id' but leave cursorIdKey defaulting to it and the opposite happens — the read works, but the request filters on >id, which the method ignores, so the same page returns for ever; that one now throws JSSDK_ACTION_CURSOR_STALLED instead of hanging. Set both. On the v3 endpoint the same method is all-lowercase (id both ways, rows under result.items), so no override is needed.
Ask the portal rather than guess: <entity>.field.list returns every field with type, filterable, sortable, editable and requiredGroups, under result.items; <entity>.field.get returns one, under result.item. Both take an optional select that narrows the descriptor keys. A v3 field without Filterable / Sortable is refused, not ignored — BITRIX_REST_V3_EXCEPTION_VALIDATION_REQUESTVALIDATIONEXCEPTION with the offending name in validation[].field. It arrives soft (isSuccess === false), not thrown: a 4xx in the v3 envelope is not an exception. Filterable also gates an aggregate select, not just filter. On the portal measured, tasks.task exposes exactly one filterable field: id.
| Method | idKey (response) |
cursorIdKey (request) |
customKeyForResult |
|---|---|---|---|
crm.item.list (v2) |
'id' |
— (= idKey) |
'items' |
crm.deal.list, crm.contact.list, … (classic v2) |
'ID' (default) |
— (= idKey) |
omit (default result) |
tasks.task.list (v2) |
'id' |
'ID' |
'tasks' |
tasks.task.list (v3) |
'id' |
— (= idKey) |
'items' |
disk.folder.getchildren |
'ID' (default) |
— (= idKey) |
omit |
main.eventlog.list (v3) |
'id' |
— (= idKey) |
'items' |
Wrong customKeyForResult makes getData() return an empty array — there is no error. If you're getting [] and expect data, this is the first thing to check.
AjaxResult — new shape
const res = await $b24.actions.v2.call.make<{ item: CrmItem }>({
method: 'crm.item.get',
params: { entityTypeId: 2, id: 10 }
})
res.isSuccess // boolean
res.getData() // SuccessPayload<T> | undefined → { result, time } | undefined
res.getErrorMessages() // string[] (preferred)
res.getErrors() // IterableIterator<Error> (values only, no keys)
res.getErrorsByKey() // Record<string, Error> (batch: keyed by command label, or numeric position for array-mode)
res.getStatus() // HTTP status
res.getQuery() // { method, params, requestId }
getData()returnsundefinedwhen the call did not succeed — the new typing forces you to either checkisSuccessfirst, or assert with!. Both patterns appear in the canonical SDK tests (test/integration/js-docs/actions-v{2,3}.spec.ts).
What getData() returns, per action
Only call.make has a result property. Reading .result off the others gives undefined — no error, no warning, just a value that reads as an empty response (#425).
| Action | getData() |
Reach a row with |
|---|---|---|
call.make |
{ result, time } |
getData()!.result |
batch.make |
the keyed map, or an array for the array form | getData()!.myKey |
callList.make |
a flat array | getData()![0] |
batchByChunk.make |
a flat array | getData()![0] |
fetchList.make |
(async generator) — yields arrays | for await (const chunk of …) |
With options.returnAjaxResult: true, each entry of a batch result is an AjaxResult rather than the raw payload, so you reach a row with getData()!.myKey.getData()!.result.
restApi:v2 paging members — kept, but v2-only
isMore() / hasMore() / getTotal() / getNext() / fetchNext() are not deprecated and are not going away in 3.0.0. They work with the v2 envelope fields next / total directly.
import type { AjaxResult } from '@bitrix24/b24jssdk'
declare const response: AjaxResult<{ ID: string }[]>
const total: number = response.getTotal() // restApi:v2 only
const more: boolean = response.isMore() // restApi:v2 only
On a restApi:v3 response both return their empty value — 0 and false — because v3 sends no such field. That is not "no rows matched" or "no more pages"; there is simply nothing to read. Never branch on them under v3.
getNext(http) / fetchNext(http) re-run the query at the reported next offset. Under restApi:v3 they throw JSSDK_CORE_METHOD_NOT_SUPPORT_IN_API_V3 rather than returning empty, because a silent false would be indistinguishable from "last page". For new paging code still prefer callList.make / fetchList.make — they hide the offset bookkeeping and work under both versions.
For a v3 count use actions.v3.aggregate.make with select: { count: ['id'] } on a method that exposes an *.aggregate action. The count arrives as a string ('18'), keyed by function then field — getData()?.count?.id — so convert it with Text.toNumber(). The action stays @experimental because no shipped module publishes *.aggregate on any of the four portals checked, not because the shape is unknown; if the endpoint isn't there, reduce a callList client-side.
Every aggregated field must be filterable, which is not the same as selectable. <entity>.field.list reports a filterable flag per field; only fields where it is true may be aggregated — on tasks.task that is one field of ninety-five, so assume nothing and ask. A selectable-but-not-filterable field answers a soft BITRIX_REST_V3_EXCEPTION_VALIDATION_REQUESTVALIDATIONEXCEPTION naming it in validation[].field — measured. Match on the code and the field, never on the message: it is localised.
Null result is passthrough
A per-command result inside a batch can legitimately be null (e.g. im.chat.get with non-matching params — see issue #23). Type the generic as T | null and handle the null branch — the SDK no longer coerces to {}.
import type { AjaxResult } from '@bitrix24/b24jssdk'
const response = await $b24.actions.v2.batch.make<{ result: ChatInfo | null }>({
calls: { Chat: ['im.chat.get', { chat_id: 999999 }] },
options: { returnAjaxResult: true }
})
const chat = (response.getData() as Record<string, AjaxResult<{ result: ChatInfo | null }>>)
.Chat.getData()!.result.result
if (chat === null) {
// chat not found — expected branch
}
Error handling — quick template
import { AjaxError, SdkError } from '@bitrix24/b24jssdk'
async function loadDeal() {
try {
const res = await $b24.actions.v2.call.make<{ item: Deal }>({
method: 'crm.item.get',
params: { entityTypeId: 2, id: 999_999 }
})
if (!res.isSuccess) {
// Soft errors (rare; usually you'll see throws)
logger.warning('non-success', { errors: res.getErrorMessages() })
return
}
return res.getData()!.result.item
} catch (e) {
if (e instanceof AjaxError) {
// Bitrix24 REST error
logger.error('REST error', { code: e.code, status: e.status, message: e.message, requestInfo: e.requestInfo })
// restApi:v3 only: `e.validation` names the field that failed, which
// `message` does not. Both `field` and `message` are optional.
for (const detail of e.validation ?? []) {
logger.error('invalid field', { field: detail.field, message: detail.message })
}
} else if (e instanceof SdkError) {
// SDK-level error (wrong API version, etc.)
logger.error('SDK error', { code: e.code, message: e.message })
} else {
throw e
}
}
}
requestInfois safe to log becauseAjaxErrorredacts it, not because the call site is careful. Its constructor runs the request params throughredactSensitiveParams, replacingauth,token,secret,access_token,refresh_token,client_secret,application_token,password,sessid,keyandsignaturewith***REDACTED***. So do not rebuild that context by hand from the original params — a hand-assembled{ method, params }inherits none of that and will put a live credential into the log.
For tuning retry/throw behaviour per error code see the hardErrorCodes / softErrorCodes / retryOnNetworkError section in the b24jssdk-core skill.
Discover available v3 methods (OpenAPI)
The portal is the source of truth for which v3 methods exist — the SDK keeps no allowlist. Ask the portal with rest.documentation.openapi, an ordinary v3 call that returns the portal's own OpenAPI 3.0.0 document (every v3 method available on that portal, with each method's request fields and response shape). Useful for an agent before generating a call: enumerate real method names and field names instead of guessing.
type OpenApiDoc = {
openapi: string
tags: Array<{ name: string, description: string }> // one per module
paths: Record<string, { post?: { tags?: string[], requestBody?: unknown } }> // keyed by "/method.name"
}
const res = await $b24.actions.v3.call.make<OpenApiDoc>({ method: 'rest.documentation.openapi' })
if (!res.isSuccess) {
throw new Error(res.getErrorMessages().join('; '))
}
const doc = res.getData()?.result
// every available method name
const methods = Object.keys(doc?.paths ?? {}).map(p => p.replace(/^\//, ''))
// does a method exist on this portal's v3?
const hasNotes = Boolean(doc?.paths?.['/note.collection.list'])
- The document is portal-specific (reflects installed modules + token scopes) and large (100 KB+). Fetch it once and cache it — it's response-only and stable within a session; don't re-request per interaction.
- Each
paths['/method.name'].post.requestBodyschema lists the accepted parameters (often with anexampleof real field names) — read those instead of inventingselectfields. - Full guide: Discovering v3 methods.
- For one entity's fields and their flags, the cheaper half of the same question: Discovering entity fields.
Anti-patterns
- ❌
$b24.callMethod(...),$b24.callBatch(...), etc. — removed in 3.0.0, these are compile errors now. Use the actions API. - ❌
res.getNext()/res.fetchNext()against a v3 client — they throwJSSDK_CORE_METHOD_NOT_SUPPORT_IN_API_V3. Under v2 they work and are supported; for new code prefercallList/fetchList, which work under both versions. - ❌ Reading
res.getTotal()orres.isMore()on a v3 response — not an error, but they always answer0/falsethere because v3 sends nototal/next. Under v2 they are correct and supported. For a v3 count useactions.v3.aggregate.make(count/countDistinct) on a method that exposes an*.aggregateaction — remembering the count is a string, not a number.
- ❌ Calling
$b24.actions.v3.call.make({ method: 'crm.item.get', ... })—crm.*is v2-only, so the v3 server returns aMETHODNOTFOUNDEXCEPTIONsoft error (response.isSuccess === false); useactions.v2.*for CRM. (The SDK no longer pre-flight-throws here.) - ❌ Passing
ordertocallList.make— silently ignored with a warning. Narrow withfilterinstead. - ❌
customKeyForResult: 'result'forcrm.item.list— wrong, use'items'. Otherwise you'll get an empty list silently. - ❌
idKey: 'ID'forcrm.item.list— wrong, use'id'. The classiccrm.deal.listis the opposite. - ❌
idKey: 'ID'alone fortasks.task.list— the response id is lowercaseid, so the cursor can't read it and paging warns and stops after 50. UseidKey: 'id', cursorIdKey: 'ID'. - ❌
idKey: 'id'alone for the same method — the read works but the request filters on>id, which the method ignores, so the same page repeats; throwsJSSDK_ACTION_CURSOR_STALLED. Same fix: set both. - ❌
Promise.allovercallList.makefor parallel paging — internal cursor pagination is sequential by design; you'll get duplicates or skipped rows. - ❌ Hand-paging a v3 list method by the
nextCursorit returns (e.g.note.*) —callList/fetchListpage via their ownidKeycursor and walk every page;nextCursoris informational and the SDK ignores it. Just use the list helpers withidKey+customKeyForResult. - ❌
batch.make({ calls, isHaltOnError: false })— batch flags at the top level are not applied. They belong underoptions: { isHaltOnError, returnAjaxResult, requestId }. TypeScript now rejects the literal and the SDK logs a warning for callers it cannot see, but nothing recovers the intent:returnAjaxResultdropped this way makesentry.isSuccessundefinedon a plain object, so a batch where everything succeeded reads as a batch where everything failed (#426). - ❌ Reading
getData()!.resultoffbatch.make/callList.make/batchByChunk.make— onlycall.makereturns that envelope. See the table above. - ❌ Expecting
idempotencyKeyto protect a CRM import —crm.*has no v3 create, so the write is v2 and the key is dropped with a warning. Checkrest.documentation.openapibefore designing around it. - ❌ Reaching for
batch.makeon a bulk load that must not duplicate — a batch carries no key. Loop single calls and accept the throughput cost, or accept the duplicates. - ❌
idempotencyKey: crypto.randomUUID()written at the call site for a job that can be retried by a different process — the restart mints a new key and writes a duplicate anyway. Derive the key from the operation (deal-${orderId}-create), or persist a minted one with the job before calling. - ❌ Reusing one
idempotencyKeyfor two different writes — the portal answers HTTP 422…IDEMPOTENCYKEYREUSEDEXCEPTIONrather than deduplicating. Prefix by operation:deal-42-createvsdeal-42-close. - ⚠️
actions.v3.batch.make/batchByChunk.makefrom a browser (B24Frame, orB24OAuthclient-side) works, but the credential goes in the query string — on v3 the commands are the request body, leaving nowhere inside it for a token, and the portal's CORS preflight allows onlyorigin, content-type, accept, so anAuthorizationheader cannot be sent. The token then reaches the portal's access log, which a request body would not. It is not newly exposed to the user (it is in the page's JavaScript already), but if that server-side record matters, run the batch on a backend or useactions.v2.batch.makewhere the methods exist on v2.B24Hookappends nothing — its secret is in the URL path already. - ❌
B24Hookin a browser bundle — leaks the webhook secret. UseB24Framethere.
Cross-reference
For v3 filter dialect / ordering / NULL handling, use the b24jssdk-filtering skill.