# API changes: July 14, 2026

[← Changelog](/docs/changelog) · [July 2026](/docs/changelog/2026-07)

### NEW-0714-1: OpenAPI spec: 3.1 validity, field semantics, and a scope slice

**Before**

The machine-readable spec [GET /v1/openapi.json](/docs/cli) carried the 3.0 `nullable` keyword (invalid under 3.1), left `{entityTypeId}` undeclared on batch/aggregate/fields/products, shipped no property descriptions/allowed-values/examples, described only `vibe_app_` keys, and served one monolith.

**After**

The spec is valid under OpenAPI 3.1: nullable fields use the union type `["<type>","null"]` and every path parameter is declared. Properties now carry `title`/`description`, allowed values (`x-enumValues` plus an in-description decode), and examples. Security schemes name all three key families (`vibe_api_`/`vibe_app_`/`vibe_live_`), every operation carries `x-required-scope`, and the root exposes an `x-scopes` catalog. Root `tags`/`externalDocs`, a `webhooks` block for bot events, and a multifield `anyOf` were added. `GET /v1/openapi.json?scope=<scope>` (e.g. `?scope=crm`) returns a single-scope slice so it fits an agent context window. Pointers to the spec were added to `/v1/me` and `/v1/guide`. A full port of per-operation curl examples is a separate follow-up.

### FIX-0714-2: Generic 5xx errors on AI endpoints now use the OpenAI-compatible envelope

[AI endpoints](/docs/ai) (`/v1/ai/*`, `/v1/models`, `/v1/chat/*`, `/v1/audio/*`) are documented with an OpenAI-compatible error format. Pool-exhaustion errors already followed this, but a generic (unexpected) `5xx` on these endpoints returned the plain V1 envelope instead.

**Before**

A generic `5xx` on an AI endpoint: `{"success": false, "error": {"code": "...", "message": "..."}}` — not the shape OpenAI SDK clients expect for these paths.

**After**

The same case now returns `{"error": {"message": "...", "type": "...", "code": "..."}}` — one envelope for every error on AI endpoints, including generic `5xx`.

**Impact on integrators**

Clients on the OpenAI SDK that already read `error.type`/`error.code` (the standard path for these endpoints) see no change. Code that expected top-level `success`/`error.code` specifically on a generic `5xx` from an AI endpoint should switch to `error.type`/`error.code`.

### FIX-0714-3: `versions` in the app source-history list is capped at 500 entries

`GET /v1/apps/:id/sources` returned the entire version history with no limit — for apps with very long histories this was an unbounded read.

**Before**

`data.versions` — the entire version list with no size limit; `data.totalVersions` always equaled `data.versions.length`.

**After**

`data.versions` holds at most the 500 most recent versions (by `savedAt`, descending). `data.totalVersions` and `data.totalSizeBytes` are still computed over the full set — aggregate accuracy doesn't depend on the cap.

**Impact on integrators**

For apps with up to 500 versions of history, behavior is unchanged. For apps with more than 500 versions, `data.versions.length` may now be smaller than `data.totalVersions` — code that relied on them being equal should use `data.totalVersions`/`data.totalSizeBytes` for aggregates and not treat `data.versions` as the complete list.

### FIX-0714-4: Telephony: `userId`/`duration` are now genuinely validated, not just checked for truthy

`POST /v1/calls/register`, `/v1/calls/:callId/show`, `/v1/calls/:callId/hide`, `/v1/calls/:callId/finish` accepted `userId` (and `duration` on `finish`) without checking its type or shape — any truthy value (for example the string `"abc"` or an object) was forwarded to Bitrix24 and failed there with an opaque upstream error.

**Before**

