# API changes: September 15, 2026

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

### BC-0915-1: Black Hole classifies the app's response by its author, not its status; new code BH_APP_TIMEOUT

> Old format supported until: not provided

**Before**

The gateway intercepted the app's response by HTTP status: `502` and `504` — always, `503` — whenever the response was not `Content-Type: application/json`. An intercepted response was replaced with a `503` carrying code `BH_APP_STARTING` and header `Retry-After: 3`, dropping the app's body and headers.

**After**

The gateway classifies the response by its author, not its status. A response the app itself wrote reaches the caller verbatim — status, headers, body. That covers `502`, `504`, and any `503`, **except two shapes indistinguishable from the agent's own refusal**: a `503` with `Content-Type: text/html` and a `502` with no `Content-Type` at all are still replaced with the `BH_APP_STARTING` screen. Other than those two shapes, only a response the app did NOT write is replaced: no frame arrived from the tunnel at all, or the agent answered for itself — reporting that nothing listens on the port, or refusing on its own (its plain-text overload refusal, a `503 Too many concurrent requests` with `Content-Type: text/plain`, matches neither shape and also reaches the caller verbatim — that is not a bug in your app, it is the agent saturated with concurrent requests).

The gateway's own window in which no full response arrived from the app in time (30 seconds with no frame at all, or an already-started response going silent for more than 15 seconds) no longer poses as `BH_APP_STARTING` — it is now the separate code `504 BH_APP_TIMEOUT` with no `Retry-After` header: the request may have been delivered to the app and executed, so an automatic retry is not safe. It differs from the app's own `504` (which also passes through verbatim) only in the body — the gateway's carries `error.code: BH_APP_TIMEOUT` in its JSON envelope.

**What integrators should do**

Handle `504 BH_APP_TIMEOUT` separately from `503 BH_APP_STARTING`: before retrying a write, verify the operation's effect by a stable identifier — the response carries no `Retry-After`. If your code assumed every `502`/`503`/`504` from your app always arrives as a contentless `BH_APP_STARTING`, update it: such responses (other than the two shapes above) now carry the app's real body and headers. To keep the gateway from replacing your app's own error, do not answer it with a `503` whose `Content-Type` is `text/html`, and do not answer it with a `502` that carries no `Content-Type` at all.

More detail — [App runtime environment](/docs/infra/app-runtime) and [What is safe to retry](/docs/errors/retry-safety).

### FIX-0915-2: Bitrix24 event subscriptions accept event codes with dots

**Before**

[POST /v1/infra/servers/:id/event-subscriptions](/docs/infra/event-subscriptions/create) rejected official Bitrix24 event codes with dots, such as `CATALOG.PRODUCT.ON.ADD`, with `400 INVALID_EVENT`. Subscribing to such events was not possible.

**After**

The method accepts both event code formats: without dots (`ONTASKADD`) and with dots (`CATALOG.PRODUCT.ON.ADD`). For codes without dots the response remains HTTP 200. The code is not rewritten: dots are kept, and delivered events carry the same code. Lowercase codes still get `400 INVALID_EVENT`.

**Impact on integrators**

No changes are required. You can now subscribe to Bitrix24 events whose codes contain dots.

### FIX-0915-3: the bitrixgpt-* family no longer names its base model in an answer

**Before**

While telling about itself, a model of the `bitrixgpt-*` family could name a third-party
base model and its vendor in the answer text — in both the regular response
(`choices[].message.content`) and the streamed one (`choices[].delta.content`).

**After**

