# Витрина приложений

Раздел даёт основу для экрана «мои приложения» в вашем продукте: одним запросом — и свои приложения человека, и доступные ему чужие. Внутри две операции: список и карточка одного приложения.

Другого способа собрать такой список нет: [`GET /v1/infra/servers`](/docs/infra/servers/list) ограничен текущим ключом, поэтому приложение, заведённое в личном кабинете, оттуда не видно. Каждая строка вдобавок несёт состояние, за которым иначе пришлось бы ходить отдельными запросами: сервер, сохранённые версии исходников, признак идущей операции и готовый адрес, по которому приложение открывают. Раздел только читает — создание, правка и удаление приложений в него не входят.

**Базовый URL:** `https://vibecode.bitrix24.tech/v1` | **Авторизация:** `X-Api-Key` | **Ключ:** API-ключ или ключ авторизации

[Что попадает в выдачу](#что-попадает-в-выдачу) | [Что несёт карточка](#что-несёт-карточка) | [Идентификаторы](#идентификаторы-что-с-чем-совпадает) | [Порядок и постраничный обход](#порядок-и-постраничный-обход) | [Ограничение частоты](#ограничение-частоты) | [Быстрый старт](#быстрый-старт) | [Полный пример](#полный-пример) | [Справочник эндпоинтов](#справочник-эндпоинтов) | [Коды ошибок](#коды-ошибок)

## Что попадает в выдачу

Выборка идёт по **владельцу ключа**, а не по самому ключу. Это главное отличие раздела от [Серверов](/docs/infra/servers): там выдача ограничена серверами текущего ключа, поэтому приложение, заведённое в личном кабинете, оттуда не видно — его сервер привязан к другому ключу того же человека. Здесь такое приложение есть, и выпуск нового ключа список не обнуляет.

Раздел работает с ключом, привязанным к порталу: подойдёт и API-ключ `vibe_api_…`, и ключ авторизации `vibe_app_…`. Токен сессии этому разделу не нужен. Под ключом авторизации выдача принадлежит владельцу ключа — тому, кто ключ выпустил, — а не сотруднику, от чьего лица работает приложение. Отдельного скоупа раздел не требует. Ключ без привязки к порталу до раздела не доходит — его отбивает проверка ключа с ответом `401 NO_PORTAL`; управляющий ключ получает `403 MANAGEMENT_KEY_NO_ENTITY_ACCESS`. Полный разбор — в «Кодах ошибок» ниже.

Что именно вернётся, задаёт параметр `scope`:

| `scope` | Что попадает |
|---------|--------------|
| `feed` | Общий список: свои приложения и все доступные вам чужие. Значение по умолчанию |
| `mine` | Только свои приложения |
| `shared` | Чужие приложения, доступные вам. Кроме тех, доступ к которым открыли лично вам, сюда попадают и приложения без сервера, открытые всему порталу |

Удалённые приложения не приезжают ни в одном `scope`.

У `feed` выборка ограничена сверху, потому что порядок для неё считает платформа, а не база. Поэтому `total` там — размер выборки, а не число приложений портала, и конверт списка говорит об этом признаком `truncated`. У `mine` и `shared` предела нет.

Отношение зрителя к каждой строке приходит в поле `viewerState`: `owner` — приложение ваше, `shared` — доступ открыли лично вам или через сервер, `usable` — у приложения нет сервера и оно открыто всему порталу, `requestable` — доступа нет. У приложения с сервером доступ определяет политика сервера, поэтому открытое всем такое приложение приходит как `shared`, а не `usable`. Отдельного признака «можно ли управлять» раздел не отдаёт — он только читает. Различие «своё против чужого» закрывает сам `viewerState`.

## Что несёт карточка

Строка списка и карточка одного приложения — одна и та же форма, поэтому клиенту не нужно знать про два разных объекта. Кроме названия, описания и иконки в ней адрес открытия и три блока состояния.

**`openUrl` и `openTarget`** — один ответ на вопрос «куда открывать это приложение». Есть сервер с адресом — `openUrl` отдаёт этот адрес, а `openTarget` равен `app`. Во всех остальных случаях оба поля приходят `null`, и пустое место здесь штатный ответ, а не сбой.

Пустые поля приходят и у приложения, зарегистрированного в одном из [мест встраивания](/docs/apps/placements) портала, то есть открывающегося внутри интерфейса Битрикс24: адреса, по которому открывают такое приложение, платформа пока не знает и выдумывать его вместо честного `null` не станет. Подставлять туда `server.url` самостоятельно нельзя — почему, разобрано в [Карточке приложения](./applications/get.md).

**`isEmbedded`** — признак, который эти два случая различает. Пустая пара открытия приходит и у встроенного приложения, и у того, которое ещё не опубликовано, а пользователю нужно показать разные вещи: «открывается внутри Битрикс24» против «пока негде открыть». Выводить встройку из наличия сервера НЕЛЬЗЯ: встроенное приложение без собственного сервера — обычное состояние, встройку сделали раньше, чем выложили код, и признак от сервера не зависит. Поле приходит всем, кто видит карточку, а не только владельцу.

**`server`** — сводка по серверу приложения: идентификатор, состояние, адрес, тип ресурса и признак `reachable`. `null`, если сервера нет или он удалён. Поле `reachable` отвечает не на тот вопрос, что `status`: `status` говорит, что контейнер поднят, а `reachable` — что он ещё и отвечает по сети. Расходятся они не в теории: контейнер бывает поднят, а туннель до него не поднят ни разу, и по одному `status` приложение выглядит рабочим.

Признак **уже включает** `status === "RUNNING"`: истинным при другом состоянии он не приходит никогда, поэтому конъюнктить его со `status` на клиенте не нужно.

⚠️ **У приложения на общем хосте галактики (`server.kind: "GALAXY_APP"`) вторая половина признака берётся с ХОСТА, а не с самого контейнера.** Связность там держит хост, у контейнера своего туннеля нет по устройству. То есть `true` означает сразу три вещи: контейнер работает, хост работает, туннель хоста поднят. Практическое следствие: только что созданный контейнер, который ещё не дошёл до `RUNNING` (а доходит он до него лишь после первой загрузки исходников), приедет с `reachable: false` даже при совершенно живом хосте. Это «контейнер пока не поднялся», а не «хост недоступен», и по одному этому полю два случая не различить — смотрите `status`.

**`iconUrl`** — относительный путь без хоста, например `/api/app-icons/7f3…?v=1755500000000`, или `null`, если указателя на иконку у карточки нет. Раздача анонимная: ни ключа, ни подписи, ни срока жизни у ссылки нет, поэтому путь достаточно склеить с базовым URL API и вставить прямо в `<img src>` — проксировать её через свой сервер не нужно. Отдаётся PNG 256×256 с `Cache-Control: public, max-age=300, must-revalidate` и ETag, так что кэш карточек не ломается: через пять минут клиент получит дешёвый `304`. Плейсхолдер для `null` рисует клиент — платформа иконку не придумывает.

**`sources`** — сводка по сохранённым версиям исходников. Поле `latestVersionId` приходит в форме `v<N>` — ровно в той, которую принимает [`GET /v1/infra/servers/:id/sources/:versionId/download`](/docs/source-storage). Это не первичный ключ записи: подставив его в адрес скачивания, вы получите последнюю версию, и отдельный запрос за списком версий не нужен.

**`activeOperation`** — идущая операция над сервером: вид, шаг и время старта. И здесь важна оговорка, которая экономит часы отладки: **`null` означает «операции с сохранённой записью нет», а не «с приложением ничего не происходит»**. Признак честен для выкладки, починки, изменения тарифа сервера и переноса контейнера между галактиками — остальные действия платформа не журналирует, и в этом поле они не появляются. Третье состояние — `status: "unknown"`: операция начиналась, а её исход неизвестен, и ждать её завершения бессмысленно.

Оба последних блока наполняются только тому, кто приложением управляет. Зрителю, которому приложение просто открыли, `sources` приходит пустым (`hasVersions: false`, оба поля `null`), а `activeOperation` — `null`. Форма ответа при этом не меняется, поэтому пустая сводка сама по себе не значит «версий нет». Причин у неё три, и снаружи они выглядят одинаково: у приложения нет сервера, версий действительно нет, или данные вам не отдаются, потому что приложение чужое.

## Идентификаторы: что с чем совпадает

Раздел возвращает два идентификатора, и оба ведут в другие разделы API — но не туда, куда можно решить по имени поля.

**`server.id` — тот же идентификатор, что принимают пути [Серверов](/docs/infra/servers).** Подставляйте его в `GET /v1/infra/servers/:id` и в загрузку исходников напрямую. Одна оговорка: совпадает формат, а не право доступа — та ручка ограничена серверами вызывающего ключа, поэтому сервер, который витрина показала (она выбирает по владельцу ключа, а не по ключу), может ответить там `404`.

**`id` приложения и `id` из [`GET /v1/apps`](/docs/apps) — РАЗНЫЕ значения разных сущностей.** Здесь это карточка приложения, там — регистрация OAuth-приложения на портале Битрикс24; у них даже формат разный. Мост между ними существует в платформе, но витрина его намеренно не отдаёт. Практическое следствие: подставив `id` из этого раздела в пути `/v1/apps/...`, вы получите `404`, а не чужие данные.

⚠️ **Разные имена одного смысла.** Название приложения здесь приходит в `name`, а в `GET /v1/apps` заголовок регистрации называется `title`. Это два независимых поля двух сущностей: они не синхронизируются и могут расходиться, поэтому переносить одно в другое нельзя.

⚠️ **Исходники: два пути, и `:appId` в первом — не то, что кажется.** Запись версии идёт в `POST /v1/apps/:appId/sources`, где `:appId` — идентификатор OAuth-приложения, а НЕ приложения из этого раздела и не сервера. Чтение и скачивание версии идут по серверу: `GET /v1/infra/servers/:id/sources/:versionId/download`, и вот туда подставляется `server.id` отсюда вместе с `sources.latestVersionId`. Оба пути живые, один другой не заменяет.

## Порядок и постраничный обход

Порядок назван здесь целиком, потому что от него зависит, можно ли обойти список по страницам и получить каждое приложение ровно один раз.

| `scope` | Порядок |
|---------|---------|
| `feed` | Закреплённые вами → свои → чужие. Внутри группы — по `updatedAt` от свежих к старым, при равных метках — по `id` |
| `mine`, `shared` | По `createdAt` от свежих к старым, при равных метках — по `id` |

⚠️ **`updatedAt` двигается только правкой карточки — выкладка её не трогает.** То есть «свежесть» в ленте означает «когда карточку меняли», а не «когда приложение выкладывали»: только что выложенное приложение окажется там, где было. Когда нужна свежесть кода, смотрите `sources.latestSavedAt`.

Ключ `id` в конце — не формальность. У приложений, созданных пакетно, метки времени совпадают, и без вторичного ключа две страницы одного обхода могли бы пересечься или пропустить приложение: порядок внутри группы с равными метками база не обещает. С ним обход воспроизводим.

## Ограничение частоты

| Операция | Лимит на флот | Доля одной реплики сегодня |
|----------|---------------|----------------------------|
| [`GET /v1/applications`](/docs/applications/list) | 60 запросов в минуту | 20 |
| [`GET /v1/applications/:id`](/docs/applications/get) | 120 запросов в минуту | 40 |

⚠️ **Числа в левом столбце — суммарный лимит платформы, а не тот, который увидит ваш клиент.** Запросы обслуживает несколько реплик бэкенда, каждая держит свою долю лимита и свой счётчик, независимый от соседей: сегодня реплик три, поэтому одна реплика пропускает 20 запросов списка в минуту и 40 запросов карточки. Полные 60 и 120 достигаются, только если запросы равномерно разошлись по репликам; клиент, у которого соединение держится на одной реплике, упрётся в её долю раньше.

Поэтому **планируйте по заголовку, а не по таблице**: `X-RateLimit-Limit` приходит с фактическим лимитом обслужившей реплики, `X-RateLimit-Reset` — с числом секунд до конца окна. Число реплик может меняться без записи в журнале изменений, а заголовок не врёт никогда.

**Счётчик — на владельца ключа в рамках портала, а не на IP-адрес и не на отдельный ключ.** Это важно в двух направлениях: несколько ключей одного человека делят один счётчик (выпуск нового ключа лимит не обнуляет), а сотрудники одного портала друг другу не мешают — даже когда все их запросы уходят в интернет через один адрес. У списка и карточки счётчики раздельные.

Превышение приходит как `429` с кодом `RATE_LIMITED` и заголовком `Retry-After` (целые секунды до конца окна).

⚠️ **`429` бывает не только про частоту, поэтому ветвиться нужно по `error.code`, а не по статусу.** Суточная квота бесплатных вызовов ключа отвечает тем же статусом, но кодом `QUOTA_EXCEEDED`, и ожидание её не лечит — нужен платный тариф. Клиент, который любой `429` показывает как «слишком часто, подождите», на исчерпанной квоте будет ждать бесконечно; клиент, который любой `429` показывает как «квота исчерпана», напугает человека на обычном всплеске частоты.

## Быстрый старт

Свои приложения одним запросом:

```bash
curl -H "X-Api-Key: YOUR_API_KEY" \
  "https://vibecode.bitrix24.tech/v1/applications?scope=mine&limit=20"
```

```json
{
  "success": true,
  "data": [
    {
      "id": "cmsw806qi0000tdskiw2aegii",
      "name": "Отчёт по сделкам",
      "type": "PERSONAL",
      "viewerState": "owner",
      "pinned": false,
      "author": { "name": "Автор приложений" },
      "isEmbedded": false,
      "openUrl": "https://app-05b67cf7.vibecode.bitrix24.tech",
      "openTarget": "app",
      "server": {
        "id": "5cbb50f9-f95f-4ddf-ba3b-b771209cb6fb",
        "status": "RUNNING",
        "url": "https://app-05b67cf7.vibecode.bitrix24.tech",
        "kind": "STANDALONE",
        "reachable": true,
        "lastDeployedAt": "2026-08-16T19:50:41.302Z"
      },
      "sources": { "hasVersions": true, "latestVersionId": "v2", "latestSavedAt": "2026-08-16T19:41:18.818Z" },
      "activeOperation": { "kind": "deploy", "status": "running", "step": "build", "startedAt": "2026-08-16T19:50:33.822Z" }
    }
  ],
  "total": 2,
  "page": 1,
  "limit": 20,
  "truncated": false
}
```

Показаны одно приложение и основные его поля. Полный разбор — [Карточка приложения](./applications/get.md).

## Полный пример

Скрипт собирает все свои приложения постранично, показывает те, над которыми идёт операция, и берёт у одного из них адрес последней версии исходников.

```javascript
const BASE = 'https://vibecode.bitrix24.tech/v1'
const API_KEY = process.env.VIBE_API_KEY

async function api(path) {
  const res = await fetch(`${BASE}${path}`, { headers: { 'X-Api-Key': API_KEY } })
  const body = await res.json()
  if (!res.ok) throw new Error(`${path}: ${body.error.code} — ${body.error.message}`)
  return body
}

// 1. Своя витрина: обходим страницы, пока не соберём все приложения.
const applications = []
let page = 1
let total = 0
do {
  const chunk = await api(`/applications?scope=mine&page=${page}&limit=100`)
  applications.push(...chunk.data)
  total = chunk.total
  page += 1
} while (applications.length < total && page <= 100)

console.log(`Приложений: ${applications.length} из ${total}`)

// 2. Приложения, над которыми прямо сейчас идёт операция.
for (const app of applications.filter(a => a.activeOperation !== null)) {
  const { kind, status, step, startedAt } = app.activeOperation
  console.log(`${app.name}: ${kind} — ${status}, шаг ${step ?? 'не сообщён'}, старт ${startedAt}`)
}

// 3. Карточка приложения: куда открывать и адрес последней версии исходников.
const target = applications.find(a => a.server !== null && a.sources.hasVersions)
if (target) {
  const { data: card } = await api(`/applications/${target.id}`)
  console.log(`${card.name}: открывать ${card.openUrl ?? 'негде'} (${card.openTarget ?? 'адреса нет'})`)
  console.log(`Сервер ${card.server.status}, отвечает по сети: ${card.server.reachable}`)
  console.log(`Версия ${card.sources.latestVersionId} от ${card.sources.latestSavedAt}`)
  console.log(`Скачать: ${BASE}/infra/servers/${card.server.id}/sources/${card.sources.latestVersionId}/download`)
}
```

Вывод на портале из примера:

```
Приложений: 2 из 2
Отчёт по сделкам: deploy — unknown, шаг build, старт 2026-08-16T19:50:33.822Z
Отчёт по сделкам: открывать https://app-05b67cf7.vibecode.bitrix24.tech (app)
Сервер RUNNING, отвечает по сети: true
Версия v2 от 2026-08-16T19:41:18.818Z
Скачать: https://vibecode.bitrix24.tech/v1/infra/servers/5cbb50f9-f95f-4ddf-ba3b-b771209cb6fb/sources/v2/download
```

## Операции

- [Список приложений](./applications/list.md) — `GET /v1/applications`
- [Карточка приложения](./applications/get.md) — `GET /v1/applications/:id`

## Справочник эндпоинтов

| Метод | Путь | Описание |
|-------|------|----------|
| GET | [/v1/applications](/docs/applications/list) | Приложения владельца ключа: свои, открытые коллегами или всё вместе |
| GET | [/v1/applications/:id](/docs/applications/get) | Одно приложение: сервер, сохранённые исходники, идущая операция |

## Коды ошибок

| HTTP | Код | Описание |
|------|-----|----------|
| 400 | `INVALID_SCOPE` | Значение `scope` вне набора `mine`, `shared`, `feed` |
| 401 | `NO_PORTAL` | Ключ не привязан к порталу. Это и есть ответ на «ключ без портала» — раздел до обработчика такой запрос не пускает |
| 403 | `MANAGEMENT_KEY_NO_ENTITY_ACCESS` | Управляющий ключ: разделы с сущностями ему недоступны, нужен личный ключ или ключ авторизации |
| 403 | `PORTAL_KEY_REQUIRED` | Страховка самого раздела. Срабатывает только на повреждённой записи ключа (нет владельца); обычный «ключ без портала» приходит как `401 NO_PORTAL` выше |
| 403 | `FORBIDDEN` | Приложение существует, но доступа к нему у зрителя нет |
| 404 | `APPLICATION_NOT_FOUND` | Приложения с таким `id` на портале ключа нет |
| 401 | `MISSING_API_KEY` | Не передан заголовок `X-Api-Key` |
| 401 | `INVALID_API_KEY` | Неверный или просроченный API-ключ |
| 429 | `RATE_LIMITED` | Превышен лимит запросов. Есть `Retry-After` — повтор после указанной паузы штатен |
| 429 | `QUOTA_EXCEEDED` | Исчерпана суточная квота бесплатных вызовов ключа. Тот же статус, но ожидание не поможет — это не про частоту |

Полный список общих ошибок API — [Ошибки](/docs/errors).

## Смотрите также

- [Серверы](/docs/infra/servers)
- [Хранилище исходников](/docs/source-storage)
- [Ключи и авторизация](/docs/keys-auth)
- [Лимиты и оптимизация](/docs/optimization)
- [Ошибки](/docs/errors)
