# Filtering and search

Three filtering syntaxes for API entities. All styles can be **mixed** in a single request.

> Filtering works in two places:
> - `GET /v1/{entity}?filter[field]=value` — URL parameter for lists
> - `POST /v1/{entity}/search` — request body `{ "filter": { ... } }`

**Quick jump:** [Filter by phone and email](#filter-by-phone-and-email) · [OR logic](#or-logic) · [Search endpoint](#search-endpoint) · [Pagination](#pagination) · [Error codes](#error-codes)

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

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_inn": { "$ne": null } } }
```

Finds only records where `ufCrm_inn` is filled in. The equivalent form is a comparison against an empty string `{ "$ne": "" }`. Works for both custom (UF) fields and standard ones (`post`, `phone`, `email`, etc.).

The opposite condition — "field is empty" — is set by an exact comparison with `null`:

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

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

> **Important for GET requests.** A URL cannot carry a real `null` — in the query string it turns into the text `"null"`, and the filter starts matching the literal string "null" (returns garbage). For "not empty" in GET, pass an **empty value**, not the word `null`:
>
> - ✅ `GET /v1/contacts?filter[ufCrm_inn][$ne]=` — field is filled in
> - ❌ `GET /v1/contacts?filter[ufCrm_inn][$ne]=null` — matches the text "null", which is NOT the same
>
> Or use `POST /v1/contacts/search` with the body `{ "filter": { "ufCrm_inn": { "$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 a work phone and a mobile phone recorded, the filter finds it by the work one — the number returned in the `phone` field of the response. Filtering by the mobile number returns an empty list.

### Find a record by phone number

Both limitations are handled by [Duplicate search](./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

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 — `INVALID_SORT_DIRECTION`. |
| `limit` | number | Number of records (default 50, maximum 5000) |
| `offset` | number | Skip N records |
| `autoWindow` | boolean | `false` — disable date windowing |

## Error codes

| HTTP | Code | Condition |
|------|-----|---------|
| 400 | `INVALID_FILTER_OPERATOR` | Unknown operator in a field value or an attempt to pass `LOGIC` / `$or` |
| 400 | `INVALID_FILTER_FIELD` | Field name starts with the Bitrix24-native prefix `@` (IN) or `!@` (NOT IN) — use the `$in` / `$nin` operators instead |
| 400 | `UNKNOWN_FILTER_FIELD` | Field is missing from the entity schema (for entities with a complete field schema) |
| 400 | `INVALID_SORT_TYPE` | `sort` is not a string, object, or array |
| 400 | `INVALID_SORT_DIRECTION` | Sort direction is not `asc` / `desc` / `1` / `-1` |
| 400 | `UNSTABLE_OFFSET_PAGINATION` | `offset > 0` with a wide date range (see [Pagination](#pagination)) |
| — | `WINDOWED_SEARCH_FAILED` | Retired: if auto-windowing fails completely, the real Bitrix24 code is returned — `UNKNOWN_FILTER_FIELD` / `INVALID_PARAMS` / `BITRIX_ACCESS_DENIED` / `RATE_LIMITED` / `BITRIX_UNAVAILABLE` / `BITRIX_TIMEOUT` (503) |

The full list of common API errors — [Error codes](./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 — for a period

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

Two ready-made approaches — pick the one that fits your task.

### Get the whole result at once

Suitable for reports, exports, and any case where you need all records. Set `limit` to any value up to 5000 — the service returns the entire matching result in a single response.

```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.total — total count of matching records
```

### Page through manually

Suitable when you need to process records in batches (for example, importing 50 at a time while saving progress). Add `autoWindow: false`, sort by `id`, and increase `offset` on each iteration. `offset` counts records, and `meta.hasMore` accounts for what you already skipped, so use it to stop the loop.

```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 endless loop on an empty page
  for (const deal of data) process(deal)
  offset += data.length
  if (!meta.hasMore) break
}
```

### What not to do

Do not run parallel requests with different `offset` on the same filter — the service returns `400 UNSTABLE_OFFSET_PAGINATION`. Pick one of the two approaches above.

## See also

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