# Фильтрация и поиск

Три синтаксиса фильтрации для API сущностей. Все стили можно **смешивать** в одном запросе.

> Фильтрация работает в двух местах:
> - `GET /v1/{entity}?filter[field]=value` — параметр URL для списка
> - `POST /v1/{entity}/search` — тело запроса `{ "filter": { ... } }`

**Быстрый переход:** [Как передать фильтр в GET-запросе](#как-передать-фильтр-в-get-запросе) · [Фильтр по телефону и почте](#фильтр-по-телефону-и-почте) · [Логика ИЛИ](#логика-или) · [Эндпоинт поиска](#эндпоинт-поиска) · [Постраничный вывод](#постраничный-вывод) · [Коды ошибок](#коды-ошибок)

## Как передать фильтр в GET-запросе

У параметра `filter` две равноправные формы записи. Выберите одну и передайте в ней весь фильтр целиком.

**Скобочная запись** — каждое условие отдельным параметром URL:

```bash
GET /v1/deals?filter[stageId]=NEW&filter[amount][$gte]=50000
```

**JSON-объект** — весь фильтр одним значением. Та же форма, что в теле `POST /v1/{entity}/search`:

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

Значение JSON-формы нужно закодировать для URL:

```javascript
const filter = { stageId: 'NEW', amount: { $gte: 50000 } }
const query = `filter=${encodeURIComponent(JSON.stringify(filter))}&limit=50`
```

Значение, которое не является ни скобочной записью, ни JSON-объектом, отклоняется с `400 INVALID_FILTER` — вместо того чтобы молча вернуть всю коллекцию. Пустое `?filter=` означает «без фильтра».

> **Две формы в одном запросе смешивать нельзя.** В запросе `?filter={"id":3}&filter[amount]=5` до фильтра доходит только одна из них: разборщик строки запроса пишет обе в одно и то же место, и вторая замещает первую. Ответ при этом выглядит корректно отфильтрованным, хотя половина условий не применена, поэтому такой запрос отклоняется с `400 INVALID_FILTER`.

## Синтаксис 1: операторы со знаком `$`

Операторы с префиксом `$` внутри объекта поля.

```json
{
  "filter": {
    "amount": { "$gte": 50000 },
    "stageId": { "$ne": "LOST" },
    "createdAt": { "$gte": "2026-01-01T00:00:00" }
  }
}
```

Найдёт записи с суммой от 50 000, стадией, отличной от LOST, созданные с начала 2026 года.

### Доступные операторы

| Оператор | Значение | Пример |
|----------|---------|--------|
| `$gt` | > (больше) | `{ "amount": { "$gt": 10000 } }` |
| `$gte` | >= (больше или равно) | `{ "amount": { "$gte": 50000 } }` |
| `$lt` | < (меньше) | `{ "amount": { "$lt": 100000 } }` |
| `$lte` | <= (меньше или равно) | `{ "amount": { "$lte": 200000 } }` |
| `$ne` | != (не равно) | `{ "stageId": { "$ne": "LOST" } }` |
| `$contains` | поиск по подстроке | `{ "title": { "$contains": "поставка" } }` |
| `$in` | входит в массив (IN) | `{ "stageId": { "$in": ["NEW", "WON"] } }` |
| `$nin` | НЕ входит в массив (NOT IN) | `{ "categoryId": { "$nin": [1, 3] } }` |

Точное совпадение задаётся значением напрямую, без оператора: `{ "stageId": "NEW" }`.

> **Исключить набор значений.** «Поле НЕ входит в список» задаётся оператором `$nin`: `{ "categoryId": { "$nin": [1, 3] } }` вернёт сделки всех направлений, кроме 1 и 3. Родные префиксы Битрикс24 `@` (IN) и `!@` (NOT IN) в имени поля (`{ "@categoryId": [...] }`, `{ "!@categoryId": [...] }`) НЕ поддерживаются — используйте операторы `$in` / `$nin`.

### Комбинирование

Несколько условий на одном поле объединяются логикой И. Условия на разные поля — тоже логикой И. Для ИЛИ-логики см. раздел [Логика ИЛИ](#логика-или) ниже.

```json
{
  "filter": {
    "amount": { "$gte": 50000, "$lte": 200000 },
    "stageId": { "$ne": "LOST" }
  }
}
```

Найдёт записи с суммой от 50 000 до 200 000 и стадией, отличной от LOST.

## Синтаксис 2: префикс в имени поля

Оператор как часть имени поля. Форма, которую напрямую понимает Битрикс24.

```json
{
  "filter": {
    ">=amount": 50000,
    "<=amount": 200000,
    "!stageId": "LOST"
  }
}
```

Найдёт записи с суммой от 50 000 до 200 000 и стадией, отличной от LOST.

### Операторы

| Префикс | Значение |
|---------|---------|
| `>=` | больше или равно |
| `>` | больше |
| `<=` | меньше или равно |
| `<` | меньше |
| `!` | не равно |
| `%` | подстрока |

## Синтаксис 3: оператор как ключ объекта

Оператор как ключ вложенного объекта. Та же форма, что в синтаксисе 2, но с разделением имени поля и условия.

```json
{
  "filter": {
    "amount": { ">=": 50000 },
    "stageId": { "!": "LOST" }
  }
}
```

Найдёт записи с суммой от 50 000 и стадией, отличной от LOST.

## Фильтрация по дате

Поля даты (`createdAt`, `updatedAt`, `closedAt`, `beginDate` и др.) принимают строки **ISO 8601**:

```json
{
  "filter": {
    "createdAt": { "$gte": "2026-01-01T00:00:00" },
    "closedAt": { "$lte": "2026-03-31T23:59:59" }
  }
}
```

Найдёт записи, созданные с начала 2026 года и закрытые до конца марта.

### Примеры

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

Найдёт записи, созданные с 1 марта 2026.

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

Найдёт записи, обновлённые с указанного момента.

### Часовой пояс в значении фильтра

Значения фильтра всегда читаются в поясе портального аккаунта. Битрикс24 обрабатывает фильтр по дате без учёта пояса: значение с суффиксом (`Z` или `+02:00`) он молча отбрасывает вместе со всем условием — запрос возвращает успех и всю таблицу. Поэтому платформа срезает суффикс сама, и в Битрикс24 уходит голое `ГГГГ-ММ-ДДTЧЧ:ММ:СС`. Указывать пояс в значении фильтра бессмысленно, а полагаться на него — опасно.

Заголовок `X-Vibe-Timezone`, [которым клиент объявляет свой пояс](./entity-api.md#часовой-пояс-на-записи), действует **только на запись**. Значит запись `2026-07-15T13:00:00` с этим заголовком и фильтр по тому же литералу не совпадут: запись легла на портал со смещением вашего пояса, а фильтр ищет в поясе портала. Пересчитайте границы фильтра в пояс портала сами.

Границы диапазона задаются операторами `$gte`/`$lte` — или эквивалентными `>=`/`<=` из синтаксисов выше. Ключи `from`/`to` в значении поля не поддерживаются и вернут `400 INVALID_FILTER_OPERATOR`:

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

## NOT-фильтры

Исключение значений:

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

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

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

Все три варианта найдут записи, у которых стадия отличается от LOST.

## Фильтр по заполненности (не пусто / не null)

Отобрать только записи, где поле **заполнено**, — например контакты с заполненным идентификатором налогоплательщика, чтобы не тянуть всю базу при дедупликации. Сравнение с `null` через оператор `$ne` делает эту выборку на стороне Битрикс24:

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

Найдёт только записи, где `ufCrm_taxId` заполнено. Эквивалентная форма — сравнение с пустой строкой `{ "$ne": "" }`. Работает и для пользовательских (UF) полей, и для стандартных (`post`, `phone`, `email` и др.).

Обратное условие — «поле пусто» — задаётся точным сравнением с `null`:

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

Вместе две выборки дают полное разбиение набора (заполненные + пустые = все).

> **Важно для GET-запросов.** URL не умеет передавать настоящий `null` — в строке запроса он превращается в текст `"null"`, и фильтр начинает искать буквальную строку «null» (вернёт мусор). Для «не пусто» в GET передавайте **пустое значение**, а не слово `null`:
>
> - Правильно: `GET /v1/contacts?filter[ufCrm_taxId][$ne]=` — поле заполнено
> - Неправильно: `GET /v1/contacts?filter[ufCrm_taxId][$ne]=null` — ищет текст «null», а это другое условие
>
> Либо используйте `POST /v1/contacts/search` с телом `{ "filter": { "ufCrm_taxId": { "$ne": null } } }` — в JSON-теле `null` передаётся корректно.

## Фильтр по телефону и почте

Поля `phone` и `email` у лидов, контактов и компаний ведут себя не так, как остальные. У них две особенности, которые надо знать до того, как писать фильтр.

**Значение сравнивается целиком, вместе со знаками.** Запись с номером `+7 (999) 123-45-67` найдётся только по этой же строке:

```json
{ "filter": { "phone": "+7 (999) 123-45-67" } }
```

Плюс, пробелы, скобки и дефисы — часть сохранённого значения. Поэтому та же запись не найдётся ни по `79991234567`, ни по `89991234567`.

В каком виде номер попадёт в базу, решает портал: один и тот же телефон может лежать в ней и как `+7 (999) 123-45-67`, и как `89991234567`. Ваша программа заранее не знает, какой вид выбран, поэтому фильтр по точному значению годится только тогда, когда сохранённая строка уже известна.

**У записи может быть несколько номеров, а фильтр видит только первый.** Если у контакта записаны рабочий и мобильный телефоны, фильтр найдёт его по рабочему — тому, который приходит в поле `phone` в ответе. По мобильному фильтр вернёт пустой список.

### Найти запись по номеру телефона

Обе особенности снимает [Поиск дубликатов](./duplicates.md) — отдельный эндпоинт `POST /v1/duplicates/find`. Он сравнивает номера по существу, а не по написанию, и проверяет все номера записи, а не только первый:

```bash
curl -X POST https://vibecode.bitrix24.tech/v1/duplicates/find \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "phone",
    "values": ["+79991234567"]
  }'
```

Запросы `+7 (999) 123-45-67`, `+79991234567` и `89991234567` найдут одну и ту же запись. Номер передаётся целиком: без кода страны или без ведущей «8» он не опознаётся. В ответе приходят ID найденных лидов, контактов и компаний.

### Поиск по части значения

Точное сравнение — не единственная форма. Оператор `$contains` ищет кусок текста внутри сохранённого значения, и для почты это рабочий способ:

```json
{ "filter": { "email": { "$contains": "@example.com" } } }
```

Найдёт записи с почтой в домене `example.com`. Для телефона результат зависит от того, где внутри сохранённого номера стоят пробелы и дефисы, и от того, первый это номер записи или нет. Поэтому запись по номеру ищут поиском дубликатов, а не оператором `$contains`.

## Логика ИЛИ

Все условия в объекте `filter` объединяются логикой И. Логический оператор ИЛИ между разными полями нельзя выразить прямо в `filter` — попытка передать `LOGIC: "OR"` или `$or` возвращает `400 INVALID_FILTER_OPERATOR`. Ниже три способа получить тот же результат.

### Способ 1: `$in` — несколько значений одного поля

Когда нужно «поле равно A **или** B **или** C», используйте оператор `$in` из таблицы выше:

```json
{
  "filter": {
    "stageId": { "$in": ["NEW", "WON"] }
  }
}
```

Найдёт сделки в стадии NEW или WON. Оператор работает на любом поле, для которого имеет смысл точное сравнение: `assignedById`, `categoryId`, `id`, `sourceId` и так далее.

### Способ 2: Batch — несколько фильтров одним запросом

Когда условия ИЛИ затрагивают разные поля («сделки в стадии NEW **или** с суммой больше 100 000»), вынесите каждое условие в отдельный вызов внутри [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 }
    }
  ]
}
```

Ответ придёт в форме `data.results.by_stage` и `data.results.by_amount` — два независимых массива, которые клиент объединяет самостоятельно.

### Способ 3: параллельные запросы + клиентская склейка

Когда нужен единый список без дубликатов, выполните вызовы параллельно и объедините результаты по `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()]
```

`Map` по `id` устраняет повторы, если запись попадает под оба условия.

### Чего делать нельзя

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

Ответ:

```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."
  }
}
```

Аналогично попытка передать `$or` возвращает `400` с подсказкой использовать Batch API.

## Смешивание синтаксисов

Речь идёт о стилях записи операторов внутри одного фильтра. Две формы передачи самого параметра `filter` в GET-запросе — скобочную и JSON — смешивать нельзя, см. [Как передать фильтр в GET-запросе](#как-передать-фильтр-в-get-запросе).

Все три стиля можно комбинировать в одном фильтре:

```json
{
  "filter": {
    "amount": { "$gte": 50000 },
    "!stageId": "LOST",
    "createdAt": { ">=": "2026-01-01T00:00:00" }
  }
}
```

Найдёт записи с суммой от 50 000, стадией, отличной от LOST, созданные с начала 2026 года. Здесь все три синтаксиса используются вместе.

## Эндпоинт поиска

`POST /v1/{entity}/search` — полнофункциональный поиск:

```bash
curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
  "https://vibecode.bitrix24.tech/v1/deals/search" \
  -d '{
    "filter": {
      "stageId": { "$ne": "LOST" },
      "amount": { "$gte": 100000 }
    },
    "sort": { "amount": "desc" },
    "limit": 200
  }'
