
# Limits and optimization

Vibecode automatically merges calls and paginates result sets server-side. This article describes the built-in mechanisms and helps you pick the right endpoint for the job.

**Base URL:** `https://vibecode.bitrix24.com/v1` | **Authorization:** `X-Api-Key`

[Auto-pagination](#auto-pagination-in-list) | [Paging and counts](#paging-and-record-counts) | [Exporting a large collection](#exporting-a-large-collection) | [Batch calls](#batch-calls-across-multiple-entities) | [Date-windowed search](#search-with-date-windowing) | [Aggregation](#aggregation-instead-of-fetching-records) | [Account queue](#account-queue) | [Client-side timeout](#client-side-timeout) | [Caching](#caching) | [Summary limits](#summary-limits)

## Auto-pagination in `list`

The `limit` parameter in `GET /v1/{entity}` accepts values up to 5000. When `limit > 50`, Vibecode splits the result set into internal pages of 50 records and assembles them into a single response:

```
GET /v1/deals?limit=500&filter[stageId]=NEW
```

Returns up to 500 records plus the `meta.hasMore` field (and `meta.total`, if a count was requested — see below). If more than 5000 records match the filter, the first 5000 are included and `meta.hasMore` comes back `true` — for the remainder, either narrow the filter or use `POST /v1/{entity}/search`.

This mode is built for result sets of up to 5000 records. A collection of tens of thousands of records is read with a cursor — [Exporting a large collection](#exporting-a-large-collection).

## Paging and record counts

These are two different tasks, solved by different fields. Conflating them is the most expensive mistake you can make on lists.

**Page by `meta.hasMore`.** The signal is derived from page fullness: a full page means there may be more, a short page means the collection has ended. When the collection size is an exact multiple of `limit`, the last step returns an empty list — that is the normal end-of-list signal. The field describes the state of one response, not an immutable snapshot of the collection.

When a method response includes `meta.nextAfterId`, use the following approach for large scans: sort strictly by `id` ascending, disable the exact count with `withTotal=false`, and use `select` to request only the required fields together with `id`. The `meta.nextAfterId` field carries the identifier of the last returned record. Pass it back as `filter[>id]` and the next page starts after it. Such a scan does not depend on an offset and does not get more expensive towards the end of the collection:

```
GET /v1/deals?order[id]=asc&limit=50&withTotal=false&select=id,title
GET /v1/deals?order[id]=asc&limit=50&withTotal=false&select=id,title&filter[>id]=<meta.nextAfterId of the previous response>
```

The cursor is available for deals, leads, contacts, companies, quotes, and smart process items. Other entities carry no `meta.nextAfterId` in the response: page them with `offset` and narrow the result set with a filter. A ready-made pagination loop in JavaScript — [Pagination](./filtering.md#pagination).

Internally, this mode follows the recommended Bitrix24 pattern: `start=-1`, `order=ID ASC`, and an `ID` filter greater than the last received identifier. The client does not pass `start` to the Vibecode API. 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.

This scan assumes that records are not deleted and access rights do not change before it finishes. If either condition is violated, some records may be skipped. The Vibecode API does not guarantee a complete traversal as a client invariant.

**Take the count from an available aggregation operation.** Counting a collection is disproportionately expensive for Bitrix24 — markedly more so than returning a page. 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, ask for the number in a single [aggregation](#aggregation-instead-of-fetching-records) call with 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 pointer to the entity or domain documentation in the same guide and use only an explicitly documented count operation. If you do not need the number, turn the count off with `withTotal=false`. On `POST /v1/{entity}/search` it is the body field of the same name. In the supported mode, `meta.total` is absent and no separate `COUNT` runs. **Important:** As an optimization, `withTotal=false` only works at a `limit` of at most 50 — at that size the count really is not requested. Above 50 the platform needs the count to plan the walk, so the parameter removes the number and not the load, and on top of that it discards the exact count a short first page would have handed over for free.

If `withTotal` is not passed, the value comes from the `totalDefault` setting on the API key, and failing that from the platform default. The value currently in effect is shown in the `totalDefault` block of [GET /v1/me](./keys-auth/me.md).

A disabled count does not always mean no number: on a call with `offset = 0` where the page came back shorter than the requested `limit`, the exact count is known from the page itself and arrives for free. An explicit `withTotal=false` in the request removes that too — for the full table of when the field is present, see [Paging and record counts](./entity-api.md#paging-and-record-counts).

The converse holds as well: the presence of `meta.total` does not mean it was paid for with a count. On a multi-page call (`limit` above 50) the count runs only when it cannot be avoided — the shape of the response is unchanged, and `meta.total` arrives exactly as it always has. Nothing is required of you here.

**Do not emulate a counter by walking the collection.** Paging through five thousand records just to learn there are 4863 of them is a hundred calls instead of one, and for a Bitrix24 account it is the worst load you can generate. When aggregation is available, one `aggregate` with `count` gives the same number in a single call.

`meta.total` is an informational field. It may lag behind the current state of the collection by up to a minute, so the number of returned rows can occasionally exceed it. Completeness of the result does not depend on `meta.total`.

## Exporting a large collection

A wide result set in a single call is built for collections that fit within 5000 records. Once tens of thousands of records match the filter, the task changes shape: such a collection is read with a cursor rather than with one wide `limit`.

The cursor is described above, in [Paging and record counts](#paging-and-record-counts): sort strictly by `id` ascending, set `withTotal=false`, list the fields you need in `select` together with the mandatory `id`, and pass `meta.nextAfterId` from the previous response back as `filter[>id]`. Every call reads one short page, so the walk does not get more expensive towards the end of the collection, and an interrupted walk resumes from the last cursor instead of starting over. A ready-made JavaScript loop — [Pagination](./filtering.md#pagination).

The sign that a result set did not fit into the time allowed for it is a `429` with the code [`OPERATION_TIME_LIMIT`](/docs/errors/limits#operation_time_limit-429) or `RATE_LIMITED`. Both mean a pause for the calling key, and repeating the same wide request leads to the same outcome — switch to the cursor.

### Filtering by a custom field

Filtering by the value of a custom field is done on the Bitrix24 side: pass the condition in `filter` under the same name the field carries in the entity schema — the spelling is covered in [Filtering](./filtering.md). This is the first choice, because you then read the matches rather than the whole collection.

If such a request runs into the time limit on a large collection, put the field in `select` and pick the records you need on your side. A custom field is passed in `select` alongside the standard ones, and the cursor keeps working.

```
GET /v1/companies?order[id]=asc&limit=50&withTotal=false&select=id,title,ufCrm_1698325419&filter[>id]=<meta.nextAfterId of the previous response>
```

The response carries the field value for every record, and the filtering then happens without another call to Bitrix24. This path reads the whole collection, so it is the fallback. The custom field names on your Bitrix24 account are listed by `GET /v1/{entity}/fields`.

## Batch calls across multiple entities

`POST /v1/batch` merges up to 50 operations across different entities into a single HTTP request. Each call is identified by its own `id`. A failure in one does not cancel the others.

Suited to dashboards and summary pages where a single load needs data from different places:

```json
{
  "calls": [
    { "id": "deals", "entity": "deals", "action": "list", "params": { "filter": { "stageId": "NEW" }, "limit": 50 } },
    { "id": "tasks", "entity": "tasks", "action": "list", "params": { "filter": { "responsibleId": 1 }, "limit": 20 } },
    { "id": "user", "entity": "users", "action": "get", "entityId": 1 }
  ]
}
```

Full specification — [Batch calls](/docs/batch).

## Batch operations on a single entity

`POST /v1/{entity}/batch` bulk-creates, updates, or deletes up to 500 records of a single entity. Internally Vibecode splits the request into batches of 50 items:

```bash
curl -X POST https://vibecode.bitrix24.com/v1/deals/batch \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "update",
    "items": [
      { "id": 575, "stageId": "WON" },
      { "id": 741, "stageId": "WON" }
    ]
  }'
```

Actions: `create`, `update`, `delete`, `list`, `get`, `fields`. For `delete`, pass an `ids` array. For `create` and `update`, pass `items`. For `list` / `get` / `fields`, pass `calls`.

## Bulk scanning with on-demand loading of related data

When you need to read thousands of records of one entity and pull in related data from other entities, the flow looks like this:

1. Fetch the root list in a single call — Vibecode paginates server-side automatically:

   ```
   GET /v1/deals?limit=5000&select=id,title,companyId,assignedById
   ```

2. Collect the `id` values of the related entities and load them in batches of 50 via `POST /v1/batch`:

   ```json
   {
     "calls": [
       { "id": "company-15", "entity": "companies", "action": "get", "entityId": 15 },
       { "id": "company-22", "entity": "companies", "action": "get", "entityId": 22 },
       { "id": "user-1",     "entity": "users",     "action": "get", "entityId": 1 }
     ]
   }
   ```

   One HTTP request carries up to 50 related records. The loop repeats for the next batch of `id` values.

In this scenario the root fetch takes one network request (Vibecode pages through the records in steps of 50 internally), and the associations are loaded at a pace of one HTTP request per 50 items instead of a request per item.

## Search with date windowing

`POST /v1/{entity}/search` is designed for large result sets. If the filter contains a date condition with a range greater than 14 days, Vibecode automatically splits the request into 7-day windows and processes them in parallel:

```json
{
  "filter": { "createdAt": { "$gte": "2026-01-01", "$lte": "2026-04-30" } },
  "select": ["id", "title", "stageId"],
  "limit": 5000
}
```

On a partial window failure — when some windows returned data and the response comes back with HTTP 200 — the following fields appear in `meta`:

| Field | Description |
|------|---------|
| `meta.autoWindowed` | `true` when the request was split into date windows. |
| `meta.windowCount` | Number of windows the request was split into. |
| `meta.windowErrors` | Number of windows for which Bitrix24 returned an error. The remaining windows return their data. |
| `meta.windowErrorSample` | An object `{ code, message }` — the code and text of the first failed window, so you can see why data was lost. |
| `meta.batchWaves` | Number of parallel window-dispatch waves. Appears when batched window dispatch was used. |
| `meta.hasMore` | Whether there are records beyond `limit`. When more than 5000 records match, the remainder is left out of the result set — narrow the filter or the date range. |

If **all** windows fail, this meta block is absent — the real Bitrix24 error code is returned, as for a narrow range: `UNKNOWN_FILTER_FIELD`, `INVALID_PARAMS`, `BITRIX_ACCESS_DENIED`, `RATE_LIMITED`, `BITRIX_UNAVAILABLE`, or `BITRIX_TIMEOUT` (503). The separate `WINDOWED_SEARCH_FAILED` code is no longer returned.

Windowing is disabled with the `"autoWindow": false` flag in the request body — use it when you hit network timeouts or unstable results. The full list of `search` parameters is in each entity's documentation.

### Incomplete result

A result can also be incomplete when no window failed at all. The response then carries a `meta.warnings` array with an entry `{ "code": "WINDOW_TRUNCATED", "field": "...", "message": "..." }`. There are two causes, and the `message` text names the one that fired: either a single window held more records than one window fetch returns, or the window fetches together hit the 5000-record ceiling and the remaining windows were never sent. Both causes share one code — you can branch on it without parsing the text. The warning arrives for entities whose list method Bitrix24 serves page by page.

The `field` value names the range field by its original Bitrix24 name, not by the one you sent: for entities that carry their own field names these are different strings. The mapping between the two is in the entity's field reference, for example [`GET /v1/deals/fields`](./entities/deals/fields.md). Do not compare `field` against your own filter keys directly.

Below is the `meta` block of a search over deals across two years. The `data` array in such a response is populated and holds the records that were collected:

```json
{
  "total": 5000,
  "hasMore": false,
  "autoWindowed": true,
  "windowCount": 105,
  "batchWaves": 2,
  "durationMs": 41230,
  "warnings": [
    {
      "code": "WINDOW_TRUNCATED",
      "field": "createdTime",
      "message": "This result is incomplete: the range filter on \"createdTime\" reached the 5000-row ceiling of a windowed search, so the remaining time windows were never requested. Narrow the date range or add filters — paging is not available on a windowed search."
    }
  ]
}
```

Check `meta.warnings` before treating a result as complete. Neither `meta.hasMore` nor `meta.total` serves that purpose: in the example above both say the result set has ended — they describe what was collected, not what was left beyond the boundary. Paging does not help either: while date windowing is active, an offset above zero is rejected with `400 UNSTABLE_OFFSET_PAGINATION`, so you cannot read the remainder with a next-page request. Narrow the date range or add filters so the search stops hitting the ceiling. A search over a narrow range that is not split into windows is unaffected by this warning.

## Aggregation instead of fetching records

When you need sums, minimums, maximums, or averages, first make sure `GET /v1/guide` exposes `operations.aggregate` for the entity. Then `POST /v1/{entity}/aggregate` returns the result without a separate record fetch:

```json
{
  "aggregate": [
    { "field": "amount", "function": "sum" },
    { "field": "amount", "function": "avg" }
  ],
  "filter": { "stageId": "WON" },
  "groupBy": "assignedById"
}
```

The response contains `data.count`, `data.aggregates` and (when `groupBy` is present) a `data.groups` array broken down by the grouping field. Without `groupBy` the response is limited to a single object of summary values. For the `count` function the result comes back in a single call regardless of result-set size. The `sum` / `avg` / `min` / `max` functions load records page by page up to 5000. The response carries `meta.truncated: true` when fewer records were read than `data.count` promised — including when more than 5000 matched the filter — or when the slice was cut short by a sub-page error. The size of the gap is reported in `data.meta.recordsShortfall`, the interrupted slice in `data.meta.pageErrorSample`. The ceiling is not the only reason for this marker, so testing its value is safer than comparing `data.count` against 5000.

The `count` function follows the separate [paging and record counts](#paging-and-record-counts) rules. Use it only at the path explicitly named by `operations.search.paginationStability.counting`. The presence of `operations.aggregate` for other functions does not prove that an exact count is available, or vice versa. The example below applies only to an explicitly named path. Do not walk the collection to calculate a count:

```json
{
  "aggregate": [ { "field": "*", "function": "count" } ],
  "filter": { "stageId": "NEW" }
}
```

## Account queue

Each Bitrix24 account has its own queue to the Vibecode API: a limited number of requests run concurrently, the rest wait for a slot. If a request hangs in the queue longer than 80 seconds, `429 QUEUE_TIMEOUT` is returned with a `userMessage` and a `hint`.

What reduces the load on the queue:

- Merge disparate calls via `POST /v1/batch` — one HTTP request instead of several.
- For bulk CRUD on a single entity — `POST /v1/{entity}/batch` (up to 500 records per request).
- For large result sets — `GET /v1/{entity}?limit=...` (Vibecode paginates server-side) or `POST /v1/{entity}/search` (date windowing).
- When you need aggregates, not records, and `GET /v1/guide` exposes `operations.aggregate` — `POST /v1/{entity}/aggregate`. For an exact count, use only the path from `operations.search.paginationStability.counting`.

**Retry on queue overload.** Under load the queue returns two different codes, and both mean "retry later":

- `429 QUEUE_OVERFLOW` — the queue is full, the request is rejected immediately (within milliseconds). The `Retry-After` header carries the recommended pause in seconds.
- `429 QUEUE_TIMEOUT` — the request waited for a slot longer than 80 seconds. The request was not sent to Bitrix24 — safe to retry. The `error.retryAfter` body field carries the recommended pause.

Analytics widgets that fire a burst of `/search` calls must retry with exponential backoff and random jitter, respecting `Retry-After`, rather than retrying instantly in a loop — that makes the overload worse. Lower the concurrency: run the requests sequentially or combine them into `POST /v1/batch`.

```javascript
async function callWithBackoff(url, options, maxRetries = 4) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, options)
    if (res.status !== 429) return res
    if (attempt >= maxRetries) return res

    // Both codes (QUEUE_OVERFLOW, QUEUE_TIMEOUT) carry the pause in the Retry-After header;
    // error.retryAfter in the body duplicates the same value
    const headerWait = Number(res.headers.get('Retry-After'))
    const bodyWait = Number((await res.clone().json())?.error?.retryAfter) || 0
    const baseSec = headerWait || bodyWait || Math.min(2 ** attempt, 30)
    const jitterMs = Math.floor(Math.random() * 1000)
    await new Promise(r => setTimeout(r, baseSec * 1000 + jitterMs))
  }
}
```

**Pauses on a single method.** Besides the queue, two more pauses can reject a request. Both answer `429` with a `Retry-After` header and lift automatically, so the retry loop above covers them too. The `error.scope` field says who the pause applies to:

- `429 OPERATION_TIME_LIMIT`, `scope: "apiKey"` — Bitrix24 has paused this method for your key for about 5 minutes: the method used up its operating-time budget on the account. Other methods and other keys of the account keep working.
- `429 TIMEOUT_QUARANTINE`, `scope: "portal"` — when the mechanism is enabled for the account, three consecutive method timeouts pause the "account + method" pair on the Vibecode side. The pause applies to every key of the account. Do not shorten the retry interval: once every 5 minutes one call is let through as a recovery probe, and frequent retries take that slot — the method stays closed longer.

These pauses are measured in minutes, not in the seconds a queue refusal takes. Do not wait them out inside a user request — move the retry to a background job.

Both codes are described in [Errors](/docs/errors).

## Client-side timeout

A response does not arrive instantly: the request first waits for a slot in the account queue, then runs in Bitrix24. Both phases have an upper limit, as does the time the platform holds the connection, and the client-side timeout has to exceed the sum of the first two.

| Phase | Limit | What arrives when it expires |
|-------|-------|------------------------------|
| Waiting for a slot in the account queue | 80 seconds | `429 QUEUE_TIMEOUT` — the request never reached Bitrix24, a retry is safe |
| A single call to Bitrix24 | 60 seconds | `503 BITRIX_TIMEOUT` |
| The platform holding the connection | 660 seconds | the connection is closed |

Hence the working values, and they differ for a single page and for a large result set. A request with `limit` up to 50 makes one call to Bitrix24 and is covered by a timeout of at least 150 seconds — the sum of the first two phases with headroom. A request with `limit > 50` reads the records in several sequential calls of 50, each bounded by its own 60 seconds, so the only ceiling above it is how long the connection is held: set the timeout above 660 seconds, at least 690, or page through 50 records at a time. The request waits in the queue only once — for the request as a whole, not once per page.

What helps instead of one long request:

- Read pages of 50 records and walk the [cursor](#paging-and-record-counts). Every call is short, and an interrupted walk resumes from the last `meta.nextAfterId` instead of starting over.
- Set the timeouts separately — one for establishing the connection, one for waiting on data. In Python: `requests.get(url, headers=headers, timeout=(10, 150))` — 10 seconds to connect, 150 to wait for data from the server. For a result set with `limit > 50` raise the second value to 690.
- For result sets over a wide date range — [`POST /v1/{entity}/search`](#search-with-date-windowing): the range is split into windows that run in parallel waves.

## Loading messages from multiple dialogs

`POST /v1/chats/messages/bulk` returns messages from no more than 50 dialogs in a single response and accepts `lastId` / `firstId` cursors and a `limit` per dialog:

```json
{
  "dialogs": [
    { "dialogId": "chat253", "limit": 20 },
    { "dialogId": "chat741", "lastId": 9357, "limit": 50 }
  ]
}
```

Scope: `im`. Response format — `{ results, errors, summary }`, the same as `/v1/batch`.

## Caching

Some responses are served from a cache, so repeat reads do not add load to Bitrix24 or the account queue. The cache is transparent — the response body is identical to the uncached one, and the `X-Cache` header shows where the response came from.

### Response cache for `/v1/users`, `/v1/statuses`, and `/v1/{entity}/fields`

Responses from `GET /v1/users`, `GET /v1/statuses`, and `GET /v1/{entity}/fields` are cached server-side. Users are cached for 60 seconds. CRM directories and field schemas are cached for 5 minutes. This speeds up dashboards that request these endpoints on every load.

The `GET /v1/users` cache is bound to the personal key `vibe_api_...`. The `GET /v1/statuses` cache is bound to the Bitrix24 account: personal keys from the same account use one cache entry because CRM stages and directories are shared across the account.

The `GET /v1/{entity}/fields` cache is bound to the Bitrix24 account, authorization key, entity, path parameters, request parameters, and response language. Complete responses are stored for 5 minutes. Responses with the `fields_partial` warning are not stored, so the next request can fetch the complete field schema.

For an OAuth application authorization key `vibe_app_...`, the cache is not used — different users have different access to account data.

A write through the API clears the matching cache immediately. `POST /v1/users`, `PATCH /v1/users/:id`, and similar operations clear the users and directory caches. `POST` / `PATCH` / `DELETE` on `/v1/userfields/:entity` and `/v1/items/:entityTypeId/userfields` clear the `GET /v1/{entity}/fields` cache for that entity or smart process. An edit made directly in the Bitrix24 interface is invisible to the cache, so such changes may not appear until the cache entry expires: up to 60 seconds for users and up to 5 minutes for directories and field schemas.

To bypass the cache and get guaranteed-fresh data, add the `Cache-Control: no-cache` header. For `GET /v1/{entity}/fields`, the `refresh=true` parameter also works:

```bash
curl -H "X-Api-Key: YOUR_API_KEY" \
  -H "Cache-Control: no-cache" \
  https://vibecode.bitrix24.com/v1/users
```

The `X-Cache` header in the response shows how the request was handled:

| Value | Meaning |
|----------|--------------|
| `HIT` | Response served from cache |
| `MISS` | Response fetched from Bitrix24 and stored in the cache |
| `COALESCED` | The request joined an in-flight fetch for the same data |
| `BYPASS` | The cache was not used — an OAuth application authorization key, the `Cache-Control: no-cache` header, or `refresh=true` for `/fields`. The reason is given in the `X-Cache-Bypass-Reason` header |

### HTTP cache for discovery endpoints

`GET /v1/openapi.json` and `GET /v1/guide` return large documents that a client need not re-download on every startup. Both responses carry standard HTTP cache headers:

- `/v1/openapi.json` — `Cache-Control: public, max-age=300`. The specification is identical for every client, so any cache may store it.
- `/v1/guide` — `Cache-Control: private, max-age=300`. The response content depends on the key's scopes, so a shared cache must not store it.
- `ETag` — a content fingerprint that changes only when the document or the key's scope set changes.

Keep the `ETag` from the first response and send it in the `If-None-Match` header on repeat requests. If the content has not changed, the endpoint answers `304 Not Modified` with an empty body instead of re-sending the whole document:

```bash
# First call — full body and ETag (openapi.json needs no authorization)
curl -i https://vibecode.bitrix24.com/v1/openapi.json
# ... ETag: "a1b2c3d4e5f6a7b8"

# Repeat call — 304 Not Modified, no body transferred
curl -i -H 'If-None-Match: "a1b2c3d4e5f6a7b8"' \
  https://vibecode.bitrix24.com/v1/openapi.json
```

The `max-age=300` header also lets the client and any intermediate cache reuse the response for 5 minutes without contacting the server.

## Summary limits

| Scenario | Limit |
|----------|-------------|
| `GET /v1/{entity}` — `limit` | up to 5000 records. When `limit > 50`, Vibecode auto-paginates |
| `POST /v1/batch` — number of calls | up to 50 in one request |
| `POST /v1/{entity}/batch` — bulk CRUD | up to 500 records in one request |
| `POST /v1/{entity}/batch` — read actions (`list` / `get` / `fields`) | up to 50 calls in the `calls` array |
| `POST /v1/{entity}/search` — `limit` | up to 5000 records. A date range greater than 14 days is split into 7-day windows |
| `POST /v1/chats/messages/bulk` — dialogs | up to 50 in one request |
| Account queue | a limited number of concurrent requests, a wait of up to 80 seconds |
| Response cache for `/v1/users` / `/v1/statuses` / `/v1/{entity}/fields` | 60 seconds / 5 minutes / 5 minutes, bypass — `Cache-Control: no-cache` header, for `/fields` also `refresh=true` |

## See also

- [Batch](/docs/batch)
- [Filtering syntax](/docs/filtering)
- [Entity API](/docs/entity-api)
- [Error codes](/docs/errors)
- [Quick Start](/docs/quickstart)
