
## Publish app

`POST /v1/apps/:id/publish`

Moves the app to `PUBLISHED`, binds placements to the Bitrix24 account, and makes the app visible to all employees.

## Parameters

| Parameter | Type | Required | Description |
|----------|-----|:-----:|---------|
| `id` (path) | string | yes | App identifier. List: `GET /v1/apps` |

## Request fields (body)

The body is optional. With an empty body, values are taken from the app record — this is the primary scenario for re-publishing without changes. If you send a body, add the `Content-Type: application/json` header — without it, a request with a body returns `413 PAYLOAD_TOO_LARGE`.

| Field | Type | Required | Description |
|------|-----|:-----:|----------|
| `catalogTitle` | string | no | Title in the Bitrix24 account catalog, not whitespace only. If omitted, the app's current value is used |
| `catalogDescription` | string | no | Catalog description |
| `catalogIcon` | string | no | Catalog icon code |
| `appUrl` | string | no | App address: a Black Hole server, a galaxy container, or an external https address. If omitted, the app's current `appUrl` is used |
| `placements` | string[] | no | Placement codes. List of allowed codes — [Available placements](/docs/apps/placements/available). If omitted, the app's current set is used |
| `sourceVersionId` | string | no | Specific source snapshot to publish, format `v<number>`. Applies when [source storage](/docs/source-storage) is enabled |
| `sourceServerId` | string | no | The server whose sources are published — the identifier from `POST /v1/infra/servers/:id/deploy`. Needed when the sources were stored by the deploy auto-save on a server that does not belong to this app's OAuth key: such a snapshot is kept with the server, not with the app. The `X-Source-Server` header means the same. The platform never picks a server for you — without this field the check looks for the app's own snapshot. Applies when [source storage](/docs/source-storage) is enabled |

## Examples

### curl — personal key

```bash
curl -X POST "https://vibecode.bitrix24.com/v1/apps/YOUR_APP_ID/publish" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "placements": ["CRM_DEAL_DETAIL_TAB"] }'
```

### curl — OAuth application

```bash
curl -X POST "https://vibecode.bitrix24.com/v1/apps/YOUR_APP_ID/publish" \
  -H "X-Api-Key: YOUR_APP_KEY" \
  -H "Authorization: Bearer USER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "placements": ["CRM_DEAL_DETAIL_TAB"] }'
```

### JavaScript — personal key

```javascript
const res = await fetch(
  'https://vibecode.bitrix24.com/v1/apps/YOUR_APP_ID/publish',
  {
    method: 'POST',
    headers: {
      'X-Api-Key': 'YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ placements: ['CRM_DEAL_DETAIL_TAB'] }),
  }
)
const body = await res.json()
if (!body.success) throw new Error(body.error.code)
// body.warnings — non-fatal advisory entries; rejected binds return PLACEMENT_BIND_FAILED
console.log(body.data.placements)
```

### JavaScript — OAuth application

```javascript
const res = await fetch(
  'https://vibecode.bitrix24.com/v1/apps/YOUR_APP_ID/publish',
  {
    method: 'POST',
    headers: {
      'X-Api-Key': 'YOUR_APP_KEY',
      'Authorization': 'Bearer USER_SESSION_TOKEN',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ placements: ['CRM_DEAL_DETAIL_TAB'] }),
  }
)
```

## Response fields

| Field | Type | Description |
|------|-----|----------|
| `success` | boolean | `true` on successful publication |
| `data` | object | The app after publication — the same set of fields returned by [Application data](/docs/apps/get) |
| `data.placements` | string[] | Bound placements |
| `data.catalogStatus` | string | After publication — `PUBLISHED` |
| `data.publishedAt` | string \| null | Publication date, ISO 8601 |
| `warnings` | string[] | Advisory entries that do not cancel the publication. The entry about removing extra placements differs depending on the rollout — see the note under the error table. The `sourceServerId` you passed went unused because source storage is disabled for the Bitrix24 account. The published server version was left without indefinite retention — that entry then carries a ready-made `PATCH` you can use to tag it. The catalog title or description lost non-ASCII characters in transit — that entry names the field. The set of entries is open: show an unfamiliar one as is rather than discarding it |

## Response example

The `data` field mirrors the app object with a filled `placements` array:

