# API changes: September 9, 2026

[← Changelog](/docs/changelog) · [September 2026](/docs/changelog/2026-09)

### BC-0909-1: Audio transcription retains subscription billing when Cowork/Code is disabled

> Old format supported until: not provided

**Before**

When Cowork/Code was disabled, requests to [POST /v1/audio/transcriptions](/docs/ai/audio/transcriptions) using an already-issued `vibe:cowork` key bypassed subscription quota, even with an active subscription and an audio price configured for an available model. Exhausting subscription quota did not prevent a successful HTTP 200 response.

**After**

Disabling the product does not change billing: with an active subscription and a configured per-minute or per-call audio price, usage counts toward its quota, just like chat. When a quota window is exhausted, the request receives HTTP 402 `cowork_quota_exhausted` with a `Retry-After` header. Without an active subscription, the existing billing path is preserved without drawing on subscription quota.

**Integration changes**

Handle HTTP 402 `cowork_quota_exhausted` even while the product is disabled: wait for the quota window to reset according to `Retry-After` or upgrade the subscription tier. The key and request format do not need to change.

### BC-0909-3: Universal lists: an absent list answers "not found", and a bad offset answers with an error

> Old format supported until: not provided

**Before**

The same absent list answered differently depending on which address was called: the element
list, the field list, the field-type set, a single element, a single field and element files all
answered `422` with a Bitrix24 message — an "something went wrong" error rather than "no such
list". A read-then-create-if-missing flow broke on that.

Paging through those same lists accepted any offset. `start=-5`, `start=abc`, `start=1.5` and
`start=1e2` were not refused: the offset was either silently dropped or applied distorted (`1e2`
became `1`, so the second record arrived instead of the hundredth). The response was `200`, so a
mistake in the calling code stayed invisible — while the neighbouring parameters of the same
request, `iblockTypeId` and `sort`, are refused with a clear message.

**After**

An absent list answers `404` with error code `LIST_NOT_FOUND` on the six addresses where
Bitrix24 reports it with a machine code. An offset is accepted only as a non-negative integer in
plain notation; anything else is refused with `400` and error code `INVALID_PARAMS`, and the
message names the parameter that did not fit. Both `start` and `offset` are checked, even when
the precedence between them is won by the first.

**What integrators should do**

If your code treated a `422` from the list collections as "the list is gone", switch it to `404`.
What is recognized is Bitrix24's machine code, not the message text, so the answer does not
depend on the account language. The code itself was measured on the international platform: if
your account reports an absent list differently, the recognition does not fire and the answer
stays as it was.
If the offset came from an external source and could arrive negative or fractional, an error now
arrives instead of a page: fix the source rather than the walk. One input deserves a separate
mention because it used to be understood CORRECTLY: `?start=+5` — a plus in a query string
decodes to a space, and the old parsing read that as 5. It is now refused. If your builder
encodes with a plus, drop it: `?start=5`.

Separately, about writing parameters with square brackets. `?start[]=7` and
`?iblockTypeId[]=lists` used to pass: the platform joined such a list into a single value and
carried on. That is now refused — brackets mean a structure, and these parameters do not take
one. If your request builder adds brackets to every list by habit, send these three parameters
as a plain value: `?start=7`, `?offset=7`, `?iblockTypeId=lists`. Bracket shapes with a name inside
(`?start[x]=1`) used to break the request with an internal error; now they get a clear
refusal instead.

One more parameter belongs to the same row — the returned field set: `?select[x]=1` used to
break the request with an internal error, and now the set is simply not applied and the full
field list arrives; empty entries such as `?select[]=&select[]=NAME` no longer travel to the
account as an empty string.

The same applies to the request body when creating a section or an element:
`"iblockSectionId": [5]` used to be read as the number five and placed the record under
that parent; the value is now dropped and the record lands at the root. Send a number, or a
string holding one.

The infoblock type in the body narrowed the same way but answers differently:
`"iblockTypeId": ["lists"]` used to pass (the list was joined into a single value), and now
`400` with error code `INVALID_IBLOCK_TYPE` arrives on the eight addresses that read this
parameter from the body at all: creating and updating a list, a field, a section and an
element. Deletions do not take it and are untouched. Send a string: `"iblockTypeId": "lists"`.

