For AI agents: markdown of this page — /docs-content-en/filtering.md documentation index — /llms.txt
Filtering and search
Three filtering syntaxes for API entities. All styles can be mixed in a single request.
Filtering works in two places:
GET /v1/{entity}?filter[field]=value— URL parameter for listsPOST /v1/{entity}/search— request body{ "filter": { ... } }
Quick jump: Filter by phone and email · OR logic · Search endpoint · Pagination · Error codes
Syntax 1: operators with a `$` sign
Operators prefixed with $ inside the field object.
{
"filter": {
"amount": { "$gte": 50000 },
"stageId": { "$ne": "LOST" },
"createdAt": { "$gte": "2026-01-01T00:00:00" }
}
}
Finds records with an amount of 50,000 or more, a stage other than LOST, created since the start of 2026.
Available operators
| Operator | Meaning | Example |
|---|---|---|
$gt |
> (greater than) | { "amount": { "$gt": 10000 } } |
$gte |
>= (greater than or equal) | { "amount": { "$gte": 50000 } } |
$lt |
< (less than) | { "amount": { "$lt": 100000 } } |
$lte |
<= (less than or equal) | { "amount": { "$lte": 200000 } } |
$ne |
!= (not equal) | { "stageId": { "$ne": "LOST" } } |
$contains |
substring search | { "title": { "$contains": "delivery" } } |
$in |
is in the array (IN) | { "stageId": { "$in": ["NEW", "WON"] } } |
$nin |
is NOT in the array (NOT IN) | { "categoryId": { "$nin": [1, 3] } } |
An exact match is expressed by the value itself, without an operator: { "stageId": "NEW" }.
Exclude a set of values. "Field is NOT in the list" is expressed with the
$ninoperator:{ "categoryId": { "$nin": [1, 3] } }returns deals from all pipelines except 1 and 3. The Bitrix24-native field-name prefixes@(IN) and!@(NOT IN) —{ "@categoryId": [...] },{ "!@categoryId": [...] }— are NOT supported. Use the$in/$ninoperators instead.
Combining
Multiple conditions on a single field are joined with AND logic. Conditions on different fields are also joined with AND logic. For OR logic see the OR logic section below.
{
"filter": {
"amount": { "$gte": 50000, "$lte": 200000 },
"stageId": { "$ne": "LOST" }
}
}
Finds records with an amount from 50,000 to 200,000 and a stage other than LOST.
Syntax 2: prefix in the field name
The operator as part of the field name. The form Bitrix24 understands directly.
{
"filter": {
">=amount": 50000,
"<=amount": 200000,
"!stageId": "LOST"
}
}
Finds records with an amount from 50,000 to 200,000 and a stage other than LOST.
Operators
| Prefix | Meaning |
|---|---|
>= |
greater than or equal |
> |
greater than |
<= |
less than or equal |
< |
less than |
! |
not equal |
% |
substring |
Syntax 3: operator as the object key
The operator as the key of a nested object. The same form as in syntax 2, but with the field name and condition separated.
{
"filter": {
"amount": { ">=": 50000 },
"stageId": { "!": "LOST" }
}
}
Finds records with an amount of 50,000 or more and a stage other than LOST.
Filtering by date
Date fields (createdAt, updatedAt, closedAt, beginDate, etc.) accept ISO 8601 strings:
{
"filter": {
"createdAt": { "$gte": "2026-01-01T00:00:00" },
"closedAt": { "$lte": "2026-03-31T23:59:59" }
}
}
Finds records created since the start of 2026 and closed before the end of March.
Examples
{ "filter": { ">=createdAt": "2026-03-01T00:00:00" } }
Finds records created since March 1, 2026.
{ "filter": { "updatedAt": { "$gte": "2026-05-01T00:00:00" } } }
Finds records updated since the specified point in time.
The range boundaries are set with the $gte/$lte operators — or the equivalent >=/<= forms from the syntaxes above. The from/to keys inside a field value are not supported and return 400 INVALID_FILTER_OPERATOR:
{ "filter": { "createdAt": { "$gte": "2026-06-01T00:00:00", "$lte": "2026-06-30T23:59:59" } } }
NOT filters
Excluding values:
{ "filter": { "stageId": { "$ne": "LOST" } } }
{ "filter": { "!stageId": "LOST" } }
{ "filter": { "stageId": { "!": "LOST" } } }
All three variants find records whose stage differs from LOST.
Filter by presence (not empty / not null)
A common case is selecting only records where a field is filled in: for example, contacts with a tax ID set, so you don't pull the whole database during deduplication. Comparing against null via the $ne operator runs this selection on the Bitrix24 side:
{ "filter": { "ufCrm_inn": { "$ne": null } } }
Finds only records where ufCrm_inn is filled in. The equivalent form is a comparison against an empty string { "$ne": "" }. Works for both custom (UF) fields and standard ones (post, phone, email, etc.).
The opposite condition — "field is empty" — is set by an exact comparison with null:
{ "filter": { "ufCrm_inn": null } }
Together the two selections form a complete partition of the set (filled + empty = all).
Important for GET requests. A URL cannot carry a real
null— in the query string it turns into the text"null", and the filter starts matching the literal string "null" (returns garbage). For "not empty" in GET, pass an empty value, not the wordnull:
- ✅
GET /v1/contacts?filter[ufCrm_inn][$ne]=— field is filled in- ❌
GET /v1/contacts?filter[ufCrm_inn][$ne]=null— matches the text "null", which is NOT the sameOr use
POST /v1/contacts/searchwith the body{ "filter": { "ufCrm_inn": { "$ne": null } } }— a JSON body carriesnullcorrectly.
Filter by phone and email
The phone and email fields on leads, contacts, and companies behave differently from the rest. There are two things to know about them before you write a filter.
The value is compared as a whole, punctuation included. A record with the number +1 (202) 555-0123 is found only by that same string:
{ "filter": { "phone": "+1 (202) 555-0123" } }
The plus sign, spaces, parentheses, and hyphens are part of the stored value. The same record is therefore not found by 12025550123 or by 2025550123.
How a number is stored in the database depends on the Bitrix24 account: the same phone number may be stored as +1 (202) 555-0123 or as 12025550123. Your program does not know in advance which form was chosen, so an exact-value filter is only suitable when the stored string is already known.
A record may have several numbers, and the filter sees only the first one. If a contact has a work phone and a mobile phone recorded, the filter finds it by the work one — the number returned in the phone field of the response. Filtering by the mobile number returns an empty list.
Find a record by phone number
Both limitations are handled by Duplicate search — a separate POST /v1/duplicates/find endpoint. It matches numbers by value rather than by formatting, and checks every number on a record, not only the first one:
curl -X POST https://vibecode.bitrix24.com/v1/duplicates/find \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "phone",
"values": ["+12025550123"]
}'
The queries +1 (202) 555-0123, +12025550123, and 12025550123 all find the same record. The number is passed in full: without the country code it is not recognized. The response contains the IDs of the leads, contacts, and companies found.
Search by part of the value
An exact comparison is not the only form. The $contains operator searches for a piece of text inside the stored value, and for email this is a workable approach:
{ "filter": { "email": { "$contains": "@example.com" } } }
Finds records with an email address in the example.com domain. For a phone number the result depends on where the spaces and hyphens appear in the stored number, and on whether it is the first number of the record. That is why a record is looked up by number with duplicate search, not with the $contains operator.
OR logic
All conditions in the filter object are joined with AND logic. OR logic between different fields cannot be expressed directly in filter — passing LOGIC: "OR" or $or returns 400 INVALID_FILTER_OPERATOR. Below are three ways to get the same result.
Option 1: `$in` — multiple values of one field
When you need "field equals A or B or C", use the $in operator from the table above:
{
"filter": {
"stageId": { "$in": ["NEW", "WON"] }
}
}
Finds deals in the NEW or WON stage. The operator works on any field for which an exact comparison makes sense: assignedById, categoryId, id, sourceId, and so on.
Option 2: Batch — multiple filters in one request
When the OR conditions touch different fields ("deals in the NEW stage or with an amount over 100,000"), move each condition into a separate call inside the Batch API:
{
"calls": [
{
"id": "by_stage",
"entity": "deals",
"action": "list",
"params": { "filter": { "stageId": "NEW" }, "limit": 200 }
},
{
"id": "by_amount",
"entity": "deals",
"action": "list",
"params": { "filter": { "amount": { "$gte": 100000 } }, "limit": 200 }
}
]
}
The response arrives as data.results.by_stage and data.results.by_amount — two independent arrays that the client merges itself.
Option 3: parallel requests + client-side merge
When you need a single list without duplicates, run the calls in parallel and merge the results by id:
const [a, b] = await Promise.all([
fetch('/v1/deals/search', {
method: 'POST',
headers: { 'X-Api-Key': key, 'Content-Type': 'application/json' },
body: JSON.stringify({ filter: { stageId: 'NEW' }, limit: 200 }),
}),
fetch('/v1/deals/search', {
method: 'POST',
headers: { 'X-Api-Key': key, 'Content-Type': 'application/json' },
body: JSON.stringify({ filter: { stageId: 'WON' }, limit: 200 }),
}),
])
const { data: dataA } = await a.json()
const { data: dataB } = await b.json()
const byId = new Map(dataA.map(d => [d.id, d]))
for (const d of dataB) byId.set(d.id, d)
const merged = [...byId.values()]
A Map keyed by id removes duplicates when a record matches both conditions.
What not to do
{ "filter": { "LOGIC": "OR", "0": { "stageId": "NEW" }, "1": { "stageId": "WON" } } }
Response:
{
"success": false,
"error": {
"code": "INVALID_FILTER_OPERATOR",
"message": "INVALID_FILTER_OPERATOR: 'LOGIC' is not supported. OR/AND logic cannot be expressed in a single filter. For same-field OR use { field: { $in: [v1, v2] } }. For cross-field OR run parallel requests via POST /v1/batch. AND is the default — combine conditions as sibling keys in one filter object."
}
}
Likewise, an attempt to pass $or returns 400 with a hint to use the Batch API.
Mixing syntaxes
All three styles can be combined in a single filter:
{
"filter": {
"amount": { "$gte": 50000 },
"!stageId": "LOST",
"createdAt": { ">=": "2026-01-01T00:00:00" }
}
}
Finds records with an amount of 50,000 or more, a stage other than LOST, created since the start of 2026. Here all three syntaxes are used together.
Search endpoint
POST /v1/{entity}/search — full-featured search:
curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
"https://vibecode.bitrix24.com/v1/deals/search" \
-d '{
"filter": {
"stageId": { "$ne": "LOST" },
"amount": { "$gte": 100000 }
},
"sort": { "amount": "desc" },
"limit": 200
}'
| Parameter | Type | Description |
|---|---|---|
filter |
object | Filtering conditions |
sort |
string, object or array | Sorting. Three forms are accepted: string ("id" / "-amount" / "id,-createdAt"), object ({ id: "asc", amount: "desc" } — keys in insertion order) or array (["id", "-amount"]). Instead of asc/desc, 1/-1 are accepted. An invalid type returns 400 INVALID_SORT_TYPE, an unknown direction — INVALID_SORT_DIRECTION. |
limit |
number | Number of records (default 50, maximum 5000) |
offset |
number | Skip N records |
autoWindow |
boolean | false — disable date windowing |
Error codes
| HTTP | Code | Condition |
|---|---|---|
| 400 | INVALID_FILTER_OPERATOR |
Unknown operator in a field value or an attempt to pass LOGIC / $or |
| 400 | INVALID_FILTER_FIELD |
Field name starts with the Bitrix24-native prefix @ (IN) or !@ (NOT IN) — use the $in / $nin operators instead |
| 400 | UNKNOWN_FILTER_FIELD |
Field is missing from the entity schema (for entities with a complete field schema) |
| 400 | INVALID_SORT_TYPE |
sort is not a string, object, or array |
| 400 | INVALID_SORT_DIRECTION |
Sort direction is not asc / desc / 1 / -1 |
| 400 | UNSTABLE_OFFSET_PAGINATION |
offset > 0 with a wide date range (see Pagination) |
| — | WINDOWED_SEARCH_FAILED |
Retired: if auto-windowing fails completely, the real Bitrix24 code is returned — UNKNOWN_FILTER_FIELD / INVALID_PARAMS / BITRIX_ACCESS_DENIED / RATE_LIMITED / BITRIX_UNAVAILABLE / BITRIX_TIMEOUT (503) |
The full list of common API errors — Error codes.
Examples by entity
Deals — by stage and amount
{
"filter": {
"stageId": "NEW",
"amount": { "$gte": 100000 }
},
"sort": { "createdAt": "desc" }
}
Finds deals in the NEW stage with an amount of 100,000 or more, sorted by creation date (newest first).
Contacts — by phone
A phone number is not looked up with a filter: it is compared against the whole stored string and only against the first number of a record — see Filter by phone and email. A number is looked up with the separate POST /v1/duplicates/find endpoint — Duplicate search:
curl -X POST https://vibecode.bitrix24.com/v1/duplicates/find \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "phone",
"values": ["+12025550124"],
"entityType": "contact"
}'
Returns the IDs of the contacts with that number in any format.
Tasks — unfinished
{
"filter": {
"status": { "$ne": 5 }
}
}
Finds all tasks with a status other than 5 (completed). Statuses: 2 = pending, 3 = in progress, 4 = awaiting control, 5 = completed, 6 = deferred.
Leads — for a period
{
"filter": {
"createdAt": { "$gte": "2026-01-01T00:00:00", "$lte": "2026-03-31T23:59:59" }
}
}
Finds leads created in the first quarter of 2026.
Calendar events
{
"filter": {
"dateFrom": { "$gte": "2026-04-01T00:00:00" }
}
}
Finds events with a start date from April 1, 2026. For calendar-events, the type and ownerId parameters are required in the URL: /v1/calendar-events?type=user&ownerId=1.
Filtering in Batch
Filters also work in the Batch API:
{
"calls": [
{
"id": "new_deals",
"entity": "deals",
"action": "list",
"params": {
"filter": { "stageId": "NEW" }
}
},
{
"id": "search_contacts",
"entity": "contacts",
"action": "search",
"params": {
"filter": { "email": { "$contains": "@example.com" } }
}
}
]
}
In a single request, this retrieves the list of deals in the NEW stage and finds contacts with an address in the example.com domain.
Pagination
Two ready-made approaches — pick the one that fits your task.
Get the whole result at once
Suitable for reports, exports, and any case where you need all records. Set limit to any value up to 5000 — the service returns the entire matching result in a single response.
const res = await fetch('/v1/deals/search', {
method: 'POST',
headers: { 'X-Api-Key': key, 'Content-Type': 'application/json' },
body: JSON.stringify({
filter: { closedAt: { $gte: '2026-03-22T00:00:00Z', $lte: '2026-04-22T00:00:00Z' } },
limit: 5000,
}),
})
const { data, meta } = await res.json()
// data — all records; meta.total — total count of matching records
Page through manually
Suitable when you need to process records in batches (for example, importing 50 at a time while saving progress). Add autoWindow: false, sort by id, and increase offset on each iteration. offset counts records, and meta.hasMore accounts for what you already skipped, so use it to stop the loop.
let offset = 0
while (true) {
const res = await fetch('/v1/deals/search', {
method: 'POST',
headers: { 'X-Api-Key': key, 'Content-Type': 'application/json' },
body: JSON.stringify({
filter: { closedAt: { $gte: '2026-03-22T00:00:00Z', $lte: '2026-04-22T00:00:00Z' } },
sort: 'id',
limit: 50,
offset,
autoWindow: false,
}),
})
const { data, meta } = await res.json()
if (data.length === 0) break // guard against an endless loop on an empty page
for (const deal of data) process(deal)
offset += data.length
if (!meta.hasMore) break
}
What not to do
Do not run parallel requests with different offset on the same filter — the service returns 400 UNSTABLE_OFFSET_PAGINATION. Pick one of the two approaches above.