# API changes: September 16, 2026

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

### FIX-0916-1: partner accounts get raised free Cowork limits

**Before**

A free Cowork seat was measured against the shared windows no matter whose
account it was. `GET /v1/cowork/me` and `GET /v1/cowork/state` returned shares
computed from the shared grid, and once a window ran out an AI request got `402`.

**After**

On a partner (NFR) account a free seat is measured against its own windows, set
on the platform side, and they only ever raise a ceiling. The response shape is
unchanged: `quotaPct` and `windows` carry the same fields, but the shares are now
computed from the partner ceiling, and the `402` for an exhausted window arrives
later. A partner's paid seat is untouched — it is measured against its own tier,
and the seat price does not change.

The platform reads the partner mark from the account licence. On a self-hosted
portal it appears once the Vibecode module is re-registered there.

**Impact on integrators**

Nothing to do: the fields and statuses are the same, and a successful response
stays successful. A client keeping its own copy of the ceiling, derived from the
share and the spend, should recompute it from a fresh response rather than cache
it between sessions.

### NEW-0916-2: Bitrix24 account members and their Cowork seats

`GET /v1/platform/cowork/members` is new — Bitrix24 account employees with their current Cowork plans, consumption and a plan recommendation. It is meant for a checkout that draws a seat calculator: the list of employees, their plans and their spend live on the Vibecode platform only.

The Bitrix24 account is addressed by `portalNetworkId`, its identifier in Bitrix24.Network — the same one carried by a `GET /v1/platform/revenue/balances` row. The fallback key is `portalDomain`. An unknown account is not an error: the response comes back with `portal.known` set to `false` and empty lists, because that is what a first-time buyer looks like.