**What this does not change**

Reading the sections still answers with a complaint about a wrong infoblock type, and reading
the list itself still answers with a permission refusal when the list id is numeric. With a
symbolic id that same address already answered "not found" before this change: there Bitrix24
returns an empty result rather than a refusal. Bitrix24 reports an absent list on those
addresses in the same words it uses for a genuine permission refusal and a genuinely wrong
type, so recognizing them as "not found" would hide real refusals.

### BC-0909-4: phantom contacts field removed from companies, leads, and deals

> Old format supported until: not provided

**Before**

Field schemas and company, lead, and deal responses could contain `contacts`, although the field could not be read reliably from Bitrix24. An explicit `select: ["contacts"]` was accepted with a `200` response.

**After**

`contacts` is no longer published in field schemas or returned in records. An explicit `select: ["contacts"]` without `*` or `UF_*` is rejected with `400 UNKNOWN_SELECT_FIELD`. Requests for all fields through `*` or `UF_*` still succeed, but `contacts` is removed from the response.

**What integrators should do**

Remove `contacts` from explicit `select` lists. Use `contactIds` for company contact relations, and `contactId` plus `contactIds` for leads and deals.

**Affected endpoints:** [GET /v1/companies/fields](/docs/entities/companies/fields), [GET /v1/leads/fields](/docs/entities/leads/fields), [GET /v1/deals/fields](/docs/entities/deals/fields), read and write operations under [companies](/docs/entities/companies), [leads](/docs/entities/leads), and [deals](/docs/entities/deals), [POST /v1/batch](/docs/batch), `POST /v1/{entity}/batch`.

### FIX-0909-5: a self-hosted portal now issues a key that needs no webhook

**Before**

On a self-hosted portal whose owner has no developer key, issuing or rotating a key carrying
only `vibe:*` scopes (for example `vibe:infra` + `vibe:storage`) answered
`400 BOX_NO_DEVELOPER_KEY`, even though no incoming webhook is registered on the portal for
such a key at all. It affected `POST /v1/keys`, `POST /v1/keys/{id}/rotate` and the personal
key issued for a Cowork application.

**After**

A scope set with no Bitrix24 scope is checked before the self-hosted guard: the key is issued,
`webhookUrl` stays empty and the portal is not called. The `400 BOX_NO_DEVELOPER_KEY` refusal
stays in force for sets carrying at least one Bitrix24 scope — such a key does get a webhook,
and nothing but the owner's developer key can remove it. Rotation adds one more condition: the
previous row must carry no webhook. A key with no Bitrix24 scope that still has one on record
answers with the same `400` — nothing can remove that webhook without the developer key, and a
successful answer would hide that the old access keeps working.

### FIX-0909-6: replacing an application key no longer hands out an already-expired secret

`POST /v1/cowork/applications/{id}/key` copied the lifetime of the replaced key onto the new one
verbatim. When that lifetime had already ended, the owner received a secret that passed no request
at all, while the expired key it replaced was given another 24 hours and started working again.

**Before**

An application whose key expired three days ago. The response is 201 with `issued: "rotated"`,
`key.expiresAt` holds the same past date, and `previousKey.graceUntil` points 24 hours ahead of the
replacement.

**After**

The response is still 201 with `issued: "rotated"`. `key.expiresAt` is counted afresh from the
lifetime configured in the Bitrix24 account (the same value `GET /v1/cowork/applications/defaults` reports as
`keyExpiresInDays`), so the new key works. `previousKey.graceUntil` never exceeds the replaced key's
own expiry: on an expired key it stays in the past, and the 24-hour grace does not revive it. A
lifetime that is still running, and the absence of one, are carried over as before — a key with no
expiry stays without one, a live expiry is repeated verbatim.

The same correction to the grace period applies to `POST /v1/keys/{id}/rotate`: rotating a key whose
lifetime had ended no longer grants the replaced key another 24 hours of service.

### FIX-0909-7: a public numeric address in baseUrl and proxyUrl is no longer refused as private

**Before**

