Para agentes de IA: markdown desta página — /docs-content-en/filtering.md índice da documentação — /llms.txt
Os artigos da documentação estão disponíveis atualmente em inglês.
Filtering and search
Three filtering syntaxes for the Entity API. 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: How to pass a filter in a GET request · Filter by phone and email · OR logic · Search endpoint · Pagination · Error codes
How to pass a filter in a GET request
The filter parameter has two equally valid forms. Pick one and pass the whole filter in it.
Bracket notation — one URL parameter per condition:
GET /v1/deals?filter[stageId]=NEW&filter[amount][$gte]=50000
JSON object — the whole filter as a single value. The same shape as the body of POST /v1/{entity}/search:
GET /v1/deals?filter={"stageId":"NEW","amount":{"$gte":50000}}
The value of the JSON form must be URL-encoded:
const filter = { stageId: 'NEW', amount: { $gte: 50000 } }
const query = `filter=${encodeURIComponent(JSON.stringify(filter))}&limit=50`
A value that is neither bracket notation nor a JSON object is rejected with 400 INVALID_FILTER instead of silently returning the whole collection. An empty ?filter= means "no filter".
The two forms must not be mixed in one request. In
?filter={"id":3}&filter[amount]=5only one of them reaches the filter: the query-string parser writes both into the same place, so the second replaces the first. The response then looks correctly filtered while half the conditions are never applied, which is why such a request is rejected with400 INVALID_FILTER.
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.
Time zone in a filter value
Filter values are always read in the Bitrix24 account's time zone. Bitrix24 handles a date filter without regard for time zones: a value carrying a suffix (Z or +02:00) is silently dropped together with the whole condition — the request succeeds and returns the entire table. So the platform strips the suffix for you, and a bare YYYY-MM-DDTHH:MM:SS is what reaches Bitrix24. Specifying a time zone in a filter value is pointless, and relying on it is dangerous.
The X-Vibe-Timezone header, which a client uses to declare its own time zone, affects writes only. That means a write of 2026-07-15T13:00:00 with that header and a filter on the same literal will not match: the record was stored with your time zone's offset applied, while the filter searches in the account's time zone. Convert the filter boundaries into the account's time zone yourself.
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_taxId": { "$ne": null } } }
Finds only records where ufCrm_taxId 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_taxId": 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:
- Correct:
GET /v1/contacts?filter[ufCrm_taxId][$ne]=— the field is filled in- Incorrect:
GET /v1/contacts?filter[ufCrm_taxId][$ne]=null— matches the text "null", which is a different conditionOr use
POST /v1/contacts/searchwith the body{ "filter": { "ufCrm_taxId": { "$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 both a work phone and a mobile phone saved, the filter matches it by the work number — the one 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
This is about operator styles inside one filter. The two ways of passing the filter parameter itself in a GET request — bracket notation and JSON — must not be mixed, see How to pass a filter in a GET request.
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 returns INVALID_SORT_DIRECTION. |
limit |
number | Number of records (default 50, maximum 5000). An invalid type — neither a number nor a numeric string — is refused before the call to Bitrix24: 400 INVALID_LIMIT |
offset |
number | Skip N records |
select |
string | string[] | Field selection. An invalid type returns 400 INVALID_SELECT_TYPE; an unknown field name returns UNKNOWN_SELECT_FIELD |
autoWindow |
boolean | false — disable date windowing |
The filter was not sent as an object
filter is an object of conditions. A string, a number, a boolean or an array instead of it
is rejected with the code INVALID_FILTER_SHAPE, and the message says what actually arrived.
POST /v1/tasks/search { "filter": [{ "responsibleId": 1 }] } -> 400 INVALID_FILTER_SHAPE
POST /v1/tasks/search { "filter": { "responsibleId": 1 } } -> 200
A query string follows a different rule: conditions are written in the bracket form
(?filter[responsibleId]=1), and a filter JSON-encoded as one string is parsed and
applied — a value that is neither form is rejected with the code INVALID_FILTER.
Such values used to be lost silently: Bitrix24 was called with no filter at all and answered
200 with the whole collection — the same as an unknown field name, only at the level of the
shape rather than the name.
An unknown filter field name
A field name the entity does not have is rejected before the Bitrix24 call — 400 with the
code UNKNOWN_FILTER_FIELD and the available names listed in the message. Check against that
list, not against GET /v1/{entity}/fields: that answers a different question — which fields
the entity has — and on some entities it is wider, because it augments the schema with
Bitrix24's live answer — and you cannot filter on such a field.
Beyond the listed names, user fields are accepted, and so is id on the
entities that declare one.
Names reserved by the language — __proto__, constructor, prototype, toString and the other
built-in object names — are rejected with the same
400 UNKNOWN_FILTER_FIELD as any other unknown field. That matters when a filter field name is
assembled from user input: such a key does not pass, and the selection is not left unfiltered.
The rule holds on every entity and on aggregation.
A user field is named exactly as it appears in the entity schema: ufCrmProjectCode on deals,
UF_CRM_1698325419 on requisites. Any other spelling of the same field returns
400 UNKNOWN_FILTER_FIELD on deals, contacts and smart process items, while on the remaining
entities the name is dropped: the condition is not applied and the whole collection comes back.
Name and value formats — User fields (UF).
Requisites are the exception: both spellings of one field, UF_CRM_1698325419 and
ufCrm_1698325419, are accepted and filter identically. The second spelling used to be
dropped. A spelling with an underscore before a letter (ufCrm_taxId) is still an unknown
name: Bitrix24 never produces it, the real name cannot be derived from it, and the condition
is lost as before.
The check is on the NAME, not the value: operators, ranges, $in/$nin and AND logic behave
as before. On smart processes, the dynamic relations parentId<N> are accepted in addition to
the names above.
Why this matters. Bitrix24 does not reject an unknown filter key: it
drops it silently and answers 200 with the WHOLE collection. So a typo in a field name looked
like a successful request with an implausibly large result rather than an error. Rejecting
before the call makes that situation visible.
Where the check is NOT enabled yet. For some entities the field schema is deliberately narrower than the real Bitrix24 contract, so the check cannot be enabled — it would reject a field that works. There the old behavior stands: an unknown name goes to Bitrix24 and is silently lost. One request tells you which case an entity is in: send a filter on a name that certainly does not exist and read the status code.
A separate case — a method with no filter at all. For a few entities the Bitrix24 method
accepts no filter in any form (it reads named arguments only). There any filter key is rejected
with the code UNSUPPORTED_FILTER, and the message lists the parameters the method does take.
One field — one spelling
A field can have several accepted spellings: the schema name and the Bitrix24-native name
(amount and OPPORTUNITY on deals), upper and lower case. The rule is one: two conditions
that resolve to the same Bitrix24 filter name are rejected with the code
400 INVALID_DUPLICATE_FILTER_FIELD. The message names both conditions and the single name
they resolved to.
filter[amount]=5000&filter[OPPORTUNITY]=9000 → 400 INVALID_DUPLICATE_FILTER_FIELD
The same rule covers two spellings of one operator ({ "amount": { "$gt": 1, ">": 2 } }) and a
pair of synonyms an entity declares as separate names: on leads those are amount/opportunity,
stageId/statusId, currency/currencyId.
Paired date field names fall here too — updatedAt with updatedTime, and createdAt with
createdTime. An entity declares one of the two and accepts the other as its alias, so both
spellings resolve to one name on the wire:
?filter[updatedAt]=2026-01-01T00:00:00&filter[updatedTime]=2026-02-01T00:00:00
→ 400 INVALID_DUPLICATE_FILTER_FIELD
The same general rule applies here: the result decides, not the spelling. Two conditions with ONE
operator (or two exact values, as above) land on one key and are refused, while
?filter[updatedAt][$gte]=…&filter[updatedTime][$lte]=… produces the different keys >= and <=
— an ordinary range, which works and is not refused. The two cases are easy to confuse, so a range
is safer built with one spelling: ?filter[>=updatedAt]=…&filter[<=updatedAt]=….
Such a request used to answer 200 while applying only one of the two conditions — which one
was decided by the key order in the request. The result looked filtered even though half of the
filter never applied.
The result decides, not the spelling. The refusal fires where two conditions genuinely take the same key on the wire. Therefore:
- Custom fields. The pair "
UF_form + camelCase spelling" folds into one name only where the platform itself converts camelCase into theUF_form — on entities with the older naming style (tasks, for example), and only for the letters-only spelling (ufCrmProjectCode). There it is refused. The digit-suffixed spelling (ufCrm_1698325419) travels to Bitrix24 as it is, so paired withUF_CRM_1698325419it yields two different names and is NOT refused — Bitrix24 then silently drops the second condition, exactly as it did before this check. Requisites are the exception: there the digit spelling is converted too and the pair is refused. On camelCase-named entities and on CRM items (deals, contacts, companies, leads, invoices, quotes, smart process items) theUF_form and the camelCase form are DIFFERENT names on the wire: both spellings travel to Bitrix24 as they are and nothing is refused. - Two operators producing one key.
$ne("not equal") and$nin("not in the list") both become the!prefix and take one key — on every entity except CRM items (there$ningets its own!@prefix and the pair passes). This boundary is DIFFERENT from the custom-field one above: camelCase-named entities fall under it too — catalog products, mail mailboxes, smart processes. Send one of the two:{ "responsibleId": { "$nin": [1, 2] } }also covers a single exclusion.
What still works. Operators that produce DIFFERENT keys behave as before:
{ "amount": { "$gte": 1000, "$lte": 5000 } } and ?filter[>=amount]=1000&filter[<=amount]=5000
are a range, not a duplicate. A single condition on a field is unchanged too.
⚠️ "A single spelling of the field" alone does not guarantee the old behaviour — what matters is how
many conditions it produces, and on which entities they land on one key. The pair $ne + $nin on
one field is a single spelling but two conditions, so by the rule above it is refused everywhere
except CRM items: POST /v1/tasks/search {"filter":{"responsibleId":{"$ne":1,"$nin":[2,3]}}}
answers 400, while the same filter on deals or contacts answers 200, because there $nin gets
its own !@ prefix and the keys differ. On CRM items Bitrix24 still drops the extra condition
silently, so a duplicate pair of NOT conditions is best avoided there anyway.
Where the check is NOT applied. The rule works on API entities — where the filter is parsed by
the standard code of lists, search, aggregation and batch calls. Individual routes with their own
filter format parse it with their own code and are not covered: there a pair of spellings still
answers 200 with a condition silently lost. Those include GET /v1/requisite-links with
POST /v1/requisite-links/search, call statistics, lists, task comments and the open-lines routes —
the list is not closed, because such a route is added independently of this check.
The rule of thumb is simple: if a route has its own set of filter keys documented on its own page rather than the entity's shared field schema, expect a duplicate spelling NOT to be refused there. Send a single spelling yourself.
Error codes
| HTTP | Code | Condition |
|---|---|---|
| 400 | INVALID_FILTER |
The filter value is neither bracket notation nor a JSON object — or one request mixes both forms (see How to pass a filter in a GET request) |
| 400 | INVALID_FILTER_OPERATOR |
Unknown operator in a field value or an attempt to pass LOGIC / $or |
| 400 | INVALID_FILTER_SHAPE |
filter in a request body was not sent as an object — a string, a number or an array |
| 400 | INVALID_FILTER_FIELD |
Field name starts with the Bitrix24-native prefix @ (IN) or !@ (NOT IN) — use the $in / $nin operators instead |
| 400 | INVALID_DUPLICATE_FILTER_FIELD |
Two spellings of one field or one operator in a single filter — see One field — one spelling |
| 400 | UNKNOWN_FILTER_FIELD |
The entity has no such field — see An unknown filter field name |
| 400 | UNSUPPORTED_FILTER |
This entity's Bitrix24 method takes no filter at all. The message lists the parameters it does take |
| 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 | INVALID_LIMIT |
limit is neither a number nor a numeric string. The message names the actual type received. null, an empty string, and an empty list mean "the parameter is not set" and are not errors |
| 400 | INVALID_SELECT_TYPE |
select is neither a string nor an array of strings — for example 999, true, {}, or [1,2]. An unknown field NAME has its own code, UNKNOWN_SELECT_FIELD |
| 400 | UNSTABLE_OFFSET_PAGINATION |
offset > 0 with a wide date range (see Pagination) |
| — | WINDOWED_SEARCH_FAILED |
No longer returned: 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 — by date range
{
"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
Three ready-made approaches — pick the one that fits your task. For each response, the loop bound is meta.hasMore, not arithmetic over meta.total: the "there is more" signal is derived from page fullness. The meta.total field itself is optional — it can be absent when the count was not requested. None of these approaches creates an immutable snapshot or guarantees complete traversal when data or access rights change.
Get the whole result at once
Suitable for reports and exports when no more than 5000 records match the filter. Set limit to any value up to 5000 — the service returns the entire matching result in a single response. A collection of tens of thousands of records is read with the next approach, the cursor — Exporting a large collection.
One response is not always enough. When the search splits the date range into windows, the result runs into either the 5000-record ceiling or the size of a single window fetch. This is reported in meta.warnings with the WINDOW_TRUNCATED code — covered in incomplete result.
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.hasMore tells you whether anything remains beyond limit
Walk the `nextAfterId` cursor
This approach applies to methods whose responses include meta.nextAfterId and is the primary choice for large scans. Sort by id ascending, disable the exact count with withTotal: false, and pass meta.nextAfterId from the previous response back into the >id filter. Use select to request only the required fields and always include id. The cursor does not depend on an offset and does not get more expensive towards the end of the collection. Every call reads one short page, and an interrupted scan resumes from the last meta.nextAfterId instead of starting over.
The cursor is available for deals, leads, contacts, companies, quotes, and smart process items. Other entities carry no meta.nextAfterId in the response — for them the other two approaches apply.
let after = null
while (true) {
const res = await fetch('/v1/deals/search', {
method: 'POST',
headers: { 'X-Api-Key': key, 'Content-Type': 'application/json' },
body: JSON.stringify({
filter: { ...(after ? { '>id': after } : {}) },
sort: 'id',
select: ['id', 'title'],
limit: 50,
withTotal: false,
}),
})
const { data, meta } = await res.json()
for (const deal of data) process(deal)
if (!meta.hasMore) break
if (!meta.nextAfterId) {
throw new Error('The response has meta.hasMore=true but no meta.nextAfterId')
}
after = meta.nextAfterId
}
The underlying request follows the recommended Bitrix24 pattern: start=-1, order=ID ASC, and an ID filter for values greater than the last identifier received. The client does not pass start in the body of a request to this endpoint — batch subcalls do accept it, see Batch requests. Vibecode applies it only to methods verified to support an id filter together with start=-1. For other methods, the service preserves request correctness and may not apply the no-count mode.
withTotal: false removes meta.total and, in the supported mode, a separate COUNT. Do not scan the collection to calculate a count. If you need an exact number, first read the entity's operations.search.paginationStability.counting advice in GET /v1/guide. When the advice contains an aggregation path, use the count function. When the advice exists but contains no path, there is no cheap exact count — read meta.total only when the field is present, and bound the scan by meta.hasMore. If the entire counting block is absent together with the generic search operation, do not guess an aggregation path: follow the entity or domain docs pointer in the same guide and use only an explicitly documented count operation.
The scan assumes that records are not deleted and access rights do not change before it finishes. If either assumption is violated, some records may be skipped. The Vibecode API does not guarantee complete traversal.
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 infinite loop on an empty page
for (const deal of data) process(deal)
offset += data.length
if (!meta.hasMore) break
}
An empty page in the middle of a scan is a separate case. When fewer rows are available at the requested position than offset skips, the page comes out empty even though records still match the filter. The response then carries meta.warnings with the code OFFSET_BEYOND_FETCHED_PAGE and the field field: "offset". Check that code before leaving the loop: raise limit, narrow the filter, or switch to the cursor.
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 three approaches above.
Do not walk a collection page by page just to learn how many records it holds. Paging through five thousand deals to get the number 4863 is a hundred calls instead of one. If you need the exact number, use the count function only at the path explicitly named by operations.search.paginationStability.counting. When the entire block is absent together with the generic search operation, follow the entity or domain docs pointer from GET /v1/guide and do not guess a path.