```json
{
  "success": true,
  "data": {
    "id": "33c4d5e6-f7a8-49b0-1234-5c6d7e8f9012",
    "title": "Sales dashboard",
    "description": null,
    "scopes": ["crm", "user", "placement"],
    "handlerUrl": "https://vibecode.bitrix24.com/v1/bitrix-handler",
    "appUrl": "https://app-abc12345.vibecode.bitrix24.com",
    "redirectUris": [
      "https://vibecode.bitrix24.com/oauth/complete",
      "http://localhost"
    ],
    "bitrixClientId": "local.7c3d4e5f6a7b80.55556666",
    "prefix": "vibe_app_local_7c3",
    "suffix": "6666",
    "authorId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "authorBitrixUserId": 42,
    "authorBitrixUserIdSource": "member",
    "portalId": "8b1f0e2a-3c4d-5e6f-7a8b-9c0d1e2f3a4b",
    "createdAt": "2026-06-24T09:12:45.781Z",
    "updatedAt": "2026-06-24T10:03:18.204Z",
    "placements": ["CRM_DEAL_DETAIL_TAB"],
    "catalogStatus": "PUBLISHED",
    "publishedAt": "2026-06-24T10:03:18.204Z"
  }
}
```

## Error response example

400 — the OAuth key lacks the `placement` scope:

```json
{
  "success": false,
  "error": {
    "code": "MISSING_SCOPE",
    "message": "OAuth API key must have the placement scope to publish (catalog publishing binds placements on Bitrix24)."
  }
}
```

## Errors

| HTTP | Code | Description |
|------|-----|----------|
| 403 | `INFRA_FORBIDDEN_FOR_COWORK_KEY` | The call was made with a Cowork key — such a key works with data only and cannot perform write operations. For what to do, see [Project key for deploy](/docs/cowork/deploy-key) |
| 404 | `NOT_FOUND` | App not found or belongs to another portal |
| 403 | `FORBIDDEN` | The call was made with an authorization key `vibe_app_…` issued for a different application. Such a key publishes only its own application, even when both applications were created by the same author |
| 403 | `FORBIDDEN` | Request is not from the app author and not from a Bitrix24 account administrator |
| 403 | `WRITE_BLOCKED_READONLY_KEY` | The key does not change Bitrix24 data, and the app does not belong to it. An app belongs to the key when this same key created it; for Cowork seat keys, when any key of the same seat created it; for an authorization key, when it is the key's own app. An app created in the dashboard never belongs to such a key |
| 403 | `WRITE_BLOCKED_READONLY_KEY` | The key does not change Bitrix24 data, and publishing affects a placement in an employee work screen or placements on a handler other than the platform handler. Both added and removed placements count: `placements: []` on an app with a bound work-screen placement is refused as well. A left menu item on the platform handler is published and removed without a refusal |
| 403 | `WRITE_BLOCKED_READONLY_KEY` | The key does not change Bitrix24 data, and the body carries `appUrl` while the app has a key allowed to write to Bitrix24 in any status, or has no keys at all |
| 409 | `ALREADY_PUBLISHED` | App is already in `PUBLISHED` status |
| 400 | `VALIDATION_ERROR` | A body field failed validation — `message` lists the fields and the reasons |
| 400 | `VALIDATION_ERROR` | The `X-Source-Server` header was sent more than once. Send it once with a single server ID |
| 400 | `VALIDATION_ERROR` | The `X-Source-Server` value is not a server ID in UUID form |
| 400 | `NO_OAUTH_KEY` | The app has no associated OAuth key |
| 400 | `MISSING_SCOPE` | The OAuth key lacks the `placement` scope |
| 409 | `SNAPSHOT_REQUIRED` | Source storage is enabled, but there is no saved snapshot. The snapshot's age does not affect the outcome. The body carries a `hint`: without `sourceServerId` it points at `POST /v1/apps/:id/sources` and explains how to publish sources from a server. With `sourceServerId` it points at `POST /v1/infra/servers/:id/sources`. The `hint.reason` field distinguishes two refusal causes — the app has no snapshot, or the named server has none. Two further values arrive only when the platform checks the snapshot's age. The hint's field set is described in [Source storage](/docs/source-storage) |
| 404 | `SERVER_NOT_FOUND` | `sourceServerId` names a server that does not exist, belongs to another portal, or has been deleted |
| 403 | `NOT_AUTHORIZED` | `sourceServerId` names a server whose sources this request may not use. Rights over the app do not extend here — the request must come from the server-owner key, a personal key of the same user, or a Bitrix24 account administrator |
| 400 | `NO_USER_TOKEN` | The app is not authorized on the portal via OAuth. Complete authorization and retry publication — [Application user authorization](/docs/keys-auth/oauth). The body carries a `hint` with three fields: `requiredAction` — what to do, `docsUrl` — this page, `oauthDocsUrl` — the authorization sequence. This hint has its own field set, which differs from the `SNAPSHOT_REQUIRED` hint |
| 400 | `TITLE_TOO_LONG_FOR_CATALOG` | The resolved title is longer than 100 characters. This happens when `catalogTitle` is not sent and the app's `title` exceeds the catalog limit |
| 400 | `APP_URL_UNSAFE` | An address outside the platform must be a public `https://` address: no `http://`, no `localhost` or private network, no credentials in the URL |
| 403 | `EXTERNAL_URL_ACTOR_NOT_ALLOWED` | An app whose address is not served by a live server of this portal on the platform (an external host or a domain whose server is gone) cannot be published with an app key, a [Connect partner](/docs/partner-connect) key, a server-linked key, a Cowork key, a key with a purpose, a management key, or the working key of an agent or managed bot. Use the Vibecode dashboard or a personal key |
| 403 | `EXTERNAL_URL_DISABLED` | Publishing apps with an address outside the platform is unavailable for this Bitrix24 account |
| 409 | `APP_URL_CONFLICT` | The app address or status changed concurrently. Re-read the app and retry |
| 422 | `APP_HOST_NOT_ALLOWED_HERE` | `appUrl` points at a named address of the platform app domain (`<name>.<domain>`, not `app-…`) whose name is not bound to a server of your Bitrix24 account. Nothing is stored and Bitrix24 is not called. A name of your own server is accepted and stored as that server's `app-…` address |
| 502 | `PLACEMENT_UNBIND_FAILED` | Bitrix24 did not confirm the removal of the extra placements. The app was NOT published and the catalog metadata from this request was not saved. The codes left on the account are listed in `error.placements`. The app's `placements` field has already been rewritten by then — re-read the app. Arrives only once the removal check is enabled — see the note below |
| 502 | `PLACEMENT_BIND_FAILED` | Bitrix24 did not confirm one or more requested binds. The app is NOT published. `error.placements` contains the unique failed-bind set, while `error.failures` carries the safe Bitrix24 code and status for every rejected transport attempt. Unconfirmed removals from the same request are listed separately in `error.failedUnbinds`. The raw Bitrix24 response text and the developer-key URL are never returned. The `placements` field already contains the successfully bound subset — re-read the app before retrying |
| 503 | `NETWORK_DEVKEY_REQUIRED` | The Bitrix24 account is switched to developer-key transport and the app author holds no such key. A retry will not help — ask the author to reconnect the account. Arrives only once the removal check is enabled — see the note below |

