Overview
Bitrix24 has two filter dialects, and they are not interchangeable — each API version accepts only its own.
- v2 (
$b24.actions.v2.{call,callList,fetchList}.make) uses a prefix-keyed object: the operator is a prefix on the field name. - v3 (
$b24.actions.v3.{call,callList,fetchList}.make) uses an array of triples:[field, operator, value].
Passing a v3-style filter to a v2 action silently misparses; passing a v2-style
filter to a v3 action returns UnknownFilterOperatorException.
v2 — prefix operators
The operator is a prefix on the field name. Multiple keys are combined with
AND. Two operators on the same field need two separate keys (e.g. a range).
filter: {
'>=opportunity': 50000,
'<=opportunity': 200000,
'!stageId': 'LOST',
'=%title': 'A%'
}
A plain array value means IN; combine with ! for "not in":
filter: {
stageId: ['NEW', 'PREPARATION', 'EXECUTING'], // IN
'!categoryId': [4, 7] // NOT IN
}
v3 — array of triples
The filter is a JSON array; each element is a [field, operator, value]
condition. The top-level array is implicitly AND.
filter: [
['stageId', '=', 'NEW'],
['createdTime', '>=', '2026-01-01T00:00:00+03:00'],
['responsibleId', 'in', [1, 2, 3]]
]
v3 operators — the only 8
= != > >= < <= in between
like / % / substring operator at the v3 protocol level. Substring
search is currently a v2-only feature — use v2 with % or =%. A v3
['title', 'like', 'A%'] fails with UnknownFilterOperatorException.betweenvalue must be a 2-element array:[min, max].invalue must be an array.
Two-arg forms are sugar:
['id', 42] // same as ['id', '=', 42]
['stageId', ['A', 'B']] // same as ['stageId', 'in', ['A', 'B']]
For anything beyond a flat list, prefer the typed FilterV3 builder — it
validates operators and in / between shapes client-side. See the
b24jssdk-filtering skill.
A boolean condition needs a JSON body
A boolean in a filter survives only if the request body is JSON. Sent as
application/x-www-form-urlencoded it arrives as a string — "false" rather
than false — and the condition is dropped: the call answers
with rows it should have excluded, and nothing reports an error.
Measured on one field of one method (user.get, ACTIVE), one portal, same
minute — so read it as what to expect rather than as a contract:
Only the false column carries the finding. Form-encoded
true is equally the string "true", so its 1 row is exactly
what an ignored condition would also produce — it is there as the control, not as
evidence. Whether the same happens to numbers or null was not
measured.
The SDK sends JSON and now says so, on every request, rather than relying on
axios to pick application/json for a plain-object body. That
matters because the axios instance is public: the SDK sets the content type per
request, so raising ajaxClient.defaults.timeout — which
the v3 call page suggests
for long writes — cannot change the body encoding by accident. One channel still
reaches it: a request interceptor on that instance runs after the SDK's
config is assembled, so an interceptor that sets Content-Type changes the
encoding of SDK traffic too, and reopens exactly this.
curl, a webhook tester, your own HTTP
client — send it as JSON. A form-encoded body is where this bites, and it bites
silently: the rows look plausible, there is no error, and the filter that was
ignored is the one you were relying on.Dates
Use the SDK helper Text.toB24Format(date) for both dialects — it produces
the Bitrix24 format yyyy-MM-dd'T'HH:mm:ssZZ and accepts Date, DateTime, or
string input.
import { Text } from '@bitrix24/b24jssdk'
const sixMonthsAgo = new Date()
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)
// v2
const filterV2 = { '>=createdTime': Text.toB24Format(sixMonthsAgo) }
// v3
const filterV3 = [['createdTime', '>=', Text.toB24Format(sixMonthsAgo)]]
Text.toB24Format() emits the offset for you.The order parameter and list actions
callList.make and fetchList.make (both v2 and v3) strip any user-supplied
order and force sorting by the cursor id ({ [cursorIdKey]: 'ASC' }),
because they page the dataset with a keyset cursor. If you pass an order, the
SDK discards it and logs a warning — the value is silently ignored for the
result set.
If you need a specific sort order, drop down to call.make and page manually —
but you almost always want to filter more narrowly instead. See the
CallList Limitations
for details.
Which shape of filter each list walker accepts
All four v3 walkers refuse the restApi:v2 object dialect ({ '>id': 100 })
client-side — the portal parses a v3 filter positionally, so a map keyed by an
operator prefix matches no shape it knows. Beyond that the two families differ,
because they paginate differently.
callList / fetchList emulate a cursor: on every page they append
[cursorIdKey, '>', cursor] to filter. Only an array can be extended, so a
logic group — a perfectly valid v3 filter on its own — has to be wrapped:
import { B24Hook, FilterV3, SdkError } from '@bitrix24/b24jssdk'
const b24 = B24Hook.fromWebhookUrl('https://your-portal.bitrix24.ru/rest/1/SECRET')
// ✅ wrapped in an array, so the page condition can be appended to it
await b24.actions.v3.callList.make({
method: 'main.eventlog.list',
params: {
select: ['id', 'severity'],
filter: [FilterV3.or(['severity', '=', 'ERROR'], ['severity', '=', 'WARNING'])]
},
idKey: 'id',
customKeyForResult: 'items'
})
// ❌ a bare group here throws JSSDK_ACTION_V3_LIST_FILTER_NOT_ARRAY
callTail / fetchTail drive the server's native tail action through
cursor: { field, value, order, limit } and forward filter untouched. The
portal accepts a bare logic group as the whole filter, so both shapes work:
import { B24Hook, FilterV3 } from '@bitrix24/b24jssdk'
const b24 = B24Hook.fromWebhookUrl('https://your-portal.bitrix24.ru/rest/1/SECRET')
// ✅ a bare group — forwarded verbatim as
// { "logic": "or", "conditions": [["severity", "=", "ERROR"], ...] }
await b24.actions.v3.callTail.make({
method: 'main.eventlog.tail',
cursorField: 'id',
params: {
select: ['id', 'severity'],
filter: FilterV3.or(['severity', '=', 'ERROR'], ['severity', '=', 'WARNING'])
},
customKeyForResult: 'items'
})
The v2 dialect is refused here rather than one round trip later, where the portal reports it in wording that never names the dialect:
import { B24Hook, SdkError } from '@bitrix24/b24jssdk'
const b24 = B24Hook.fromWebhookUrl('https://your-portal.bitrix24.ru/rest/1/SECRET')
try {
await b24.actions.v3.callTail.make({
method: 'main.eventlog.tail',
params: { select: ['id'], filter: { '>id': 100 } as never },
customKeyForResult: 'items'
})
} catch (error) {
// 'JSSDK_ACTION_V3_TAIL_FILTER_INVALID' — thrown before any request is sent
console.error((error as SdkError).code)
}
The as never above is only there to get past the compiler: filter on the
tail actions is typed TypeFilterV3 | FilterV3Group, so the v2 dialect does not
type-check in the first place.
One more rule specific to tail: the cursor field must not appear in
filter. The server orders and pages by it and rejects a filter on the same
field with INVALIDFILTEREXCEPTION, so the SDK logs a warning when it spots
one — including inside a nested group:
import { B24Hook, FilterV3 } from '@bitrix24/b24jssdk'
const b24 = B24Hook.fromWebhookUrl('https://your-portal.bitrix24.ru/rest/1/SECRET')
// ⚠️ warns: the cursor field "id" must not appear in `filter`
await b24.actions.v3.callTail.make({
method: 'main.eventlog.tail',
cursorField: 'id',
params: { select: ['id'], filter: [FilterV3.and(['id', '>', 5])] },
customKeyForResult: 'items'
})
Wrapping in an array works on all four walkers, so wrap if you would rather keep one habit than remember the split.
One failure the filter shape cannot cause but is easy to confuse with it: if the
cursor read from a page equals the one just sent, the page condition was dropped
and the walk can never end. The page does not have to be full — a row at or
before the cursor cannot be in an answer that honoured a strictly-greater
condition, however few rows came back — so a short page is checked before it is
taken for the end of the data. All six walkers — these
four plus the two restApi:v2 list ones — throw SdkError with
code JSSDK_ACTION_CURSOR_STALLED — or JSSDK_ACTION_CURSOR_WENT_BACKWARDS, if the server cycles between pages rather than repeating one — rather than walking for ever. On
the tail walkers the usual cause is a cursorField whose values are not unique,
or an initialValue the server echoes straight back. See
Errors for the full remedy.
Side-by-side: the same filter in v2 and v3
The dialect is the point here, not the entity — and the two halves below cannot
use the same method, because crm.* is restApi:v2-only. The v3 endpoint
publishes no crm.item.* on any portal measured; what crm it does publish is
timeline email. So the v2 half asks for open deals, and the v3 half asks the
same shape of question of tasks: a NOT-IN group, a between, and a lower bound
on a date.
v2 — open deals (stageId not in WON / LOSE) with an amount between 50 000
and 200 000, created in the last six months:
import { EnumCrmEntityTypeId, Text } from '@bitrix24/b24jssdk'
const sixMonthsAgo = new Date()
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)
const response = await $b24.actions.v2.callList.make({
method: 'crm.item.list',
params: {
entityTypeId: EnumCrmEntityTypeId.deal,
filter: {
'!stageId': ['WON', 'LOSE'],
'>=opportunity': 50000,
'<=opportunity': 200000,
'>=createdTime': Text.toB24Format(sixMonthsAgo)
},
select: ['id', 'title', 'stageId', 'opportunity']
},
idKey: 'id',
customKeyForResult: 'items'
})
v3 — unfinished tasks (status not in completed / deferred) with a story-point
estimate between 5 and 20, created in the last six months:
import { Text } from '@bitrix24/b24jssdk'
const sixMonthsAgo = new Date()
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)
const response = await $b24.actions.v3.callList.make({
method: 'tasks.task.list',
params: {
filter: [
// v3 has no "not in" operator — wrap `in` in a NOT group (`negative: true`).
// The `FilterV3.not(FilterV3.in(...))` helper builds the same shape.
// 5 = completed, 6 = deferred
{ negative: true, conditions: [['status', 'in', [5, 6]]] },
['storyPoints', 'between', [5, 20]],
['created', '>=', Text.toB24Format(sixMonthsAgo)]
],
// v3 field names are camelCase — `tasks.task.field.list` is how you ask the
// portal for the real ones rather than guessing.
select: ['id', 'title', 'status', 'storyPoints']
},
idKey: 'id',
customKeyForResult: 'items'
})
See also
- Choosing the right method — pick
Call/CallList/FetchList/Batch. - CallListV2 / FetchListV2 — list actions that strip
order. - Skill: b24jssdk-filtering — the full content reference (
FilterV3builder, IN/between, multi-funnel).