```

| Параметр | Тип | Описание |
|----------|-----|---------|
| `filter` | object | Условия фильтрации |
| `sort` | string, object или array | Сортировка. Принимаются три формы: строка (`"id"` / `"-amount"` / `"id,-createdAt"`), объект (`{ id: "asc", amount: "desc" }` — ключи в порядке вставки) или массив (`["id", "-amount"]`). Вместо `asc`/`desc` принимаются `1`/`-1`. Некорректный тип возвращает `400 INVALID_SORT_TYPE`, неизвестное направление — `INVALID_SORT_DIRECTION`. |
| `limit` | number | Количество записей (по умолчанию 50, максимум 5000) |
| `offset` | number | Пропустить N записей |
| `autoWindow` | boolean | `false` — отключить разбивку по дате |

## Фильтр передан не объектом

`filter` — это объект условий. Строка, число, булево значение или массив вместо него
отклоняются с кодом `INVALID_FILTER_SHAPE`, и в сообщении сказано, что именно пришло.

```
POST /v1/tasks/search   { "filter": [{ "responsibleId": 1 }] }   → 400 INVALID_FILTER_SHAPE
POST /v1/tasks/search   { "filter": { "responsibleId": 1 } }     → 200
```

В строке запроса действует другое правило: условия пишутся скобочной формой
(`?filter[responsibleId]=1`), а `filter`, закодированный в JSON одной строкой,
разбирается и применяется — значение, которое не является ни той, ни другой формой,
отклоняется с кодом `INVALID_FILTER`.

Раньше такие значения молча терялись: Битрикс24 получал вызов вообще без отбора и отвечал
`200` со всей коллекцией — то же самое, что и с неизвестным именем поля, только на уровне
формы, а не имени.

## Неизвестное имя поля в фильтре

Имя поля, которого нет у сущности, отклоняется до вызова Битрикс24 — ответ `400` с кодом
`UNKNOWN_FILTER_FIELD` и списком доступных имён в сообщении. **Сверяться нужно с этим списком,
а не с `GET /v1/{entity}/fields`:** тот отвечает на другой вопрос — какие поля у сущности
есть, — и на части сущностей он шире, потому что дополняет схему живым ответом Битрикс24, а
фильтровать по такому полю нельзя. Сверх перечисленного принимаются пользовательские поля, а у сущностей, где он объявлен, — ключ `id`.

Служебные имена языка — `__proto__`, `constructor`, `prototype`, `toString` и прочие служебные
имена объекта — отклоняются тем же кодом
`400 UNKNOWN_FILTER_FIELD`, как любое другое неизвестное поле. Это важно, когда имя поля фильтра
собирается из пользовательского ввода: такой ключ не проходит и выборка не остаётся
неотфильтрованной. Правило действует на всех сущностях и на агрегации.

Пользовательское поле указывается тем же именем, под которым оно стоит в схеме сущности:
`ufCrmProjectCode` у сделок, `UF_CRM_1698325419` у реквизитов. На другое написание того же поля
у сделок, контактов и элементов смарт-процессов приходит `400 UNKNOWN_FILTER_FIELD`, а у
остальных сущностей имя отбрасывается: условие не применяется и приходит вся коллекция. Формат
имён и значений — [Пользовательские поля (UF)](/docs/entity-api#пользовательские-поля-uf).

Исключение — реквизиты: там принимаются оба написания одного поля, `UF_CRM_1698325419` и
`ufCrm_1698325419`, и отбор применяется одинаково. Раньше второе написание отбрасывалось.
Написание с подчёркиванием перед буквой (`ufCrm_taxId`) остаётся неизвестным именем: Битрикс24
такого имени не даёт, вывести из него настоящее нельзя, и условие по-прежнему теряется.

Проверка идёт по имени, а не по значению: операторы, диапазоны, `$in`/`$nin` и логика И
работают без изменений. У смарт-процессов сверх перечисленного принимаются динамические связи
`parentId<N>`.

**Почему это важно.** Битрикс24 неизвестный ключ фильтра не отклоняет, а молча
выбрасывает и отвечает `200` со ВСЕЙ коллекцией. То есть опечатка в имени поля выглядела как
успешный запрос с неправдоподобно большим результатом — а не как ошибка. Отказ до вызова
превращает эту ситуацию в явную.

**Где проверка ещё НЕ включена.** У части сущностей схема полей заведомо уже настоящего
контракта Битрикс24, поэтому включать проверку нельзя — она отклонила бы работающее поле. Там
поведение прежнее: неизвестное имя уходит в Битрикс24 и молча теряется. Узнать, включена ли
проверка у конкретной сущности, можно одним запросом: отправьте фильтр по заведомо
несуществующему имени и посмотрите на код ответа.

**Отдельный случай — метод вообще без фильтра.** У нескольких сущностей метод Битрикс24 не
принимает фильтр ни в каком виде (он читает только именованные аргументы). Там отклоняется
любой ключ фильтра, с кодом `UNSUPPORTED_FILTER`. В сообщении перечислены параметры, которые
метод принимает.

## Коды ошибок

| HTTP | Код | Условие |
|------|-----|---------|
| 400 | `INVALID_FILTER` | Значение `filter` не является ни скобочной записью, ни JSON-объектом — либо в одном запросе смешаны обе формы (см. [Как передать фильтр в GET-запросе](#как-передать-фильтр-в-get-запросе)) |
| 400 | `INVALID_FILTER_OPERATOR` | Неизвестный оператор в значении поля или попытка передать `LOGIC` / `$or` |
| 400 | `INVALID_FILTER_SHAPE` | `filter` в теле запроса передан не объектом — строкой, числом или массивом |
| 400 | `INVALID_FILTER_FIELD` | Имя поля начинается с родного префикса Битрикс24 `@` (IN) или `!@` (NOT IN) — используйте операторы `$in` / `$nin` |
| 400 | `UNKNOWN_FILTER_FIELD` | Поле отсутствует у сущности — см. [Неизвестное имя поля в фильтре](#неизвестное-имя-поля-в-фильтре) |
| 400 | `UNSUPPORTED_FILTER` | Метод Битрикс24 у этой сущности не принимает фильтр. В сообщении перечислены принимаемые параметры |
| 400 | `INVALID_SORT_TYPE` | `sort` не строка, не объект и не массив |
| 400 | `INVALID_SORT_DIRECTION` | Направление сортировки не `asc` / `desc` / `1` / `-1` |
| 400 | `UNSTABLE_OFFSET_PAGINATION` | `offset > 0` при широком диапазоне дат (см. [Постраничный вывод](#постраничный-вывод)) |
| — | `WINDOWED_SEARCH_FAILED` | Больше не возвращается: при полном отказе авто-окон возвращается реальный код Битрикс24 — `UNKNOWN_FILTER_FIELD` / `INVALID_PARAMS` / `BITRIX_ACCESS_DENIED` / `RATE_LIMITED` / `BITRIX_UNAVAILABLE` / `BITRIX_TIMEOUT` (503) |

Полный список общих ошибок API — [Коды ошибок](./errors.md).

## Примеры по сущностям

### Сделки — по стадии и сумме

```json
{
  "filter": {
    "stageId": "NEW",
    "amount": { "$gte": 100000 }
  },
  "sort": { "createdAt": "desc" }
}
```

Найдёт сделки в стадии NEW с суммой от 100 000, отсортированные по дате создания (новые первыми).

### Контакты — по телефону

Телефон фильтром не ищут: он сравнивается со всей сохранённой строкой и только по первому номеру записи — см. [Фильтр по телефону и почте](#фильтр-по-телефону-и-почте). Номер ищут отдельным эндпоинтом `POST /v1/duplicates/find` — [Поиск дубликатов](./duplicates.md):

```bash
curl -X POST https://vibecode.bitrix24.tech/v1/duplicates/find \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "phone",
    "values": ["+79161234567"],
    "entityType": "contact"
  }'
