# 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 lists
> - `POST /v1/{entity}/search` — request body `{ "filter": { ... } }`

**Quick jump:** [How to pass a filter in a GET request](#how-to-pass-a-filter-in-a-get-request) · [Filters in curl](#filters-in-curl) · [Filter by phone and email](#filter-by-phone-and-email) · [OR logic](#or-logic) · [Search endpoint](#search-endpoint) · [Pagination](#pagination) · [Error codes](#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:

```bash
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`:

```bash
GET /v1/deals?filter={"stageId":"NEW","amount":{"$gte":50000}}
```

The value of the JSON form must be URL-encoded:

```javascript
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]=5` only 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 with `400 INVALID_FILTER`.

### Filters in curl

`curl` reads square and curly brackets in a URL as a pattern for expanding several URLs. Without the `-g` flag (long form `--globoff`) both filter forms break before the request is sent:

- with bracket notation, `curl` exits with code `3` and the message `bad range in URL`, nothing is sent, and with `-s` there is no message at all
- the JSON form loses its curly brackets, and the API responds `400 INVALID_FILTER`. With several conditions, `curl` sends a separate request for each one.

Pass `-g` and wrap the URL in single quotes — inside double quotes the shell replaces `$gte` with an empty variable:

```bash
curl -g 'https://vibecode.bitrix24.com/v1/deals?filter[stageId]=NEW&filter[amount][$gte]=50000' \
  -H "X-Api-Key: YOUR_API_KEY"
```

`fetch` and other HTTP clients do not treat brackets as a pattern, so they need no flag.

## Syntax 1: operators with a `$` sign

Operators prefixed with `$` inside the field object.

```json
{
  "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 `$nin` operator: `{ "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` / `$nin` operators 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](#or-logic) section below.

```json
{
  "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.

```json
{
  "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.

```json
{
  "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:

```json
{
  "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

```json
{ "filter": { ">=createdAt": "2026-03-01T00:00:00" } }
```

Finds records created since March 1, 2026.

```json
{ "filter": { "updatedAt": { "$gte": "2026-05-01T00:00:00" } } }
```

Finds records updated since the specified point in time.

### Time zone in a filter value

ISO 8601 with `Z` or a numeric offset reaches Bitrix24 intact, including fractional seconds. The values `2026-10-05T19:20:22.000Z` and `2026-10-05T22:20:22.000+03:00` specify the same instant. You can pass `updatedAt` from a response into `$gte` without adjusting the clock. In a query string, encode `+` as `%2B`.

A value without a zone, such as `2026-10-05T19:20:22`, retains Bitrix24 local-time semantics: it depends on the settings of the user on whose behalf the call runs. For a personal key, this is the key owner: their ID comes in `data.owner.userId` of the [`GET /v1/me`](./keys-auth.md) response, and the zone in `timeZone` of the [`GET /v1/users/:id`](./entities/users.md) response. A calendar date `YYYY-MM-DD` passes through unchanged. Use an explicit zone for an absolute instant.

For a range with explicit zones, automatic windowing preserves the absolute boundary instants. Bounds without a zone stay unzoned in every window, so Bitrix24 reads them in local time, as in a request without windowing. A mix of a local bound and a zoned bound runs without windowing, because a shared window axis would require the account timezone. The search response includes `DATE_WINDOWING_SKIPPED` in `meta.warnings`: on Bitrix24 methods that filter after pagination, the result may be incomplete. This warning reports a risk, not confirmed row loss; it appears for every mixed pair because even the exact range width cannot be determined without the account timezone. To enable windowing for that range, provide an explicit zone on both bounds. Explicit `autoWindow: false`, or automatic windowing disabled for the entity, suppresses this warning.

The `X-Vibe-Timezone` header, [which declares the caller's time zone](./entity-api.md#time-zone-on-writes), affects **writes only**. It does not affect filters. Writing a naive literal with the header and filtering on the same naive literal can refer to different instants: use the zoned value from the response as the filter boundary. If your integration manually compensated for the old suffix removal, remove that compensation.

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`:

```json
{ "filter": { "createdAt": { "$gte": "2026-06-01T00:00:00", "$lte": "2026-06-30T23:59:59" } } }
```

## NOT filters

Excluding values:

```json
{ "filter": { "stageId": { "$ne": "LOST" } } }
```

```json
{ "filter": { "!stageId": "LOST" } }
```

```json
{ "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:

```json
{ "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`:

```json
{ "filter": { "ufCrm_taxId": null } }
```

Together the two selections form a complete partition of the set (filled + empty = all).

**Zero in a numeric field counts as empty.** The condition `{ "amount": null }` returns deals with an amount of `0`, and `{ "amount": { "$ne": null } }` does not return them. An exact comparison with zero, `{ "amount": 0 }`, selects the same records as the comparison with `null`, so a filter cannot tell zero apart from an unset field. The rule is the same for standard numeric fields and for custom fields of the "number" type. If zero and a missing value are different states for your task, select the records with the "field is empty" condition and split them by the field value in the response.

> **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). In GET, pass an **empty value** instead of `null`. It works in both directions:
>
> - Correct: `GET /v1/contacts?filter[ufCrm_taxId]=` — the field is empty, same as `{ "ufCrm_taxId": null }`
> - 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 condition
>
> An empty value is the "field is empty" condition, not a way to drop the condition. If the field is unset on every record in the selection, the request returns all of them, and the response looks as if the filter did nothing. If the field is set on every record, you get an empty list.
>
> Or use `POST /v1/contacts/search` with the body `{ "filter": { "ufCrm_taxId": { "$ne": null } } }` — a JSON body carries `null` correctly.

## 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:

```json
{ "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](./duplicates.md) — 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:

```bash
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:

```json
{ "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:

```json
{
  "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](./batch.md):

```json
{
  "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`:

```js
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

```json
{ "filter": { "LOGIC": "OR", "0": { "stageId": "NEW" }, "1": { "stageId": "WON" } } }
```

Response:

```json
{
  "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](#how-to-pass-a-filter-in-a-get-request).

All three styles can be combined in a single filter:

```json
{
  "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:

```bash
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 with 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 response, 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)](/docs/entity-api#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 known to be 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. There is one rule: **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 the `UF_` 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 with `UF_CRM_1698325419` it 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) the `UF_` 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 `$nin`
  gets 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, 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 behavior — 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 shared code for list, 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 Channels 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](#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](#one-field-one-spelling) |
| 400 | `UNKNOWN_FILTER_FIELD` | The entity has no such field — see [An unknown filter field name](#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](#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](./errors.md).

## Examples by entity

### Deals — by stage and amount

```json
{
  "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](#filter-by-phone-and-email). A number is looked up with the separate `POST /v1/duplicates/find` endpoint — [Duplicate search](./duplicates.md):

```bash
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

```json
{
  "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

```json
{
  "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

```json
{
  "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](./batch.md):

```json
{
  "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](./optimization.md#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](./optimization.md#incomplete-result).

```js
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.

```js
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](./batch.md). 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.

```js
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.

## See also

- [API overview](./entity-api.md)
- [Duplicate search](./duplicates.md)
- [Related data](./includes.md)
- [Batch API](./batch.md)
- [API reference](./api-reference.md)
- [Error codes](./errors.md)