Full list of common API errors — [Errors](/docs/errors).

### `PLACEMENT_BIND_FAILED` failure fields

| Field | Type | Description |
|------|-----|----------|
| `error.placements` | string[] | Unique list of placements whose bind was not confirmed by any transport |
| `error.failures` | object[] | One entry for each rejected transport attempt; entry order is not part of the contract |
| `error.failures[].placement` | string | Placement code from the request |
| `error.failures[].transport` | string | Rejected attempt channel: `developer_key` or `oauth` |
| `error.failures[].bitrixCode` | string | Safe diagnostic Bitrix24 code, not a stable enum for client branching. The platform has already decided whether to use the OAuth fallback before returning this response; retain the value for diagnostics and act on the outer `error.code` |
| `error.failures[].bitrixStatus` | number | Bitrix24 response HTTP status; `0` when the transport did not provide one |
| `error.failedUnbinds` | string[] | Optional old placements whose removal was also unconfirmed in the same request |

**The account-side check for removing extra placements is currently rolling out per account, so there are two cases:**

- **While the capability is not enabled on the account** — publication returns `200` even when the extra placements could not be removed on the account. The code that was not removed STAYS in the app's `placements`, and the reason arrives as a `Failed to unbind <code>` entry in `warnings`. That entry is the only sign the list has drifted from the account, so for now you have to parse `warnings`. The codes `PLACEMENT_UNBIND_FAILED` and `NETWORK_DEVKEY_REQUIRED` do not occur on this endpoint.
- **Once enabled** — an unconfirmed removal returns `502 PLACEMENT_UNBIND_FAILED` with the list of codes in `error.placements`, and the app is not published at all. A `200` carrying a placement that was not removed does not occur on this path — on a successful response the removal entries in `warnings` are limited to confirmation notes: which transport confirmed the removal, and whether the second transport refused.