```

Вернёт ID контактов с этим номером в любом его написании.

### Задачи — незакрытые

```json
{
  "filter": {
    "status": { "$ne": 5 }
  }
}
```

Найдёт все задачи со статусом, отличным от 5 (завершена). Статусы: 2 = ждёт выполнения, 3 = выполняется, 4 = ожидает контроля, 5 = завершена, 6 = отложена.

### Лиды — за период

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

Найдёт лиды, созданные в первом квартале 2026 года.

### Календарные события

```json
{
  "filter": {
    "dateFrom": { "$gte": "2026-04-01T00:00:00" }
  }
}
```

Найдёт события с датой начала от 1 апреля 2026. Для `calendar-events` обязательны параметры `type` и `ownerId` в URL: `/v1/calendar-events?type=user&ownerId=1`.

## Фильтрация в Batch

Фильтры работают и в [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" } }
      }
    }
  ]
}
```

Одним запросом получит список сделок в стадии NEW и найдёт контакты с адресом в домене `example.com`.

## Постраничный вывод

Три готовых способа — выбирайте по задаче. Для отдельного ответа границей цикла служит `meta.hasMore`, а не арифметика от `meta.total`: признак «есть ещё» выводится из полноты страницы. Само поле `meta.total` необязательное — его может не быть, если количество не заказывалось. Ни один способ не создаёт неизменяемый снимок коллекции и не обещает полный обход при изменениях данных или прав доступа.

