For AI agents: markdown of this page — /docs-content-en/filtering.md documentation index — /llms.txt

Filtering and search

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

Filtering works in two places:

  • GET /v1/{entity}?filter[field]=value — URL parameter for lists
  • POST /v1/{entity}/search — request body { "filter": { ... } }

Quick jump: Filter by phone and email · OR logic · Search endpoint · Pagination · Error codes

Syntax 1: operators with a `$` sign

Operators prefixed with $ inside the field object.

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

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

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:

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

Terminal
curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
  "https://vibecode.bitrix24.com/v1/deals/search" \
  -d '{
    "filter": {
      "stageId": { "$ne": "LOST" },
      "amount": { "$gte": 100000 }
    },
    "sort": { "amount": "desc" },
    "limit": 200
  }'
Parameter Type Description
filter object Filtering conditions
sort string, object or array Sorting. Three forms are accepted: string ("id" / "-amount" / "id,-createdAt"), object ({ id: "asc", amount: "desc" } — keys in insertion order) or array (["id", "-amount"]). Instead of asc/desc, 1/-1 are accepted. An invalid type returns 400 INVALID_SORT_TYPE, an unknown direction — INVALID_SORT_DIRECTION.
limit number Number of records (default 50, maximum 5000)
offset number Skip N records
autoWindow boolean false — disable date windowing

Error codes

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

The full list of common API errors — Error codes.

Examples by entity

Deals — by stage and amount

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. A number is looked up with the separate POST /v1/duplicates/find endpoint — Duplicate search:

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

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.

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

JavaScript
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