`POST /v1/ai/credentials` and `PATCH /v1/ai/credentials/{id}` carrying a public numeric address in
`baseUrl` or `proxyUrl` — `http://203.0.113.10:8080`, say — answered `400` with code
`BASE_URL_PRIVATE` or `PROXY_URL_PRIVATE`. Storing such a credential was only possible by writing
the same address as a domain name.

**After**

That request now succeeds, exactly as it does for a domain name. A private numeric address still
answers `400` with the same codes: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`,
`100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `0.0.0.0/8`, and for IPv6 `::1`, `::ffff:`,
`fc00::/7` and `fe80::/10`. **For numeric addresses only**, that list now also covers the reserved
ranges a numeric address could never get through before: `192.0.0.0/24`, `198.18.0.0/15`,
`224.0.0.0/4`, `240.0.0.0/4` (including `255.255.255.255`), and for IPv6 `fec0::/10`, `ff00::/8`
and `100::/64`. For domain names the set of refused ranges is unchanged: a name pointing into any
of those reserved ranges keeps working, and a request carrying a domain name still succeeds as
before. The exception is addresses that wrap IPv4 in an IPv6 form (`::a.b.c.d`, NAT64
`64:ff9b::/96`, 6to4 `2002::/16`): those are refused the same way whether they are written as
numbers or a domain name points at one.

### FIX-0909-8: the spec declares the 429 refusal of endpoints that carry their own rate limit

**Before**

Twenty-eight operations carry their own rate limit and answer `429 RATE_LIMITED` once it is spent, while the public `GET /v1/openapi.json` spec did not declare that response for them. A client generated from the spec treated `429` as an undescribed status and built no retry branch at all. Affected: `POST /v1/batch`, `GET /v1/bots/:botId/events`, `GET /v1/chats/recent`, `POST /v1/chats/events/subscribe`, `GET /v1/connect/authorize`, `POST /v1/connect/token`, `POST /v1/connect/revoke`, `GET /v1/app/blueprints/:slug`, `GET /v1/me/sources`, `POST /v1/feedback/attachments`, `GET /v1/platform/coupons/campaigns`, `GET /v1/platform/coupons/campaigns/:slug`, `GET /v1/workday/records` and fifteen infrastructure endpoints — `POST /v1/infra/servers`, `GET /v1/infra/servers/:id/ssh`, `POST /v1/infra/servers/:id/reboot`, `PATCH /v1/infra/servers/:id/sleep`, `PATCH /v1/infra/servers/:id/port`, `POST /v1/infra/servers/:id/wake-schedules`, `PATCH /v1/infra/servers/:id/wake-schedules/:scheduleId`, `DELETE /v1/infra/servers/:id/wake-schedules/:scheduleId`, `POST /v1/infra/servers/:id/sources`, `POST /v1/infra/servers/:id/exec`, `POST /v1/infra/servers/:id/upload`, `POST /v1/infra/servers/:id/icon`, `GET /v1/infra/servers/:id/logs`, `POST /v1/infra/servers/:id/deploy` and `POST /v1/infra/servers/:id/unstick`. Separately, `POST /v1/platform/coupons/issue` did declare its `429`, but named it with the code `RATE_LIMIT_EXCEEDED`, which the platform never sends.

**After**

Every one of these operations declares its `429` in the spec, and coupon issuance now names the real code `RATE_LIMITED`. The description sends you to the `x-ratelimit-limit` response header for the value in force and to `Retry-After` for the retry delay. No figure is written into the spec, deliberately: the header is the only source that cannot drift from the configured value. For `POST /v1/infra/servers`, `POST /v1/infra/servers/:id/upload` and `POST /v1/infra/servers/:id/deploy` a second code is named on the same status — `DEPLOY_BACKEND_BUSY`, which arrives when every slot for an inline request body is taken, refuses that one body rather than the caller, and carries `Retry-After: 30`. General information about limits stays on the [limits](/docs/errors/limits) page.

**Impact on integrators**

Neither the limits nor the behaviour of the endpoints changed, and no request needs editing. A client generated from the spec before this change is worth regenerating — `429` handling then appears where the refusal used to arrive as an undescribed status. Check the code-matching branch on coupon issuance if it was written against the spec: the server answers `RATE_LIMITED`.