### Получить весь результат сразу

Подходит для отчётов и выгрузок, когда под фильтр попадает не больше 5000 записей. Укажите `limit` до 5000 — сервис вернёт весь подходящий результат одним ответом. Коллекция в десятки тысяч записей читается следующим способом, курсором — [Выгрузка большой коллекции](./optimization.md#выгрузка-большой-коллекции).

Одного ответа хватает не всегда. Когда поиск разбивает диапазон дат на окна, выдача упирается либо в потолок в 5000 записей, либо в размер одного чтения окна. Признак этого приходит в `meta.warnings` кодом `WINDOW_TRUNCATED` — разбор в [неполной выдаче](./optimization.md#неполная-выдача).

```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 — все записи; meta.hasMore говорит, осталось ли что-то за пределами limit
```

### Идти по курсору `nextAfterId`

Этот способ применим к методам, ответы которых содержат `meta.nextAfterId`, и является основным для больших обходов. Отсортируйте по `id` по возрастанию, отключите точный подсчёт через `withTotal: false` и передавайте `meta.nextAfterId` предыдущего ответа обратно в фильтр `>id`. Запрашивайте через `select` только нужные поля и обязательно включайте `id`. Курсор не зависит от смещения и не дорожает к концу коллекции. Каждый вызов читает одну короткую страницу, а прерванный обход продолжается с последнего `meta.nextAfterId`, не начиная сначала.

Курсор доступен у сделок, лидов, контактов, компаний, предложений и элементов смарт-процессов. У остальных сущностей `meta.nextAfterId` в ответе нет — для них подходят два других способа.

```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('В ответе нет meta.nextAfterId при meta.hasMore=true')
  }
  after = meta.nextAfterId
}
```

Внутренний запрос следует рекомендованной схеме Битрикс24: `start=-1`, `order=ID ASC`, фильтр `ID` больше последнего полученного идентификатора. Клиент не передаёт `start` в тело запроса к этому эндпоинту — у подкоманд пакета он принимается, см. [Пакетные запросы](./batch.md). Вайбкод применяет его только к методам, для которых подтверждена совместная работа фильтра по `id` и `start=-1`. Для остальных методов сервис сохраняет корректность вызова и может не применить режим без подсчёта.

`withTotal: false` убирает `meta.total` и, в поддерживаемом режиме, отдельный `COUNT`. Не обходите коллекцию ради подсчёта. Если точная цифра нужна, сначала прочитайте `operations.search.paginationStability.counting` сущности в `GET /v1/guide`. Когда указание содержит путь агрегации, используйте функцию `count`. Когда указание есть, но пути нет, дешёвого точного подсчёта нет — читайте `meta.total`, только когда поле пришло, а обход ограничивайте по `meta.hasMore`. Если весь блок `counting` отсутствует вместе с общей операцией поиска, не угадывайте путь агрегации: перейдите по указателю на документацию сущности или домена из того же руководства и используйте только явно описанную операцию счёта.

Обход предполагает, что записи не удаляются, а права доступа не меняются до его завершения. Если любое из условий нарушено, часть записей может быть пропущена. API Вайбкод не обещает полный обход как инвариант клиента.

### Идти по страницам вручную

Подходит, когда нужно обрабатывать записи порциями (например, импорт по 50 штук с сохранением прогресса). Добавьте `autoWindow: false`, сортировку по `id` и увеличивайте `offset` на каждой итерации. `offset` считается по записям, а `meta.hasMore` учитывает уже пропущенное, поэтому цикл останавливается по нему.

```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        // защита от бесконечного цикла на пустой странице
  for (const deal of data) process(deal)
  offset += data.length
  if (!meta.hasMore) break
}
```

Пустая страница в середине обхода — отдельный случай. Когда на запрошенной позиции доступно меньше строк, чем пропускает `offset`, страница выходит пустой, хотя записи под фильтр ещё есть. Ответ тогда несёт `meta.warnings` с кодом `OFFSET_BEYOND_FETCHED_PAGE` и полем `field: "offset"`. Проверяйте этот код перед выходом из цикла: увеличьте `limit`, сузьте фильтр или перейдите на курсор.

### Чего делать нельзя

Не запускайте параллельные запросы с разным `offset` на одном фильтре — сервис вернёт `400 UNSTABLE_OFFSET_PAGINATION`. Выберите один из трёх способов выше.

Не обходите коллекцию постранично, чтобы узнать количество записей. Пролистать пять тысяч сделок ради цифры «4863» — это сто вызовов вместо одного. Если точная цифра нужна, используйте функцию `count` только по пути, явно указанному в `operations.search.paginationStability.counting`. Когда весь блок отсутствует вместе с общей операцией поиска, следуйте указателю на документацию сущности или домена из `GET /v1/guide` и не угадывайте путь.

## Смотрите также

- [Обзор API](./entity-api.md)
- [Поиск дубликатов](./duplicates.md)
- [Связанные данные](./includes.md)
- [Batch API](./batch.md)
- [Справочник API](./api-reference.md)
- [Коды ошибок](./errors.md)