`{"userId": "abc"}` (or any other truthy value that wasn't a positive integer) passed validation and was sent to Bitrix24; the `Required: userId (number)` error only appeared for a fully empty/falsy value.

**After**

`userId` is accepted as a positive integer or a numeric string (`"42"`), otherwise a clean `400 MISSING_PARAMS` with the refined text `Required: userId (positive integer)` (plus `phoneNumber` for `register`). `duration` on `finish` follows the same rule — a non-negative number or a numeric string, otherwise `400`.

**Impact on integrators**

Correct calls (`userId` as a number or numeric string) are unchanged. Calls that previously "got through" with an invalid `userId`/`duration` (not a number, not a numeric string) now get an explicit `400` instead of an opaque Bitrix24-side error.

### FIX-0714-5: Creating a comment on a missing task — an explicit error instead of a false success

[POST /v1/tasks/:taskId/comments](/docs/entities/task-comments/create) and its batch counterpart [POST /v1/tasks/:taskId/comments/batch](/docs/entities/task-comments/comments-batch) (`action: create`) call Bitrix24 to create a comment on the legacy task card. If the task doesn't exist or isn't accessible to the key, Bitrix24 doesn't create the comment and returns no identifier.

**Before**

Both endpoints responded with success and an empty identifier — the single call as `201 {"success": true, "data": {"id": null}}`, the batch item as `{"success": true, "id": null}`. No comment was created, but the integrator couldn't distinguish this from the normal case.

**After**

The single call now returns `404 TASK_NOT_FOUND`. The batch variant marks the corresponding item as `{"success": false, "error": "TASK_NOT_FOUND"}` without cancelling the rest of the batch. A separate, unrelated case is unchanged: on the new task card the comment goes through chat, and if the system couldn't recover its id via a follow-up search, the response is still `success: true` with `id: null` — the comment was genuinely created in that case.

**Impact on integrators**

Code that checks `data.id` / `data[i].id` for `null` as an error signal keeps working unchanged and now gets a more precise error code. Code that relied on a silent success with `id: null` for an inaccessible task should handle `404` / `TASK_NOT_FOUND` explicitly.

### FIX-0714-6: Rate limit for `/v1/search`, `/v1/research`, `/v1/batch` is now per-account

**Before**

The limits (`/v1/search` 60/min, `/v1/research` 20/min, `/v1/batch` 30/min) were effectively keyed by source IP, not by account. An account using multiple API keys (or several accounts behind one shared egress IP) could exceed the documented cap, and the limit was bypassable by IP rotation.

**After**

The limit is now keyed **per Bitrix24 account**: all API keys of one account share a single bucket (60 / 20 / 30 requests per minute respectively). The documented per-tenant cap is now enforced correctly and cannot be bypassed by using more keys or rotating IPs.

**Integrator impact**

If your account spread `/v1/search` / `/v1/research` / `/v1/batch` traffic across several API keys, the effective ceiling is now the single account limit, not the sum across keys. On exceeding it you get `429` with a `Retry-After` header (as before).

### FIX-0714-7: Calendar: working section batch-delete, clean update errors, no leaked sync fields

**Before**

- `POST /v1/calendar-sections/batch` with `action: "delete"` had no channel for the `type`/`ownerId` that `calendar.section.delete` mandates — every item failed on both platforms.
- `PATCH /v1/calendar-sections/{id}` without `type`/`ownerId`/`name` forwarded to Bitrix24 and returned a raw `422` leaking the internal method name.
- `GET /v1/calendar-sections` returned the undocumented raw Bitrix24 fields `GAPI_CALENDAR_ID`, `CAL_DAV_CON`, `SYNC_TOKEN`, `PAGE_TOKEN`, `EXTERNAL_TYPE` (three of them sync tokens).
- `GET /v1/calendar-events` and `GET /v1/calendar-events/{id}` leaked the internal `attendeesEntityList` field — the schema tried to strip it but no-op'd on a key-casing mismatch.

**After**

- Section batch-delete reads `type`/`ownerId` from the body alongside `ids` and threads them into every delete command. A missing anchor is a clean `400 MISSING_REQUIRED_PARAMS` before the Bitrix24 call.
- Section partial-update requires the `type`/`ownerId`/`name` anchors (sections have no get-by-id to backfill) — a clean `400`, no raw `422`, no leaked method name.
- Both calendar read paths strip the listed internal/sync fields from the response.

**Affected endpoints:**

- [`POST /v1/calendar-sections/batch`](/docs/entities/calendar-sections/create)
- [`PATCH /v1/calendar-sections/{id}`](/docs/entities/calendar-sections/update)
- [`GET /v1/calendar-sections`](/docs/entities/calendar-sections/list)
- [`GET /v1/calendar-events`](/docs/entities/calendar-events/list)

### FIX-0714-8: PATCH catalog-product-properties works again (was an inescapable catch-22)

**Before**

Updating a product property was impossible under any body: `PATCH` without `iblockId` → `422` ("Required fields: iblockId" — B24 mandates it on every update), and `PATCH` **with** `iblockId` → `400 READONLY_FIELD` (create-only field). The entire UPDATE verb was dead — no field could be changed after create.

**After**

`iblockId` is now carried over automatically from the existing record (a pre-fetch, like `catalog-sections`), so `PATCH {name:"…"}` reaches B24 with the required `iblockId` and returns `200`. You still don't send `iblockId` in the body (and it is still rejected as read-only if you do) — the service supplies it.

**Integrator impact**

If your product-property `PATCH` always failed `422`/`400`, now send only the fields you're changing (`PATCH {name:"…"}`); `iblockId` is not required.

### FIX-0714-9: entityTypeId validation: junk forms → 400 instead of silent truncation

**Before**

Five surfaces parsed `entityTypeId` (the smart-process / dynamic-entity TYPE selector) leniently — with unanchored `parseInt` or coercing `Number()` — and a junk form silently turned into a DIFFERENT (within-account) entity type:

- The path `entityTypeId` (`/v1/items/:entityTypeId/...`, `/v1/categories/:entityTypeId/...` — CRUD and `/aggregate`): `GET /v1/items/1058abc` truncated to `1058`, `1e3` → `1`, `1.5` → `1`.
- Global `POST /v1/batch`: `params.entityTypeId` via `Number()` accepted fractions (`1.5`), hex (`'0x10'` → 16), overflow to `Infinity`, plus the array form `[1058]` → 1058.
- `/v1/items/:entityTypeId/userfields/*`: its own parser — `2abc` resolved the userfields of type `2`.
- `POST /v1/smart-processes/batch`: `ids: ['1030abc']` truncated to `1030` — delete/update silently ran against a REAL, DIFFERENT type; a fractional number (`1030.5`) passed too.
- `POST /v1/triggers/fire` (`entityType="item"`): `entityTypeId: '1038abc'` → the automation trigger fired against type `1038`.

**After**

All five surfaces require the canonical positive-integer form (`/^[1-9]\d*$/` for strings, `Number.isInteger` for numbers): any other form → `400` with each surface's existing error code (`INVALID_DYNAMIC_PARAM` / `INVALID_ENTITY_TYPE_ID` / `BATCH_ITEM_VALIDATION` / `MISSING_PARAMS`) BEFORE any Bitrix24 call. Aggregate now shares the CRUD routes' validator instead of an inline copy.

**Integrator impact**

Forms that previously coerced to a correct value and were served — `007` → 7, `%20`-spaces, `+2` → 2, the array form `[1058]` in batch — are now rejected with `400` as well: the value must be a canonical integer with no prefixes, suffixes or leading zeros. A boolean in batch was rejected before too (it coerced into a reserved type); only its error code changes — now `INVALID_DYNAMIC_PARAM`. Correct calls are unchanged.

### FIX-0714-10: Reopening feedback clears the resolution fields

**Before**

`PATCH /v1/feedback/:id` (and the admin endpoint `PATCH /api/platform/feedback/:id`, which the admin UI reopens through) moving a ticket back to an active status (`NEW`, `REVIEWING`, `AWAITING_USER`, `NEEDS_REVIEW`) from `RESOLVED`/`WITHDRAWN` did not reset `resolvedAt`, `resolvedBy`, or `resolution` — they lingered from the prior close, so a reopened ticket looked both active and resolved.

**After**

Moving a ticket **out of** `RESOLVED`/`WITHDRAWN` into an active status clears `resolvedAt`, `resolvedBy`, and `resolution`. `RESOLVED`/`WITHDRAWN` still set the resolution stamp; `ARCHIVED` leaves the fields untouched (archiving preserves the resolution history). A plain transition between active statuses (e.g. `AWAITING_USER → REVIEWING`) leaves the fields alone — on active tickets `resolution` mirrors the last team comment. An explicit `resolution` in the same request still wins over the clear.

**Integrator impact**

If you read `resolvedAt`/`resolution` on a reopened ticket and got the prior close's values, they are now `null` for an active ticket.

### FIX-0714-11: `INVALID_JSON_BODY` no longer quotes the engine parser text

**Before**

Six route groups (`/api/billing/*`, `/v1/apps*`, `/v1/bots*`, `/v1/keys*`, `/v1/note*`, `/v1/infra/servers/*` deploy/exec/upload) answered malformed JSON with `400` and a message like `Invalid JSON: Unexpected token } in JSON at position 41` — raw V8 engine text (a runtime fingerprint and an implementation detail). The deploy/exec/upload group set no error code at all.

**After**

All six now return the single static message `Request body is not valid JSON.` — matching `/v1/<entities>` (the same class is closed there by a separate fix). On V1 surfaces the code is `INVALID_JSON_BODY` (deploy/exec/upload now sets it too); the `400` status is unchanged.

**Integrator impact**

If your code parsed the message text (e.g., extracted the error position), rely on the `INVALID_JSON_BODY` code instead; the position is no longer reported.

### FIX-0714-12: Quote GET response returns amount, currency, and dates again (were null)

**Before**

Reading a quote (`GET`/list/search `/v1/quotes`) returned `null` for `amount`, `currency`, `beginDate`, `closeDate` — the values leaked only under the raw Bitrix24 keys (`opportunity`, `currencyId`, `begindate`, `closedate`). Writes worked, but the READ projection dropped every aliased field: **a quote's total and currency were 100% invisible via the documented API**.

**After**

The READ branch now reverse-maps the declared aliases (mirroring the write mapping): `opportunity → amount`, `currencyId → currency`, `begindate → beginDate`, `closedate → closeDate`, with type coercion. The raw Bitrix24 keys no longer appear in the response.

**Integrator impact**

If you read a quote's `amount`/`currency` and got `null`, they are now populated. Code that worked around it by reading the raw `opportunity`/`currencyId` from the response will no longer find them there — switch to the documented `amount`/`currency`.

### FIX-0714-13: Write-path validation: phantom checklist → 404, garbage types and junk `:id` → 400

**Before**

- `POST /v1/tasks/:taskId/checklist` against a nonexistent task returned `201` with a plausible `id`, yet nothing was created (the item was never GETtable).
- `POST /v1/warehouses` accepted non-string `title`/`address` (numbers, objects) and forwarded them to Bitrix24 with unpredictable results; `POST /v1/doc-templates` likewise passed non-string `name`/`region` and non-numeric `numeratorId` through.
- A non-numeric `:id` on entities with a typed numeric id (`GET/PATCH/DELETE /v1/quotes/abc`, `/v1/deals/1.5`, `/v1/leads/1e3`) went to Bitrix24 verbatim — returning an opaque B24 error instead of a clear code. On smart-processes `12abc` was `parseInt`-truncated to `12` and hit the WRONG type.

**After**

- Checklist: the parent task is verified before the item is created; a missing (or invisible-to-the-key) task → `404 TASK_NOT_FOUND`.
- Warehouses and document templates: a wrong-typed value → `400 INVALID_PARAMS` with no Bitrix24 call (a numeric string in `numeratorId` is still accepted).
- Entities with an explicitly typed numeric id: a non-canonical-integer `:id` → `400 INVALID_PARAMS` before any Bitrix24 call (id `0` — the main deal pipeline of `categories` — stays valid). Smart-processes keep `INVALID_ENTITY_TYPE_ID` and now reject `12abc` on GET/PATCH/DELETE instead of truncating it to `12`. Entities whose id type is not declared in the schema keep their prior pass-through behavior.

Affected endpoints: [POST /v1/tasks/:taskId/checklist](/docs/entities/tasks/checklist), [POST /v1/warehouses](/docs/entities/warehouses/create), [POST /v1/doc-templates](/docs/entities/doc-templates/create) + GET/PATCH/DELETE on entities with a typed numeric id.

**Integrator impact**

If your code relied on the phantom checklist `201` or sent garbage-typed values hoping for the best, you will now get an explicit `4xx` with a code. Correct calls are unchanged.

### FIX-0714-14: Five silent false-success / hint defects: an honest response instead of a fake success

**Before**

- `PATCH /v1/userfields/{entity}/{id}` with `label` was a silent no-op on update: `200`, but the field label never changed (Bitrix24 `crm.*.userfield.update` ignores `LABEL`).
- `POST /v1/humanresources/nodes/{id}` with `parentId` faked a successful reparent: `name` was applied, `parentId` was silently ignored, and the response echoed the stale parent.
- `POST /v1/chats/messages/bulk` did not resolve the `dialogId: "me"` alias inside the bulk loop (single routes do) → messages went to the wrong dialog.
- `POST /v1/bots/{botId}/chats/{dialogId}/users` — the `USERS_NOT_ADDED` safety-net was dead code (it never matched the v2 method's response shape) → a failed add passed as success.
- `crm.item.list` errors received an irrelevant "Maximum 50 records…" hint even when the cause was something else (e.g. "entity type does not exist").

**After**

- `label` on update fans out to the real `EDIT_FORM_LABEL`/`LIST_COLUMN_LABEL`/`LIST_FILTER_LABEL` params — the label actually changes.
- `parentId` and `type` are now create-only: on `PATCH` they are rejected with `400` (reparent via `POST /v1/humanresources/nodes/{id}/move`) instead of faking success.
- Bulk message reads resolve `dialogId: "me"` per item, like the single routes.
- The bot-chat add safety-net fires again: users that were not added come back in `warning.USERS_NOT_ADDED`.
- Known-limitation hints are gated on the error message's relevance, not the method name alone.

**Affected endpoints:**

- [`PATCH /v1/userfields/{entity}/{id}`](/docs/userfields/crm/update)
- [`PATCH /v1/humanresources/nodes/{id}`](/docs/humanresources/nodes/update)
- [`POST /v1/chats/messages/bulk`](/docs/chats)
- [`POST /v1/bots/{botId}/chats/{dialogId}/users`](/docs/bots)

### FIX-0714-15: GET /v1/task-time now honestly returns more than 50 rows when limit>50

**Before**

[GET /v1/task-time](/docs/entities/tasks/time) with a `limit` above 50 returned only 50 rows even though `meta.limit` echoed the requested value and `meta.hasMore` could mislead. A client paginating with a step above 50 silently lost rows.

**After**

The request now returns up to `limit` rows (max 500), collected page-by-page on the backend; `meta.total` and `meta.hasMore` match the window actually returned. With a `limit` above 50, `offset` now points at the correct position instead of shifting onto the first pages.

### NEW-0714-16: GET /v1/companies/fields and catalog-prices system fields now carry label and description

[GET /v1/companies/fields](/docs/entities/companies/fields) now returns human-readable `label` and `description` for every company field. [GET /v1/catalog-prices/fields](/docs/entities/catalog-prices/fields) adds the same metadata to the system fields `extraId`, `priceScale` and `timestampX`. Labels come in English. A field's meaning can now be read programmatically from the response instead of cross-referencing static documentation.

### FIX-0714-17: /stop and /reboot distinguish a missing server from a wrong status

**Before**

[POST /v1/infra/servers/:id/stop](/docs/infra/lifecycle/stop) and [POST /v1/infra/servers/:id/reboot](/docs/infra/lifecycle/reboot) on a server that was not in `running` status (e.g. sleeping) returned a flat `404 NOT_FOUND` reading "Running server not found" — from which you could not tell the server still existed, so an agent concluded it had been deleted.

**After**

Both routes now behave like [/start](/docs/infra/lifecycle/start) and [/wake](/docs/infra/lifecycle/wake): `404 SERVER_NOT_FOUND` only when no server with this `id` exists; `422 SERVER_WRONG_STATE` when the server exists but is not in `running` status. `error.currentState` carries the current state and `error.availableActions` lists the actions available now (`wake`/`start`/`repair`/`delete`).

### FIX-0714-18: clearer /fields error for task comments and task time entries

**Before**

[GET /v1/tasks/:taskId/comments/fields](/docs/entities/task-comments) returned a confusing `400 INVALID_PARAMS` reading "taskId and id must be positive integers" (you asked about fields, the answer was about an id), and [GET /v1/tasks/:taskId/time/fields](/docs/entities/tasks/time) leaked a raw Bitrix24 error exposing an internal PHP method and an HTML tag.

**After**

Both entities recognise the `fields` segment and return a clear `400 WRONG_PATH`: they have no `/fields` method (the field schema is documented in `/v1/guide`), and the message lists the valid routes. A non-numeric or fractional id on the by-id routes is now rejected as `400 INVALID_PARAMS` before the Bitrix24 call — no internal error leaks out.

### FIX-0714-19: `/v1/ai/credentials*` rate limits are now per-account; `CREDENTIAL_NOT_FOUND` carries a hint

**Before**

- The BYOK route limits (`POST /v1/ai/credentials`, `/:id/test`, `/:id/fetch-models` — 10/min; `/:id/models` add/delete — 30/min) were effectively keyed by source IP: credential probing was bypassable by IP rotation, and tenants behind one shared egress IP shared a bucket. `PATCH /:id` (which verifies the key upstream when `credentials` is sent — the same oracle as `/:id/test`) had no limit at all.
- The `404 CREDENTIAL_NOT_FOUND` from `/v1/search` and `/v1/research` carried only the provider slug — no pointer to how to configure a key.

**After**

- The limit is keyed **per Bitrix24 account**: all API keys of one account share a single bucket; IP rotation and key count no longer affect the cap. On exceeding it you get `429` with `Retry-After`. Additionally `PATCH /:id` (which verifies the key upstream, like `/:id/test`) previously had NO limit — it is now also 10/min per account.
- The `CREDENTIAL_NOT_FOUND` response now includes a `hint` field with the exact recipe: `POST /v1/search/credentials {provider, apiKey}`; provider list — `GET /v1/search/providers`.

### FIX-0714-20: PATCH for bizproc templates, robots and activities via /v1 now applies changes

**Before**

`PATCH /v1/bizproc-templates/:id`, `/v1/bizproc-robots/:code` and `/v1/bizproc-activities/:code` with metadata fields (`name`, `description`, `autoExecute`) returned `422 BITRIX_ERROR "No fields to update."` — updates were impossible (the same in batch requests). An `autoExecute` value sent as a number was additionally rejected as `Incorrect field AUTO_EXECUTE!`.

**After**

Fields are applied correctly (including `autoExecute` sent as a number); the endpoint confirms success and returns the `id` of the updated entity. Both single `PATCH` and batch requests work. Creation (`POST`) is unchanged.

### FIX-0714-21: Transcription: wallet check before the recognition call

**Before**

`POST /v1/audio/transcriptions` (and `/v1/ai/audio/transcriptions`) did not check the wallet before calling the upstream: a PREPAY account past its overdraft (but not yet frozen by the background sweep) still triggered recognition and slid the balance deeper negative. Chat and embeddings already rejected such calls up front; a fully frozen account was always blocked globally (`ACCOUNT_FROZEN`).

**After**

Same as chat and embeddings: the wallet check runs **before** the Whisper call. An exceeded overdraft → `402 insufficient_balance`, recognition never starts. BYOK keys (USER scope) are free — no check, no behavior change.

### FIX-0714-22: app deletion is no longer blocked by a galaxy host on its key

**Before**

`DELETE /v1/apps/:id` returned `409 APP_HAS_ACTIVE_SERVERS` when a galaxy host (shared account infrastructure) happened to sit on the application's key. The app could not be deleted, and the response gave no explanation.

**After**

A galaxy host is excluded from the blocking-servers check: it is managed at the account level, not the key level, so it must not block app deletion. Standalone application servers (including application containers) still block deletion with `409 APP_HAS_ACTIVE_SERVERS` — rebind them to another key first.

### FIX-0714-23: OpenAPI: per-entity batch endpoint body is now documented correctly (action + items/ids/calls)

**Before**

The spec (`GET /v1/openapi.json`) documented every per-entity batch body as `{create:[], update:[], delete:[]}`. The runtime (shared batch handler) requires `{action, items|ids|calls}` and returns `400 INVALID_BATCH_ACTION` for the documented shape. A client generated from the spec (codegen / AI agent) got **100% batch-write failure** across all ~45 per-entity batch endpoints. The feature itself works — only the spec was wrong (the global `POST /v1/batch`, `/v1/tasks/{taskId}/comments/batch`, and `/v1/guide` already documented the correct shape).

**After**

The spec generator emits the correct shape: a single `action` (`create`/`update`/`delete`/`list`/`get`/`fields`); `create`/`update` send `items`, `delete` sends `ids`, reads send `calls`. Matches the runtime and the global `/v1/batch`.

**Integrator impact**

If you generated a client from `openapi.json` and batch-write failed with `INVALID_BATCH_ACTION`, regenerate it: the body is now `{action:"create", items:[…]}` instead of `{create:[…]}`. Hand-written clients that already sent `{action,…}` are unaffected.

### FIX-0714-24: Currencies: fullName, format, and decimals now persist on a flat write

**Before**

`POST`/`PATCH /v1/currencies` with flat `fullName`, `formatString`, `decimals`, `decPoint`, `thousandsSep` returned success but **silently dropped** the values — Bitrix24 stores them per-language (`LANG`) and the API sent them flat. The documented workaround "send a raw `LANG`" did not work either: `LANG` is a read-only field, so the request was rejected.

**After**

The API packs the flat localizable fields into **your** language's localization (the API key's language) before the Bitrix24 call, so a flat write persists and reads back (`POST {fullName:"…",decimals:3}` → `GET` returns them). This works on every write path: single-route, `POST /v1/currencies/batch`, and the global `POST /v1/batch`. A raw `LANG` in the body is still rejected as read-only. Setting different values for several languages at once via the API is not yet supported. The write language is the API key's locale (ru or en) and may differ from the currency's display language in the Bitrix24 account — on accounts with another locale (de/pl/ua…) the edit lands under en.

**Integrator impact**

If you worked around the bug with a raw `LANG` (and hit `400 READONLY_FIELD`), drop it and send the flat fields. Flat requests that already worked now also persist the values.

### FIX-0714-25: Aggregate enforces required filters; infra validation no longer leaks raw Zod

**Before**

- `POST /v1/<entity>/aggregate` on an entity that mandates a filter (e.g. `catalog-products` needs `iblockId`) let an empty request reach Bitrix24 and returned a raw `422`, while GET-list/search return a clean `400` on the same condition.
- `POST /v1/infra/servers/:id/{deploy,exec,upload,logs}` put a multi-line JSON-serialized Zod issue array into `error.message` on a body-validation error (a raw validator fingerprint).

**After**

- Aggregate checks required filters/params before the Bitrix24 call: a missing required filter → `400 MISSING_REQUIRED_FILTER` (e.g. `catalog-products`→`iblockId`); a missing required list-param → `400 MISSING_REQUIRED_PARAMS` (e.g. `calendar-events`→`type`,`ownerId`; `humanresources-nodes`→`type`). Like list/search.
- Infra validation formats the error compactly (`field: message; …`), matching the sibling `infra.ts`. The code (`VALIDATION_ERROR`) and `400` status are unchanged.

**Integrator impact**

If you caught a raw `422` from a filter-less aggregate, you now get `400 MISSING_REQUIRED_FILTER`. If you parsed infra `error.message` as JSON, it is now a flat `field: message` string.

### FIX-0714-26: Batch: per-entity batch works for items, and folder creation via batch

**Before**

- `POST /v1/items/{entityTypeId}/batch` returned `404` — the per-entity batch route for dynamic-param entities (`items`, `categories`) was mounted at the param-less path (`/v1/items/batch`), so the documented path didn't resolve and the `entityTypeId` never reached the Bitrix24 command. The global `POST /v1/batch` fallback worked.
- `POST /v1/folders/batch` with `action: "create"` failed every item with `ERROR_ARGUMENT`: batch-create sent `fields[...]`, but `disk.folder.addsubfolder` expects the parent folder as a top-level `id` and the rest under `data[...]`.

**After**

- The per-entity batch route for `items`/`categories` is mounted with the `:{entityTypeId}` segment and threads the validated `entityTypeId` (positive integer; dedicated-API ids like `deals=2` are rejected with a pointer, same as the single routes) into every command — for **all** actions: `create`/`update`/`delete` and the read actions `list`/`get`/`fields`.
- Folder batch-create mirrors the single-route shape: `id=<parent>&data[...]`. A missing `parentId` is a clean per-item `400` before the Bitrix24 call.

**Affected endpoints:**

- [`POST /v1/items/{entityTypeId}/batch`](/docs/entities/items/create)
- [`POST /v1/folders/batch`](/docs/entities/folders/create)

### NEW-0714-27: recover a stuck server exec channel

A new endpoint [POST /v1/infra/servers/:id/unstick](/docs/infra/deploy/exec) force-frees a Black Hole server's stuck command channel when a deploy or exec keeps returning `EXEC_BUSY` ("Another command is running") even after `DELETE /v1/infra/servers/:id/lock`. It releases the platform-side lock and bounces the agent tunnel — on reconnect the agent finishes the stuck command and frees its mutex. The server is not rebooted.

If a legitimate operation (a deploy, exec, or harden) is still running on the server when you call it, the endpoint returns `409 OPERATION_IN_PROGRESS` by default and leaves it alone — only a genuinely stuck channel should be unstuck. Retry with `?force=true` if you are certain the command channel is hung.

Response: `{ success: true, data: { backendLockReleased, agentBounced, reconnected } }`. Error codes: `404 SERVER_NOT_FOUND`, `409 CONFLICT` (a recovery is already running), `409 OPERATION_IN_PROGRESS` (an operation is running on the server — retry with `?force=true`), `409 GALAXY_UNSTICK_UNSUPPORTED` (not supported for galaxy hosts or galaxy apps), `502 GATEWAY_ERROR`. The `/exec` error (`EXEC_BUSY`) and a `/deploy` failure (code `DEPLOY_FAILED`, message "Another command is running") now also carry a `hint` pointing at this endpoint.

### NEW-0714-28: server description in `PATCH /v1/infra/servers/:id`

**Before**

`PATCH /v1/infra/servers/:id` accepted only `displayName`. There was no `description` field in the contract, and `GET` responses did not expose one.

**After**

`PATCH /v1/infra/servers/:id` accepts an optional `description` field (string, up to 500 characters; an empty string or `null` clears the description; omitting the field leaves the current value unchanged). The value is synced to the application's catalog card. The `description` field is now returned in `GET /v1/infra/servers`, `GET /v1/infra/servers/:id`, and in the `PATCH` response. Existing requests without `description` keep working unchanged.

### FIX-0714-29: galaxy app deploy returns an honest error instead of a false success when the connection drops mid-build

**Before**

If the connection to the host dropped during a galaxy app build (common under heavy-build load), `POST /v1/infra/servers/:id/deploy` could return `200` with status `running` and a `[recovered]` note in `buildLog`, even though the new version never built or started — the previous container kept running. A retry hit the same drop and again reported a false success.

**After**

A deploy is treated as recovered only if it completed fully: the container running under the app's name is the one this attempt started, and the deploy ran to the end. If the connection dropped during the build — or at any point before the deploy completed — and the new version did not come up fully, the endpoint returns a retryable `502` with code `GALAXY_HOST_UNREACHABLE` and a hint to re-send the same deploy without deleting the server — instead of a false `200`. Only if the deploy completed fully and just the final response was lost does recovery to `200` work as before.

### BC-0714-30: renaming a catalog app reaches Bitrix24, publish loses menuTitle

> Old format supported until: 14.01.2027

**Before**

[PATCH /v1/apps/:id](/docs/apps/update) with a `title` field on an app that is in the catalog returned `200` but changed nothing the user could see: the catalog card and the placement bindings on the Bitrix24 account (the left-menu item, CRM tabs) kept the old name. There was never a failure — the call always succeeded.

For [POST /v1/apps/:id/publish](/docs/apps/publish) the body was not validated, and the placement title was set by a separate `menuTitle` field.

**After**

For an app in the catalog, `title` is a single operation on the display name: the name is synchronized into the catalog card (`catalogTitle` is written together with `title`) and re-bound into the placements on the Bitrix24 account. A call that always returned `200` can therefore now fail honestly:

- `400 NO_USER_TOKEN` — the app is not authorised on the Bitrix24 account, so there is nothing to re-bind the placements with;
- `400 TITLE_TOO_LONG_FOR_CATALOG` — a catalog app name is capped at 100 characters, while `title` allows 255;
- `502 BITRIX_PARTIAL_REBIND` — Bitrix24 rejected the binding. The name is not written in that case, and repeating the same request repairs the state.

The body of `POST /v1/apps/:id/publish` is now validated, and the `menuTitle` parameter is removed: the placement title is always the app's display name. An empty body still works — publication takes the values from the app record.

**What integrators should do**

- Drop `menuTitle` from the publish body: the field is ignored, the menu item name comes from `title` and `catalogTitle`.
- Keep a catalog app name within 100 characters.
- Handle the rejections on a rename: on `NO_USER_TOKEN` authorise the app on the Bitrix24 account, on `BITRIX_PARTIAL_REBIND` repeat the request.
- Note the side effect: renaming through `title` now also writes `catalogTitle` — for an app in the catalog the two fields are kept in sync.

### NEW-0714-31: GET /v1/tasks/:taskId/comments/fields — task-comment field schema

Task comments gained a `/fields` method like every other entity: `GET /v1/tasks/:taskId/comments/fields` returns a static 5-field schema (`id`, `taskId`, `authorId`, `message`, `createdAt`) with type, read-only flag, and segment-localized label and description. The method makes no Bitrix24 call. This path previously returned `400 WRONG_PATH` — the field set was only available from the static docs.

### FIX-0714-32: per-entity batch with action list now applies filter

**Before**

`POST /v1/{entity}/batch` with `action: "list"` ignored `filter`: field names were not mapped to their Bitrix24 names (e.g. a leads `statusId` was not turned into `stageId`), and operators `$gt` / `$contains` / `$in` and others had no effect. The call returned `200` with the whole table — a silent failure with wrong data. The global `POST /v1/batch` and `POST /v1/{entity}/search` filtered correctly.

**After**

Per-entity batch runs `filter` through the same translator `search` and the global `/v1/batch` use. Field aliases and operators (`$gt`, `$gte`, `$lt`, `$lte`, `$ne`, `$contains`, `$in`, `$nin`, prefix `>=`, `<=`, `!`, etc.) are applied. An invalid `filter` (an unknown field on an entity with a complete schema, an unsupported operator, the `@` / `!@` prefixes, or `$or` / `$and` logic tokens) now returns `400` naming the call index instead of silently returning the whole set — matching the single endpoints.

### FIX-0714-33: auto-pagination no longer duplicates records across page boundaries

**Before**

For Bitrix24 list methods without sort support (e.g. the storage-object list) a record on a page boundary could shift between the fetches of adjacent pages and land in both — with `limit > 50` the response carried a duplicate that occupied a slot, and the client processed the same record twice.

**After**

After all pages are stitched, the result is deduplicated by `id` (the first occurrence is kept). For stably-sorted methods nothing changes (no duplicates — a no-op).

### FIX-0714-34: requisite-preset field list no longer comes back empty; create no longer returns a foreign record

**Before**

`GET /v1/requisite-presets/:presetId/fields` could return `200` with an empty `data: []` even when the preset had fields: the Bitrix24 method returns `result` sometimes as an array `[{…}]` and sometimes as an object-map `{"0":{…},"1":{…}}`, and the handler accepted only the array form. On create (`POST …/fields`) the echoed record could be a DIFFERENT existing field — the created row was read back by the id from the `add` response, and on some accounts a read by that id returned another field.

**After**

The list normalizes both Bitrix24 response shapes (array and object-map) — fields are no longer dropped. The created record is echoed only when its `fieldName` matches the one that was created; on any mismatch the response carries `{ id }` (the row object is not substituted), so a client never receives a foreign record.

**Integrator impact**

A preset field's `id` is a Bitrix24 positional identifier: it can change after write operations on the preset and, on some accounts, is not a stable key. Do not cache `id` across preset mutations — re-fetch the field list before a `get`/`update`/`delete` on a specific field.

### FIX-0714-35: placement bind for earlier-created apps is no longer rejected over the handler

**Before**

[POST /v1/placements/bind](/docs/keys-auth) could return `400` with code `PLATFORM_HANDLER_UNRESOLVABLE` for an app created before the switch to the single platform handler (such an app kept its own technical address as the handler). The bind was rejected even when the platform handler `/v1/bitrix-handler` was available — the app could not be re-published through the API.

**After**

The bind succeeds: the placement handler is registered on the platform `/v1/bitrix-handler`, and the response carries `handlerRewritten: true` plus `requestedHandler` with the original value. `PLATFORM_HANDLER_UNRESOLVABLE` is now returned only when the platform handler is genuinely unavailable. [POST /v1/placements/unbind](/docs/keys-auth) removes such a placement by the same address.