### FIX-0909-9: an expired OAuth user token returns an authentication error

**Before**

When an application could not refresh an expired user token, the Bitrix24 `expired_token` rejection was returned by the V1 API as `422 BITRIX_ERROR`, which incorrectly indicated invalid request data.

**After**

A single V1 API call returns `401 TOKEN_EXPIRED` with a hint to re-open the application from the Bitrix24 menu. In a batch request, `TOKEN_EXPIRED` appears in the individual call's error while the overall HTTP response remains `200`. See [API errors](/docs/errors).

### FIX-0909-10: the token endpoint's rate-limit refusal is now machine-readable

**Before**

When the platform-edge rate limiter fired, `POST /v1/connect/token` returned an HTML page and set no `Retry-After` header. An application could neither parse the body nor learn how long to wait. For the device code sign-in this is the only rate-limit refusal a live poll actually reaches.

**After**

The same refusal now arrives with a body in RFC 6749 form — `{"error":"slow_down","error_description":"..."}` — and with a `Retry-After` header naming the minimum pause in seconds. The form matches the one the endpoint already declares for its other refusals, so an off-the-shelf OAuth library parses it with no extra work. The response status stays `429`, so a rate-limit refusal is still distinguishable from the authorization states, which arrive with code `400`. Existing calls keep working.

**Important:** this route has two rate limiters and their bodies differ. Only the platform-edge limiter answers in the RFC 6749 form described above. The endpoint's own limiter answers with the same `429` status but in the general API envelope — `{ "success": false, "error": { "code": "RATE_LIMITED", "message": "..." } }` — so a branch of the shape «on `429`, parse the body and compare `error` against `slow_down`» recognises only half the refusals and takes the other half for an unknown error. Tell the two apart by the `X-RateLimit-Limit` header: the platform-edge refusal does not carry it, the endpoint's own refusal does. The `Retry-After` value is a minimum pause, not a promise that the next request is accepted, so an application needs to grow its own pause as well. Both refusals and both bodies are described in [Partner Connect](/docs/partner-connect).

### FIX-0909-11: model reasoning for Cowork no longer appears in final text

**Before**

When Cowork did not send an explicit reasoning setting, a model could return internal working text
as a regular part of the final answer.

**After**

[POST /v1/chat/completions](/docs/ai/chat/completions) applies the model-declared default reasoning
setting to keys with the `vibe:cowork` scope. Reasoning stays in its separate channel, while distinct
final answer text is preserved. The response remains HTTP 200.

**Impact on integrators**

No client changes are required. Calls without the `vibe:cowork` scope retain their previous behavior.

### BC-0909-12: box top-ups now check out at the licence's own till

> Old format supported until: not provided

**Before**

A box licence issued outside the installation's default region was pointed at its single default till: no other box till existed, so the platform deliberately resolved such a licence to the default region. The purchase did not go through there anyway — the till rejects a licence key from another region — so the path ended in a dead end with no explanation.

**After**

Where a country's own box till is open, checkout goes to that country's site in its own currency — prices, currency and the checkout URL come from the licence region's block. While a country's catalogue has no box items, top-up for such clients is refused with a clear message instead of pointing them at a till that would reject the purchase anyway. Once the items appear, checkout switches on by itself — no release needed.

Additionally, on installations whose catalogue carries more than one currency: a payment whose currency differs from the order's is now held for review instead of silently closing the order. Where the catalogue has a single currency this does not apply.

`GET /v1/cowork/subscription/preview` returns the licence country's currency for such a licence.

**What to do**

Read the currency from the response instead of assuming the default one: `currency` on packages and at the response root may now be the licence country's, and `null` while top-up is unavailable there (`topUpAvailable: false`). Amounts are in that currency's minor units — converting them as if they were the default currency yields wrong numbers.

Handle the `BOX_TOPUP_NOT_AVAILABLE` refusal on `POST /api/billing/topup-init` and the unavailable flag in the catalogue: they are returned while the country's catalogue has no box items. Previously this case returned a checkout URL for the default till — the purchase did not go through there anyway, so it can no longer be treated as a working one.