The rule recognises a first-person statement the model makes about itself: a copula whose
predicate is the name ("I am …"), and self-description forms ("my base model …", "created
me …"). In such a statement the base model name is replaced with the public model name and
the vendor with `Bitrix24`. The rule behaves identically for regular and streaming
responses and keys off the model that actually served the request, not the `model` field of
the request.

The response stays HTTP 200 and its structure is unchanged. A mention of another model in
ordinary answer text (for example "Llama is an open-weights model") is not rewritten: the
rule fires only on a statement the model makes about itself and does not parse arbitrary
phrasings — it is not a replacement for a product identity setting. Not affected: models
whose origin is part of the public identifier, keys with their own providers (BYOK), the
`reasoning_content` and `tool_calls` fields.

On a request with `response_format` (`json_object` or `json_schema`) the rule does not run
at all — identically for regular and streaming responses. The rest of the response
handling is unchanged: as before, the router extracts the reasoning block into
`reasoning_content`.

### FIX-0915-5: Black Hole apps on a self-hosted portal get the employee ID on direct-link sign-in

**Before**

An employee of a self-hosted portal opened a Black Hole app by a direct link without signing in to the platform cabinet. If the email of their Bitrix24 Network account differed from the one on the Bitrix24 account, the `X-Vibe-User-Id` header reached the app as `net_<id>`, the same as for a visitor from outside the account. Named access granted by employee ID did not apply to such a visitor.

**After**

Such a visitor is recognized by the employee ID already stored in their Vibecode account. `X-Vibe-User-Id` arrives as a number — the same one sent when the app is opened from Bitrix24 — and named access by employee ID applies.

**Impact on integrators**

No action required. If an app stored data for such visitors under `net_<id>`, the same people now arrive with a numeric ID — the one they already had when opening the app from Bitrix24.

### FIX-0915-6: the frozen-account refusal no longer promises the backup model is paid from the wallet

**Before**

A Cowork/Code subscription key with an exhausted quota, on an account frozen for non-payment, got a `402 ACCOUNT_FROZEN` refusal from [POST /v1/chat/completions](/docs/ai/chat/completions) saying that "the fallback model is paid from the wallet". No such charge exists: a backup-model reply spends neither the subscription quota nor the wallet balance, and the platform pays for that inference itself. An integrator showing this text to the user explained the refusal by a charge that never happens and sent them looking for money where none is taken.

**After**

The text names the real reason: the subscription quota is exhausted and the backup-model reply is not served while the account is frozen; work resumes once the balance is topped up. The `ACCOUNT_FROZEN` code, the `402` status and the body shape are unchanged.

**Impact on integrators**

Nothing to change: the refusal code and the status are the same. A handler that shows `error.message` to the user will now tell the truth about money.

### FIX-0915-7: a platform integration key survives its creator moving to the administrator tier

**Before**

A `/v1/platform/*` key was closed on any drop of its creator's platform tier, including a move from superadministrator to administrator. An administrator issues the very same key with the very same access rights, so closing it took away no permissions — it only stopped a working channel: requests started receiving `401`, with nothing to check the channel state in advance.

**After**

A key is closed only when its creator is left without a platform tier at all: removal from the team, a drop to the staff level, a block, an accepted account erasure request, or the expiry of an administrator term. A move between the superadministrator and administrator tiers leaves the key alone, in both directions.

The rest of the earlier entry still holds: a closed key answers `401`, the remaining platform administrators are notified by email, and a new key is issued through `POST /api/platform/integration-keys`. Integrations should keep treating `401` as the "a new key is needed" signal.

### FIX-0915-8: a refused Black Hole app now explains which account the platform recognized

**Before**

A visitor refused access to a Black Hole app saw a refusal page on the app's own domain. It did not name the account the platform had recognized, so "I signed in with the wrong account" was indistinguishable from "I am not on the access list". The only way to tell was to sign out and sign in again.

**After**

An ordinary browser tab is sent to the platform page "No access to the app". It names the visitor's own name and email — the ones they were recognized by — and the domain of the app's Bitrix24 account when the visitor is a member of that account. The page shows nothing about the app's owner or about the access list.

Opening the app inside Bitrix24 is unchanged: an embedded window keeps the previous refusal page, because an embedded app must not be navigated to another address. Programmatic calls are unchanged as well — they still receive a `403` response with the `BH_ACCESS_DENIED` code.

**Impact on integrators**

No action required.

### FIX-0915-10: external collaborator keys are bound to their server

**Before**

A request made with an external-collaborator key could name a server other than the one
for which the key was issued. When access to the requested server was current, the API
processed the request instead of returning a server-mismatch error.

**After**

A request whose path names a server other than the one for which the key was issued now
receives `EXTERNAL_COLLABORATOR_KEY_SERVER_MISMATCH` without details about the requested
server.

**Impact on integrators**

Correct calls made with the requested server's own key remain unchanged. When working with
multiple servers, use the key for the corresponding external membership.

**Affected endpoints:** [GET /v1/infra/servers/:id](/docs/infra/servers/get), [PATCH /v1/infra/servers/:id](/docs/infra/servers/update), [DELETE /v1/infra/servers/:id](/docs/infra/servers/delete), [POST /v1/infra/servers/:id/deploy](/docs/infra/deploy/deploy), [POST /v1/infra/servers/:id/exec](/docs/infra/deploy/exec), [POST /v1/infra/servers/:id/upload](/docs/infra/deploy/upload), [GET /v1/infra/servers/:id/logs](/docs/infra/deploy/logs), [POST /v1/infra/servers/:id/wake](/docs/infra/lifecycle/wake), [GET /v1/infra/servers/:id/sources](/docs/source-storage), [POST /v1/infra/servers/:id/sources](/docs/source-storage), [GET /v1/infra/servers/:id/sources/:versionId](/docs/source-storage), [PATCH /v1/infra/servers/:id/sources/:versionId](/docs/source-storage), [DELETE /v1/infra/servers/:id/sources/:versionId](/docs/source-storage), [GET /v1/infra/servers/:id/sources/:versionId/download](/docs/source-storage).

### BC-0915-11: application publication no longer hides a rejected placement bind

> Old format supported until: not provided

**Before**

[Application publication](/docs/apps/publish) returned HTTP 200 and moved the application to `PUBLISHED` even when Bitrix24 rejected every requested placement bind. A generic developer-key HTTP 401 in [direct binding](/docs/apps/placements/bind) could be misclassified as `INT_TARIFF_REQUIRED`.

**After**

A developer-key HTTP 401 is treated as a credential refusal and is retried through OAuth automatically when an OAuth token is available, unless the Bitrix24 response explicitly names a non-credential cause such as an account-policy refusal or a missing scope. Named non-credential refusals remain authoritative at every HTTP status. If no transport confirms the bind, publication returns HTTP 502 `PLACEMENT_BIND_FAILED`, does not move the application to `PUBLISHED`, and returns the safe Bitrix24 machine code and status in `error.failures`. If removal of an old placement also fails in the same request, its code is returned in `error.failedUnbinds`. The stored placement list already contains the successfully bound subset. Direct binding without a successful OAuth fallback returns HTTP 403 `B24_ACCESS_DENIED`, not a tariff paywall.

**What integrators should do**

Treat publication as successful only on HTTP 200. On `PLACEMENT_BIND_FAILED`, re-read the application, retain the placements already present in `data.placements` of that read response, and retry publication only after resolving the listed causes. For `B24_ACCESS_DENIED`, refresh the credential or supply a live OAuth session. The platform has already decided whether to use the OAuth fallback before returning; retain `error.failures` for diagnostics, but do not start your own retry from an internal Bitrix24 code.

### NEW-0915-12: the application external API is open to every Bitrix24 account

The address [ANY /v1/applications/{id}/api/**](/docs/applications/external-api) is available on every Bitrix24 account: the platform accepts an HTTP request from outside — a Bitrix24 business-process robot or another application — delivers it to the application over its tunnel and returns the application's answer as is. `GET`, `POST`, `PUT`, `PATCH`, `DELETE` and `HEAD` are accepted; everything after `/api/` reaches the application as its own request path, the query string and the body are passed verbatim. The path is checked both raw and once-decoded, its limit is 2048 characters, and an invalid path or query string form yields `400 APP_API_BAD_PATH`.

**The key.** The address accepts only the external-API key issued for this application on its card on the Vibecode platform. It is passed in the `X-Api-Key` header or as `Authorization: Bearer` — the two forms are equivalent. A personal key or an application authorisation key gets `403 APP_API_NOT_GRANTED`, and an external-API key on any other platform address gets `403 APP_API_KEY_OUT_OF_SCOPE`. A missing or revoked key yields `401 MISSING_API_KEY` / `401 INVALID_API_KEY`, a read-only key on a mutating call yields `403 WRITE_BLOCKED_READONLY_KEY`. Calls are billed to the application owner as API requests: a frozen account yields `402 ACCOUNT_FROZEN`, an exhausted quota `429 QUOTA_EXCEEDED`.

**Conditions on the application side.** The channel is open when the "External API" switch is on in the application card, the application has a server and the server does not go to sleep. Otherwise the answer is `409 APP_API_NOT_ENABLED`, `409 APP_API_NO_SERVER` or `409 APP_API_NOT_ALWAYS_ON`; an unknown or deleted application yields `404 APP_API_APP_NOT_FOUND`. An enabled switch exposes the whole HTTP surface of the application to key holders: there is no route selection on the platform side, the application authenticates and authorises its own routes itself, and the `Authorization` header stays free for that — the platform strips it only when it carries the platform's own key.

**What the application receives.** The original method, path, query string, body and the caller's headers, except the platform key `X-Api-Key`, `Cookie`, `Host`, `Content-Length`, hop-by-hop connection headers, the whole `X-Vibe-` prefix and proxy-trust headers — the `X-Forwarded`, `X-Original`, `X-Rewrite`, `CF` and `Fastly` families and the individual names libraries read the client address from (`Forwarded`, `X-Real-Ip`, `Client-Ip` and the like). In their place the platform sets `X-Vibe-Request-Id`, `X-Vibe-Caller-Kind: external-api`, `X-Vibe-Caller-Portal-Id` and `X-Vibe-Caller-Key-Id`; there are no `X-Vibe-User-*` or `X-Vibe-Authorization` headers on this path — an external call carries no session.

**What the caller receives.** The application's status, body and response headers as is, except `Set-Cookie`, `Content-Length`, hop-by-hop connection headers and the `X-Vibe-` prefix; `3xx` answers are not followed. A platform refusal is told apart from the application's answer by the `X-Vibecode-Proxy-Error: 1` header — it is on every response the platform built, and the application cannot set it. The refusal body is the V1 envelope `{"success": false, "error": {"code", "message"}}`. The end-to-end call identifier `X-Vibe-Request-Id` on the response is described in entry NEW-0914-13.

**Limits.** The request body is 4 MiB, more yields `413 APP_API_PAYLOAD_TOO_LARGE`. The application's response body is 4 MiB, more yields `502 APP_API_RESPONSE_TOO_LARGE` without `Retry-After`: a retry will not fix such an answer. The rate is 120 requests per minute per key, read the effective value from the `X-RateLimit-Limit` header; above it `429 APP_API_RATE_LIMITED` with `Retry-After`. An application that does not answer, an unreachable or a saturated channel — `503 APP_API_UNAVAILABLE` with `Retry-After` in seconds; an answer that did not arrive within the overall call ceiling of 30 seconds — `503 APP_API_TIMEOUT` with `Retry-After` (entry BC-0914-25). An application answer outside the contract, for example a `1xx` status, — `502 APP_API_BAD_ENVELOPE`. Keep long work outside the call: answer right away and deliver the result separately.

### NEW-0915-13: task comment attachments and comment file download

A task comment now carries a list of attachments. The key is always present: with no files it is an empty list. Every attachment has a file identifier and a download path, plus a name and a size whenever Bitrix24 reported them. The path is root-relative — join it with the same API base address you called. A comment whose only content is the attached file is now distinguishable by its non-empty attachment list — previously it arrived with empty text and nothing indicated the file. The set of returned comments is unchanged: such a comment was returned before as well.

A new operation, [GET /v1/tasks/:taskId/comments/:id/files/:fileId/download](/docs/entities/task-comments/file-download), returns the file bytes. It needs only `task` access: the key does not have to be widened to the whole Drive, and the operation can return only a file of the very comment that key can already read. A file of a different comment, or one whose identifier merely matches by number, is refused. What comes back are the bytes themselves — the file's address on the account never appears in the response.

The field list at [GET /v1/tasks/:taskId/comments/fields](/docs/entities/task-comments/fields) gained an `attachments` entry describing the element shape. The same entry now appears in `GET /v1/guide` — under `entities[task-comments].fields` and `fieldsDetailed`, where the element shape is machine-readable under the `itemSchema` key. That matters to an agent holding no account token: the live field list is out of its reach, while the guide is served anyway.

### NEW-0915-14: catalog price types are available through the API

Price type identifiers depend on the account, and V1 had no way to read them: the `catalog` scope wrapped only the warehouse. Because of that the docs suggested treating `1` as the base price, and integrations that did so received a `422` from Bitrix24 with a message about the wrong price group.

A read-only entity has been added — [GET /v1/catalog-price-types](/docs/entities/catalog-price-types) — with list, get by identifier, search and a field reference. The response carries `id`, `name`, `base`, `xmlId`, `sort`, `createdBy`, `modifiedBy`, `dateCreate` and `timestampX`. The base price type of the account is the record whose `base` equals `Y`, and its `id` is not necessarily one. The scope is unchanged, `catalog`, but it is not enough: in Bitrix24 `catalog.priceType.*` is available only to a key whose owner has Bitrix24 account administrator rights. Writing is not supported, price types are created in the Bitrix24 interface.

The `422` response from [POST /v1/catalog-prices](/docs/entities/catalog-prices) for an unknown `catalogGroupId` now carries a hint pointing at the new endpoint. The claim that the base price is `1` has been removed from the catalog price documentation.

**Affected endpoints:** [GET /v1/catalog-price-types](/docs/entities/catalog-price-types), [GET /v1/catalog-price-types/:id](/docs/entities/catalog-price-types/get), [POST /v1/catalog-price-types/search](/docs/entities/catalog-price-types/search), [GET /v1/catalog-price-types/fields](/docs/entities/catalog-price-types/fields), [POST /v1/catalog-prices](/docs/entities/catalog-prices)

### BC-0915-15: a read-only key can deploy an application to its own server again

> Old format supported until: not provided

**Before**

Entry `BC-0825-2` closed every platform write to a key in read-only mode, including code
delivery to the caller's own server. [POST /v1/infra/servers/{id}/deploy](/docs/infra/deploy/deploy),
[POST /v1/infra/servers/{id}/exec](/docs/infra/deploy/exec),
[POST /v1/infra/servers/{id}/upload](/docs/infra/deploy/upload),
[POST /v1/infra/servers/{id}/icon](/docs/infra/app-icon) and
[POST /v1/infra/servers/{id}/unstick](/docs/infra/servers/unstick)
returned `403 WRITE_BLOCKED_READONLY_KEY` before the operation ran.

**After**

Those endpoints now pass for such a key, together with [POST /v1/infra/servers/{id}/unstick](/docs/infra/servers/unstick) —
releasing a stuck exec lock, the recovery for exactly those operations. ⚠️ This is NOT
`DELETE /v1/infra/servers/{id}/lock`: that one releases a stuck operation lock and was an exception
before this change too. The framing of the decision: read-only mode
restricts Bitrix24 data, while the caller's own application stays at their disposal. The group
is indivisible — deploying an arbitrary archive is the same as executing arbitrary code, so
allowing `deploy` without `exec` would be an imaginary restriction.

The rest of the mode's behaviour is unchanged. Still returning `403 WRITE_BLOCKED_READONLY_KEY`:
server creation [POST /v1/infra/servers](/docs/infra/servers/create), lifecycle operations
([POST /v1/infra/servers/{id}/stop](/docs/infra/lifecycle/stop), `start`, `reboot`,
[POST /v1/infra/servers/{id}/wake](/docs/infra/lifecycle/wake), sleep and wake schedules),
server deletion, and also AI, storage, keys and placements.

⚠️ Do not read that "still refused" list as closed: the central restriction has other
exceptions that predate this change. `POST /v1/apps` answers `201` for such a key — the
application and its paired key are created in read-only mode, and the `403` arrives only when
read+write is requested. `DELETE /v1/cowork/key` passes as an emergency exit, exactly like
`DELETE /v1/infra/servers/{id}/lock` above. The full set of platform-gate exceptions arrives in
`writeRestriction.exceptions` of the [GET /v1/me](/docs/keys-auth/me) response — check that,
not this paragraph.

Three effects of such a key are now visible to the Bitrix24 account, and that is the price
of the decision. The first deploy of a server with a public subdomain creates the application
card in the account catalog, which every employee sees — the icon endpoint changes the picture
on that same card. And deploying to a sleeping server wakes the machine on its own, which
starts spending the Vibecode balance. The separate wake call stays forbidden: such a key cannot
wake a server without deploying anything to it.

And the third one, the one that matters most to whoever issues the keys: **read-only mode is no
longer a boundary for the owner of a server.** Deploy and `exec` run code inside the container,
and the credentials the platform issued to that application live there — the application's
personal API key (which is in read+write mode) and the application's tokens to the account.
By reading the environment, the holder of a read-only key obtains the right to write Bitrix24
data. This is an accepted price of the decision, not an oversight: the endpoint itself writes no
account data, but it grants access to credentials that do.

The list of exceptions to the central restriction arrives in the
[GET /v1/me](/docs/keys-auth/me) response as `writeRestriction.exceptions`, and per-operation
availability in the `capabilities` block. The `capabilities.servers.deploy` slot no longer
arrives with `available: false` for a read-only key.

⚠️ The shape of one neighbouring response changed too. `GET /v1/infra/servers/{id}/logs` on a
sleeping galaxy app answers `200` with a `recovery` block, and `recovery.recoveryAction` is now
CONDITIONAL: it is absent whenever the caller cannot call the wake. ⚠️ That set of conditions is NOT
closed — branch on the PRESENCE of the field, not on a list. ⚠️ The converse is not guaranteed:
the field's presence only means the caller is not refused by identity or key mode; billing and an
administrative wake ban are separate axes, and the address it names may still answer `402` or `403`. Today it holds: read-only mode, a
Cowork/Code key, the galaxy pilot switched off for the account, an agent maintenance key (log reads
are open to it, waking is not), an owner whose account deletion is pending, and reaching the app
through its application link while another key owns the server.
It used to arrive always and named the wake address, which answered `403` for such a key. An
empty string in place of the address is deliberately not sent: a machine would try to execute it,
whereas a missing key reads as "there is no wake here, look at the neighbouring fields".

A third field of the same block became conditional too — `recovery.poll`, the readiness-polling
address. It disappears when `GET /v1/infra/servers/{id}` refuses the caller itself: an agent
maintenance key does not carry that route in its allowlist, and for an owner whose account
deletion is pending it sits in the frozen surface, unlike this log read. In that state the hint
simply says to repeat the log read later.

In that same state `recovery.wakeSchedule` became a verdict about the CALLER, not only about the
app: it arrives `available: false`, usually carrying the code of the door that refused the wake
(`WRITE_BLOCKED_READONLY_KEY`, `INFRA_FORBIDDEN_FOR_COWORK_KEY`, `GALAXY_DISABLED`,
`AGENT_MAINTENANCE_KEY_OUT_OF_SCOPE`, `user_self_deletion_pending`, `NOT_FOUND` — the set is
open, treat an unknown code as a refusal). It used to
judge the app's eligibility alone and could arrive `available: true` next to a hint saying "a
window is refused to you as well" — a client branching on it walked into a guaranteed `403`.
Creating a window is the same write, refused mostly by the same doors as the wake. The exception is a window door
checked before the wake's door: then `code` and the `hint` text both name that window door.

⚠️ But not only by those, and "nothing changed for whoever can wake" is wrong: the window has TWO
doors of its OWN, and each answers with ITS OWN code. The first: the schedule route may sit outside
the set of routes your key may call while the wake itself sits inside it — that is how an
external-collaborator key works; code `EXTERNAL_COLLABORATOR_KEY_OUT_OF_SCOPE`. The second: the
schedule route requires the `vibe:infra` scope, which reading logs and waking do NOT, so an ordinary
owner key without that scope reaches this refusal as well; code `INFRA_SCOPE_REQUIRED`, and the cure
is to grant the scope, not to change the key's mode. Either one gives `available: false` EVEN for a
caller who is allowed to wake, and then no field of the response names the schedule address. Branch
on `available`, not on whether the wake is available to you.

A new conditional field `recovery.deliveryWakesHost` appeared — the second way up. It arrives
when waking is unavailable to the caller **and the deploy is available to them**, carrying
`action` (the deploy call) and `cost` (its price in words). The reason: this same change ALLOWS a
read-only key to deploy to a server it owns, and a deploy to a sleeping galaxy app brings the host
up by itself — so for that caller "nothing can bring it up" would be untrue. The price sits next
to the address deliberately: it is a code deployment and it spends the balance, not a wake, and it
should not be called just to read a log.

⚠️ The field is DOUBLY conditional, and that matters to a client: the carve-out lifts the
ACCESS-MODE gate on deploys and nothing else. A Cowork/Code key, an account with the galaxy pilot
switched off and an agent maintenance key are refused `POST …/deploy` by the same doors that refuse
the wake — they do not get the field at all. Check for its presence rather than assuming it: no
field means no open path.

The TEXT of that response changed along with the fields. The prose in `hint` and
`recovery.reason` no longer names the wake address, nor the creation of a scheduled window. In
place of an address, `hint` names EXACTLY the condition that applied to this caller (rather than
a list joined by "or") plus the second way up together with its cost. For a caller who can call
the wake, the text and the set of fields are unchanged — **except where creating a window is closed to
them separately**. Then `recovery.wakeSchedule` arrives `available: false` carrying that door's code
and no field names the schedule address. There are two such doors, and neither coincides with the
wake's: the schedule route sitting outside the set of routes the key may call (that is how an
external-collaborator key works — the wake is open to it, the window is not) — code
`EXTERNAL_COLLABORATOR_KEY_OUT_OF_SCOPE`; and the key lacking the `vibe:infra` scope, which the
schedule handler checks on its FIRST line while reading logs and waking require no such scope —
code `INFRA_SCOPE_REQUIRED`. The code comes from the door that actually fired for you, not from its
neighbour: the route allowlist is checked before every other door, while the scope is checked after
the mode, freeze, Cowork and pilot doors but BEFORE server ownership. So a caller admitted through
its application link and lacking `vibe:infra` gets `INFRA_SCOPE_REQUIRED`, not `NOT_FOUND`.

**What integrators should do**

A client calling those endpoints has nothing to change — the refusal simply stopped arriving.

Read `recovery.recoveryAction` in the logs response only after checking the key is present:
without that check a client dereferences a missing field and ends up either in an exception or
in a request to `undefined`.

Do not branch on `recovery.wakeSchedule.available` as a verdict about the app: it now accounts
for the caller's right too, and for a refused caller it arrives `false` where it used to arrive
`true`. A client that created a window on `true` will no longer get the refusal — it will not
issue the request at all.

Check for the PRESENCE of `recovery.deliveryWakesHost` rather than assuming it: the field only
arrives for callers the deploy is actually available to. And before calling `action`, read the
`cost` beside it: the call delivers code and spends the balance. It is not a way to "just wake the
app to read a log".

Check for the PRESENCE of `recovery.poll` — the third conditional field of the same block. The
readiness-polling address is dropped whenever `GET /v1/infra/servers/{id}` refuses the caller
itself: an agent-maintenance key does not carry that route in its allowlist, and an owner whose
account deletion is pending has it behind the freeze. A client written against the earlier
contract, where the field always arrived, dereferences a missing field and ends up either in an
exception or in polling the address `undefined`. In this state readiness is not polled at all —
the hint tells you to re-read the log later.

Do not scrape the wake address out of `hint` with a regular expression: in that state it is not
there, and the expression returns an empty result rather than a refusal.

Action is required from the account administrator who issued read-only keys precisely because
such a key could not deploy code. Review who holds those keys. The access mode no longer serves
that purpose: it restricts Bitrix24 data, not delivery.

**Important:** removing the `vibe:infra` scope does NOT close delivery — that scope is checked only
on server creation and on wake schedules. Delivery is granted by any one of three branches: the
server was created by this key; the server is bound to an application whose primary key this is;
the key owner belongs to the server's development team with the code right.

Because of that, **only one lever is reliable — revoke the key.** Re-binding the server to another
key does NOT close delivery: it changes the server owner but touches neither the application
binding nor the team membership, so the previous key keeps delivering code through those two
branches.

### NEW-0915-16: Bitrix24 account balance change notifications

Added `balanceChanged` and `balance.get` to synchronize the Bitrix24 account balance with the module. Notifications coalesce, with the next attempt reserved one hour later. Execution delays may shorten the actual interval between sends. Enqueueing after the monetary commit may lose a signal; the next change allows the module to retrieve the current balance. `version` identifies a notification generation, not a balance revision: the module serializes reads and stores responses, including those with an unchanged generation. The `portal-balance-events` flag is disabled by default.

### FIX-0915-17: the mode-switch address for an application auth key now follows where the application card lives

**Before**

The `WRITE_BLOCKED_READONLY_KEY` access-mode refusal for an application auth key
(`vibe_app_*`) always returned the Applications page `"/applications"` in `details.switchUrl`.
Not every application has a card there, though: an application that is not registered in
the Applications section has no card on that page, so the holder of such a key landed on a
page with no switch.

**After**

For an application auth key, `details.switchUrl` points at the page that carries that
particular application's card: the Applications page `"/applications"` when the application
is registered in that section, otherwise the Auth Keys page `"/apps"`. There the card opens
from the row menu, item "Info", and the **Access mode** block sits in it right under the key.
The address for a personal key (`"/keys"`) and a management key (`"/management-keys"`) is
unchanged. The message text still names the same address as the field, and the refusal code
and the response itself stay the same — only the value of `switchUrl` changed.

**Impact on integrators**

A client that reads `switchUrl` from the response needs no change. Details are on the
[Access mode](/docs/keys-auth/access-mode) and [Authentication errors](/docs/errors/auth)
pages.

### NEW-0915-18: open channels: chats by CRM card and putting an operator into a dialog

Three endpoints were added, all requiring the `imopenlines` scope and working on every Bitrix24 account.

`GET /v1/openlines/crm/chats?crmEntityType=lead|deal|company|contact&crmEntityId=N` returns the Open Channel chats bound to a CRM object: `{ "success": true, "data": [ { "chatId": 2043, "connectorId": "telegrambot", "connectorTitle": "Telegram" } ] }`. The optional `activeOnly=false` adds finished dialogs to the open ones. For an object with no dialogs `data` is empty. A `crmEntityType` outside the list is refused with `400 INVALID_PARAMS` before Bitrix24 is called.

`POST /v1/openlines/sessions/intercept` with the body `{ "chatId": 2043 }` moves the dialog to the current operator and answers `{ "success": true, "data": { "chatId": 2043, "intercepted": true } }`. `POST /v1/openlines/sessions/join` with the same body adds the operator to the dialog as one more participant and answers `{ "success": true, "data": { "chatId": 2043, "joined": true } }`. Both accept `chatId` as a number and as a string of the `chat2043` form, both write into a live conversation with a client and are therefore unavailable to a read-only key — it gets `403 WRITE_BLOCKED_READONLY_KEY`.

Until now there was no way to find an Open Channel dialog by a lead, deal, company or contact card, and of the operator actions the API only offered `POST /v1/openlines/operator/answer` and `POST /v1/openlines/operator/finish`. Existing requests keep working as before.

Documentation — [Chats by CRM card](/docs/openlines/crm-chats).