A rejected requested bind is independent of that rollout flag: on either side it returns `502 PLACEMENT_BIND_FAILED` and does not move the app to `PUBLISHED`. When removal of an old placement is also unconfirmed in the same request, the outer code still remains `PLACEMENT_BIND_FAILED` and the old placement is listed in `error.failedUnbinds`. If Bitrix24 rejects the developer key with a credential-only HTTP 401 and the app has an OAuth token, publication first retries the bind through OAuth; a successful fallback remains HTTP 200 and is recorded in the audit log. A response that explicitly names a non-credential cause, such as an account-policy refusal or a missing scope, remains authoritative at every HTTP status and is never retried through OAuth.

**A `502` here does not mean "nothing changed".** The app's `placements` field is written BEFORE the refusal is raised and already reflects the actual state on the account: the codes removed successfully are gone from it, and the unconfirmed ones remain. So re-read the app through [Application data](/docs/apps/get) before retrying. A retry without the `placements` field publishes exactly the set the app holds now — including the placement you believed removed. To remove it, retry with an explicit `placements` that omits that code.

## Known specifics

- **OAuth authorization provides the primary or fallback transport.** On an account without a developer key, placement binding uses the OAuth token. When developer-key transport is selected and Bitrix24 rejects that credential with a credential-only HTTP 401, the same OAuth token becomes the fallback channel. Named account-policy and missing-scope refusals are authoritative regardless of HTTP status and never enter that fallback. With no usable transport, publication returns `NO_USER_TOKEN` or `NETWORK_DEVKEY_REQUIRED`. The authorization sequence — [Application user authorization](/docs/keys-auth/oauth).
- **Signing in on the app's own page does not create the app token.** The "Sign in with Bitrix24" button at your app address grants access to the app — that is a different surface, and it has no effect on `NO_USER_TOKEN` at publication. The token appears only at the OAuth consent step **on the Bitrix24 account side**: the authorization linked above, installing the app on the account, or the first time an embedded placement is opened. If publication still returns `NO_USER_TOKEN` after signing in on the app page, the account-side authorization never happened.
- **The token is long-lived and renews itself.** Authorization is needed once, not before every publication: the platform refreshes the token with its refresh token. Re-authorization is required only if access was revoked on the account or the OAuth application was recreated.
- **A deploy's `autoSaved: true` does not always satisfy the publish check.** The deploy auto-save stores the sources with the server you deployed to. If that server does not belong to the published app's OAuth key — for example, you deployed with a personal `vibe_api_` key — the snapshot is kept with the server, and the publish check, which looks at the app's own snapshots by default, does not see it. Pass `sourceServerId` for that same server and publication will take the sources from there — saving them again through `POST /v1/apps/:id/sources` is unnecessary. The server identifier is the one in the deploy URL. The server's snapshots are listed by `GET /v1/infra/servers/:serverId/sources`.
- **The placement title is the app name.** The left-menu item and the CRM tabs are labelled with the resolved `catalogTitle` — there is no separate name for the menu. Renaming the app through [updating the application](/docs/apps/update) also changes the placement labels.
- **The app already has a left-menu item.** The app appears in the account menu as soon as it is [created](/docs/apps/create), without publication and without placements. The `LEFT_MENU` placement adds a second menu item with its own address on the account. In Vibecode it is still one app, and [bound placements](/docs/apps/placements/list) show a single `LEFT_MENU` entry. If you do not need the second item, do not pass `LEFT_MENU` in `placements`. For a published app, remove the extra item by [unbinding](/docs/apps/placements/unbind) the `LEFT_MENU` code or by [updating the application](/docs/apps/update) with `placements` that omit it. The item that appeared at creation stays in place.
- **Unpublished apps can be published again.** Publication is accepted in `UNPUBLISHED` status too — the app returns to `PUBLISHED` with the same or new placements. Catalog metadata is preserved on unpublish, so with an empty body the catalog is restored without refilling it.
- **Every requested placement must bind.** Codes are bound separately, so some can already be registered on the account. If any bind is not confirmed, publication returns `502 PLACEMENT_BIND_FAILED`, the app does not become `PUBLISHED`, and the successfully bound codes already remain in its `placements` field.

## See also

- [Applications](/docs/apps)
- [Application user authorization](/docs/keys-auth/oauth)
- [Unpublish](/docs/apps/unpublish)
- [Update an application](/docs/apps/update)
- [Application data](/docs/apps/get)
- [Source storage](/docs/source-storage)
- [Bitrix24 event subscriptions](/docs/infra/event-subscriptions)