The `portal` block carries `usersTotal` and `usersInVibecode`; the `seats` block carries assigned seats in `assigned`, seats running out in `expiringWithin30Days`, and `unassigned` for seats bought ahead. A `data` row carries `userId` (the person's identifier in Bitrix24.Network — the same value that arrives on entry from the checkout), `b24UserId`, `email`, `name`, `position`, `departmentIds`, `isAdmin`, `inVibecode`, a `plan` block (`code`, `status`, `source`, `months`, `validFrom`, `validUntil`), a `usage` block (`requestsMonth`, `quotaVibesMonth`, `usedVibesMonth`, `lastActiveAt`) and `recommendation` (`plan`, `reasonCode`, `confidence`, `facts`). An employee who is not on the Vibecode platform yet comes with `userId` set to `null` and is identified by `b24UserId`.

Selection is `filter` with the values `ALL`, `PAID`, `EXPIRING`, `SUSPENDED`, `NO_PLAN`, `NOT_IN_VIBECODE`, `HAS_RECOMMENDATION`; `search` matches name and position. Paging: `limit` up to 500 (100 by default), continued by `cursor` taken from `nextCursor`; the snapshot time arrives in `capturedAt`. An unknown query parameter is rejected with `INVALID_FILTER` instead of being ignored silently.

The `degraded` field says whether the list is complete. `true` means the account's employees could not be read and the response holds only those already working on the Vibecode platform; `usersTotal` is then a lower bound, not an exact count.

Authorization is a platform integration key in the `Authorization: Bearer` header with the `cowork:read` scope, granted by a platform administrator when the key is issued. The scope is separate from `revenue:*` on purpose: the response carries employees' personal data rather than amounts, and an accounting key must not receive it along the way.

### FIX-0916-3: a concurrent application key replacement issues exactly one key

**Before**

The "one replacement per application" limit held only until a neighbouring replacement finished, not for the whole life of a request. Two concurrent calls to `POST /v1/cowork/applications/{id}/key` carrying DIFFERENT `Idempotency-Key` values could land in that window and issue a key each: the documented `APPLICATION_KEY_REPLACE_IN_PROGRESS` refusal never reached the second caller, the card slot went to whichever finished second, and the key issued to the first caller was silently moved to a one-day expiry as a replaced key — its owner was never told.

**After**

The limit now holds for the whole life of a request. A caller whose card has meanwhile been taken by another replacement gets `409 APPLICATION_KEY_REPLACE_IN_PROGRESS` and mints nothing — exactly the refusal already documented for this method. One key is issued, and that key is the one in the slot.

**Integrator impact**

Nothing to change: the `APPLICATION_KEY_REPLACE_IN_PROGRESS` code and the advice that goes with it are unchanged, and no new codes were added. What changed is how often it arrives: the refusal now also covers the race where a second key used to slip through. Treat it as a normal outcome of a parallel call — read the application card and check whether another key is still needed. If you fire replacements in parallel against yourself, serialise those calls on your side.

### NEW-0916-4: server details show the port detected by the tunnel agent

[GET /v1/infra/servers/:id](/docs/infra/servers/get) additionally returns `detectedPort` and `detectedPortObservedAt` in the full server card: the last reliably observed tunnel target port and the observation time. Both fields are `null` before the first observation; an unsuccessful observation does not erase an earlier one. The GET itself does not probe the machine, so assess freshness together with the timestamp and connection status.

**Impact on integrators**

No action is required: the new fields are additive. Use `detectedPort` to diagnose the tunnel's actual target, and continue treating `localPort` as configuration or fallback.

### FIX-0916-5: the COWORK_HARNESS_DISABLED code no longer arrives on Cowork subscription keys

**Before**

[PATCH /v1/keys/:id](/docs/management-keys) and [POST /v1/keys/:id/rotate](/docs/management-keys)
answered `403` with the code `COWORK_HARNESS_DISABLED` while issuing subscription keys for
third-party agents was closed on the platform.

**After**

That code does not arrive. The remaining issuance checks are unchanged: the key owner's Cowork
access, the account administrator's permission for third-party clients and the subscription state
— each still answers `403` with its own code, and a successful `200` response stays successful.
Nothing has to change in an integration, and handling of the `COWORK_HARNESS_DISABLED` code can be
dropped.

### FIX-0916-6: Object reads are limited independently for each Bitrix24 account

**Before**

[GET /v1/storage/objects/:key](/docs/storage/objects/get) and
[HEAD /v1/storage/objects/:key](/docs/storage/objects/head) did not limit a flow
of repeated requests.

**After**

Each method now has its own budget of 600 requests per minute per Bitrix24 account, shared
by all API keys of that account. Normal successful responses are unchanged. Once
the budget is exhausted, the API returns `429 RATE_LIMITED`: the effective limit
arrives in `x-ratelimit-limit`, and the retry delay in `Retry-After`.

**Impact on integrations**

No changes to normal requests are required. On `429 RATE_LIMITED`, wait for the
`Retry-After` delay before retrying.

### FIX-0916-7: API reports the effective tunnel state

**Before**

After a tunnel was lost, server reads and `/refresh` could keep returning `CONNECTED`, so clients did not see the `repair` action and `/exec`, `/upload`, and `/logs` proceeded to a missing-tunnel error.

**After**

List, detail, and refresh responses report the effective tunnel state, including `DISCONNECTED` and the `repair` action. This status correction is not persisted. Before exec, upload, logs, and deploy, the API verifies server availability. Tunnel absence is confirmed only by a non-empty current connection snapshot; an empty or unavailable snapshot preserves the stored status. Exec, upload, and deploy may restore a confirmed-missing tunnel, while GET logs returns `409 SERVER_NOT_READY` with an explicit `POST /repair` instruction and does not start repair. Successful response shapes and their HTTP 200 status remain unchanged.

**Affected endpoints:** [GET /v1/infra/servers](/docs/infra/servers/list), [GET /v1/infra/servers/:id](/docs/infra/servers/get), [POST /v1/infra/servers/:id/refresh](/docs/infra/lifecycle/refresh), [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/deploy](/docs/infra/deploy/deploy), [GET /v1/infra/servers/:id/logs](/docs/infra/deploy/logs).

**Impact on integrators**

Treat `DISCONNECTED` and `repair` as the effective state. After `409 SERVER_NOT_READY`, retry with normal backoff and honor `Retry-After` when the header is present. For GET logs, first call the `POST /repair` endpoint named in `hint`: the GET does not start repair itself. Response shapes and successful HTTP 200 behavior are unchanged.

### NEW-0916-8: Cowork off-peak and quota-relief keys now always arrive

In [GET /v1/cowork/me](/docs/cowork/me) and [GET /v1/cowork/state](/docs/cowork/state) the keys
arrive in every successful response: `offPeak` (both responses), `touSavedPct` (state), `relief`
(both responses), `boostPct` and `boostExpiresAt` (me). Presence checks for these keys can be
dropped — the `200` response is still successful, the values inside the blocks are unchanged, and
`null` inside a block keeps its own separate meaning. `currentWindowEndsInHours` in
[GET /v1/off-peak](/docs/ai/consumption/off-peak) arrives in every successful response as before: it
never depended on a per-account rollout.

`offPeakHint` in the `402 cowork_quota_exhausted` refusal of
[POST /v1/chat/completions](/docs/ai/chat/completions) no longer depends on the capability being
enabled for the account, but it still arrives only when the unblock moment falls into a discounted
hour. The presence check for that key stays.

### FIX-0916-9: storage: a file uploaded again under the same key no longer counts as several objects

**Before**

Until 2026-09-04, uploading a file again under the same key with a personal developer key or on behalf of a server created one more object instead of replacing it. The [GET /v1/storage/objects](/docs/storage/objects/list) response listed one file several times under different identifiers, while [GET /v1/storage/objects/{key}](/docs/storage/objects/get) and [DELETE /v1/storage/objects/{key}](/docs/storage/objects/delete) acted on one of the copies, so a deleted file stayed in the list.

**After**

Copies of private files are folded: the key keeps one object with the identifier of its earliest non-deleted copy and the metadata of the latest upload (`sizeBytes`, `sha256`, `contentType`, `contentUpdatedAt`). Deleting by key works on the first call. File bytes are unchanged. Copies of public files stay in place for now, so links already issued for them through [GET /v1/public-storage/{portalId}/{objectId}](/docs/storage/objects/public-get) keep working. Objects uploaded with an app key are not affected.

**Impact on integrators**

No changes are needed. A private file is read and deleted by key, and the key is unchanged. The identifiers of the folded copies disappear from the listing.

### FIX-0916-10: a wake window on a server with a run mode is no longer refused as an always-on conflict

**Before**

`POST /v1/infra/servers/{id}/wake-schedules` answered `400 ALWAYS_ON_CONFLICT` for any server with no idle-sleep threshold — including a machine whose owner had already assigned a scheduled run mode. Adding an extra wake to such a machine was impossible.

**After**

A server in the scheduled (`SCHEDULE`) run mode is no longer treated as always-on: its sleep is declared by the owner explicitly, through the schedule windows. An extra wake window on such a machine is created as usual and the `201` response is unchanged. For a machine without a schedule — including one in the `ALWAYS` run mode — the behaviour is the same as before: `400 ALWAYS_ON_CONFLICT`.

### BC-0916-11: Unambiguous record type when listing CRM documents

> Old format supported until: not provided

**Before**

[GET /v1/crm-documents](/docs/entities/documents/crm-list) accepted repeated `entityTypeId` values with HTTP 200 and could return documents of another record type. Supplying both `entityTypeId` and `entityTypeID` was also accepted.

**After**

Repeated parameters, both names together and bracket forms return HTTP 400 `INVALID_ENTITY_TYPE`, even when the values agree. For a single bracket form, the error code changes from `MISSING_PARAMS` to `INVALID_ENTITY_TYPE`. A single positive integer supplied as `entityTypeId` or `entityTypeID` works as before.

**What integrators need to do**

Supply exactly one `entityTypeId` or `entityTypeID` parameter with one positive integer value. Do not use arrays or objects for the record type. Encode user input when building query strings.

### NEW-0916-12: calendar events now support include: owner, host, attendee

A calendar event now declares three relations to employees, requestable through the `include` parameter on `GET /v1/calendar-events` and `GET /v1/calendar-events/{id}`: `owner` — the calendar owner (via the `ownerId` field), `host` — the meeting organiser (via `meetingHost`), `attendee` — the invitees (via `attendeeList`). Previously the entity declared no relations at all, so every name was rejected with `400 INVALID_INCLUDE` and the list of accepted names in the refusal text was empty.

A request such as `GET /v1/calendar-events/{id}?include=owner,host` answers `200` and places the employee cards under `_included`. A request without `include` works as before. The relation reads an employee card, so the key must carry the `user` permission — otherwise the request answers `403 SCOPE_DENIED`.

A name outside that list is still rejected, but the refusal now names the available relations: `Unknown include 'section'. Available: owner, host, attendee`. Calendar sections are deliberately not declared as a relation: Bitrix24 offers no read of a section by identifier, it is available only as a list.

### NEW-0916-13: employee custom fields via /v1/userfields/users

Employee custom fields (entity `users`, field-name prefix `UF_USR_`) can now be created and edited through the Vibecode API. Previously the platform could only read them: an employee field already arrived in `GET /v1/users/fields` with its type and value list, while creating the same field via `POST /v1/userfields/users` answered `400 UNKNOWN_ENTITY` listing the six CRM entities.

Six routes were added: `GET /v1/userfields/users` (list), `GET /v1/userfields/users/{id}` (single field), `POST /v1/userfields/users` (create), `PATCH /v1/userfields/users/{id}` (update), `DELETE /v1/userfields/users/{id}` (delete). The `user.userfield` scope is required; the bare `user` scope is not enough for this entity. The create body is the same as for CRM fields: `fieldName`, `userTypeId`, `label` and the other field properties. An employee field name carries the `UF_USR_` prefix and is passed in full — the platform does not prepend it.

`GET /v1/userfields/users/types` answers `400 UNSUPPORTED_ACTION`: Bitrix24 has no method listing employee field types, so `userTypeId` is stated explicitly on create. The existing behaviour of the six CRM entities on `/v1/userfields/{entity}` is unchanged in every response.

The same entity is now available in MCP: the `manage_userfield` tool accepts `entity: "users"`.

### FIX-0916-14: aggregation refusals explain the limit on reads without pagination

**Before**

In `POST /v1/{entity}/aggregate`, the `AGGREGATION_LIMIT_EXCEEDED` message suggested paginated exports even for entities that this aggregation path reads without pagination.

**After**

For these entities, the message explains the protection against unbounded reads for numeric and grouped aggregation. It suggests `count` without `groupBy`, without promising that counting reads no data. HTTP 422, the error code, and the 5000-record limit are preserved.

**Impact on integrators**

Only `error.message` changes for this case. Handle the refusal by its `AGGREGATION_LIMIT_EXCEEDED` code.

### BC-0916-15: previousKey in the application key replacement response

> Old format supported until: not provided

**Before**

`POST /v1/cowork/applications/{id}/key` returned `previousKey` on the `rotated` branch with a mandatory `graceUntil` date — the moment the previous key stops authenticating.

**After**

`previousKey` now carries `{ id, graceUntil, status }`. The `graceUntil` field can be `null`, and exactly then `status` is `BLOCKED`: the previous key was blocked, it was not working before the replacement either, and no grace period was granted. A live previous key behaves as before — a date and `status` `ACTIVE`. The response remains HTTP 201.

**What integrators should do**

Accept `null` in `graceUntil` and print the text by `status`: on `BLOCKED` the previous key already stopped working, there is nothing to wait for, and the application needs the new secret right now. Code that reads `graceUntil` as an always-present date breaks on such a response.

### FIX-0916-16: an application with a blocked key can be repaired by replacing it

**Before**

`POST /v1/cowork/applications/{id}/key` answered 409 `KEY_ROTATE_NOT_ACTIVE` when the personal key of the application was blocked. There was no way to free the slot: deletion was refused while a live server held the key, and a blocked key cannot be put back in service because the block is terminal. The only repair was re-creating the application.

**After**

The replacement goes through. The block is not lifted: the previous row stays blocked, it gets no one-day grace period, and the live access tokens it issued are revoked rather than moved to the new key. The refusal `KEY_ROTATE_NOT_ACTIVE` now means a revoked or expired key only. The replacement refusal texts have been rewritten: three of the four name the exact cabinet section and the action to take, while the platform-issued key refusal names no section — there is none in the cabinet — and tells you what to do instead: contact support. When those live tokens cannot be revoked, the replacement is not reported as success: the answer is 500 `KEY_ROTATE_REVOKE_FAILED` with `details.orphanKeyId` (the id of a key minted but handed to nobody) and no secret. One field decides what to do next. No `details.strandedSlots` — the slots were moved back to the previous key in full: revoke the key from `orphanKeyId` and retry with a NEW `Idempotency-Key`. The field is present — the rollback did not fully succeed and those categories still point at the orphan key: you must NOT retry and must not revoke it either — contact support. One entry there is not a slot category: `previousKeyGrace` means the previous key, blocked mid-replacement, kept the one-day expiry the rotation gave it and stops working at that deadline. A retry here is worse than useless: it replaces whatever the card points at NOW and can answer 201 with a working secret, while the leaked key's live access tokens are never revoked — the incident stays open behind a screen that says it is done.

**Impact on integrators**

The successful replacement response is unchanged apart from `previousKey` (a separate entry). A client that branched on 409 `KEY_ROTATE_NOT_ACTIVE` as "the key is blocked" must drop that branch: a blocked key is now replaceable, and this code means a revoked or expired key only.

### FIX-0916-17: POST /v1/keys/{id}/rotate accepts a blocked key

**Before**

Rotating a blocked key answered 403 `KEY_BLOCKED`. A key held by a live server could not be repaired at all: the server blocked deletion and this check blocked re-issuing.

**After**

The rotation goes through on the same terms as the application key replacement: the block stays, the previous row gets no grace period, and its live access tokens are revoked. When that revocation cannot be confirmed, the rotation is not reported as success: the endpoint answers 500 `KEY_ROTATE_REVOKE_FAILED` with `details.orphanKeyId` (the id of a key already minted but never handed over) and no raw secret is returned in that response. When the rollback itself did not fully succeed, the answer also carries `details.strandedSlots` — what still points at the orphan key; on a clean rollback that field is absent. One entry there is not a slot: `previousKeyGrace` means the previous key kept the one-day expiry the rotation gave it (which happens when the key was blocked mid-rotation and restoring its earlier expiry failed), so it stops working at that deadline. Unblocking is still impossible — `PATCH` on a blocked key answers 403 `KEY_BLOCKED` as before.

**Impact on integrators**

A client that used to get only a 403 on a blocked key now gets either a 201 or a 500 `KEY_ROTATE_REVOKE_FAILED`. Branch on `details.strandedSlots`: absent means the rollback completed — revoke the orphan key from `details.orphanKeyId` and retry — the endpoint supports no `Idempotency-Key`, so a retry is a plain new request, and it attempts the revocation again. Present means you must NOT retry: part of the estate stayed on the orphan key, and a retry replaces THAT one while the leaked key's live access tokens are never revoked — the answer may even be a success, but the compromise stays. That key must not be revoked while anything still points at it either — contact support.

### NEW-0916-18: department names in the members read

The `GET /v1/platform/cowork/members` response carries a new `departments` block — the Bitrix24 account's department directory, department id to name. One block per response rather than a field on every row: the tree belongs to the account, and an employee is not necessarily in it just once.

The `department` field on an employee row now carries a name when that name is unambiguous: the person belongs to exactly one department and it was found in the directory. For someone in two departments the field stays `null` — picking the "first" of them would present the result of an unpinned ordering as a fact.

A `departmentIdsKnown` field appears next to it — whether this employee's department membership was read at all. Without it an empty `departmentIds` is ambiguous: it arrives both when the person genuinely belongs to no department and when we know nothing about their departments — the row was missing from the Bitrix24 overlay, or the overlay did not arrive at all. The difference matters exactly where the list is grouped by department: with `departmentIdsKnown` equal to `false` the empty array means "unknown", and such an employee must not be filed under "no department". Resolve ambiguous cases from the row's `departmentIds` together with the `departments` dictionary, but only while `departmentIdsKnown` is `true`.

An empty object and `null` mean different things in the `departments` field. `departments` is `{}` when the company has no departments: department membership was read for every employee in the response, and none of them belongs to any department. `departments` is `null` when the names are unknown — the Bitrix24 overlay did not arrive, membership was not read for everyone, the directory could not be read, the rights were insufficient, or Bitrix24 did not answer in time. Collapsing the two answers into one value is not safe: in the first case there is nothing to show, in the second it is worth retrying later.

Existing fields are unchanged, and requests written before this entry keep working.

### NEW-0916-19: model catalog: capability filter for Cowork/Code keys

[GET /v1/models](/docs/ai/models) accepts an optional `capability` parameter —
an explicit selection of models by one declared capability, instead of scanning
the full list client-side.

The parameter is read only for a key carrying the `vibe:cowork` scope. For every
other key it is **ignored**: the response stays the same for any value, unknown
ones included. The set of accepted values is closed and grows together with the
capabilities the platform serves for Cowork/Code.

The response without the parameter is unchanged for every key.

### BC-0916-20: a whitespace-only application name is rejected on every write path

> Old format supported until: not provided

**Before**

A name made only of spaces, tabs or line breaks (for example `"   "`) used to pass the "at least one character" check. The application was created or renamed, and its menu item in Bitrix24 got a blank name.

**After**

Such a value is rejected with `400 VALIDATION_ERROR` before Bitrix24 is called at all — on every request that sets the application's display name or a placement label:

- [POST /v1/apps](/docs/apps/create) — `title` (neither the application nor its key is created);
- [PATCH /v1/apps/{id}](/docs/apps/update) — `title`;
- [POST /v1/apps/{id}/publish](/docs/apps/publish) — `catalogTitle`, as well as publishing from the wizard and editing the catalog card;
- `POST /v1/placements/bind` — the placement `title` (see [application placements](/docs/apps/placements)).

A name with at least one visible character is accepted as before and stored unchanged, including leading and trailing spaces. The rule does not apply to already stored values: publishing an application that carries an old blank name works exactly as before.

**What integrators should do**

Send `title` and `catalogTitle` with at least one visible character. If the name is generated automatically and may come out empty, substitute a meaningful default before sending the request. Pay particular attention to rename and re-publish flows — those used to accept a blank name.

### FIX-0916-21: a call to the account's previous address is now refused by name

**Before**

After a self-hosted Bitrix24 account moved to a new address, a key whose webhook still pointed at the previous one was refused by the origin guard — but the client got `500` with `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}}`: no cause, no next step.

**After**

The same call answers `409`:

```json
{"success":false,"error":{"code":"PORTAL_ADDRESS_CHANGED","message":"Portal address changed: this credential is issued for the portal's previous address. Re-issue the key webhook to continue"}}
```

The remedy is unchanged: re-issue the key's webhook (Keys → Re-issue). The key string, its id and its scopes stay the same.
