# Сохранить снапшот

Явное сохранение архива исходников: когда нужно пометить версию тегом, зафиксировать промежуточный результат без развёртывания или подготовить откат. При деплое снапшот создаётся [автоматически](/docs/source-storage/auto-save).

Обзор хранилища и справочник эндпоинтов — [Хранилище исходного кода](/docs/source-storage).

## Сохранение снапшота

`POST /v1/apps/:id/sources`

Принимает сырые байты архива исходного кода в теле запроса. Формат определяется заголовком `Content-Type`. Метаданные (теги, комментарий, имя файла) передаются через дополнительные заголовки.

> **Только бинарная загрузка (контракт v2).** Эндпоинт принимает **сырые байты архива** в теле запроса (`--data-binary`). `multipart/form-data` **больше не поддерживается** — такой запрос возвращает `400 INVALID_CONTENT_TYPE`. Ранние версии клиентов, которые слали `multipart/form-data` с полями формы (`-F "tag=manual"`), нужно обновить: тело — это сами байты архива, а **теги, комментарий и имя файла передаются заголовками `X-Tags` / `X-Note` / `X-Filename`** (не полями формы и не query-параметрами). MCP-клиенты используют инструмент `save_sources`, который сам упаковывает и отправляет архив в правильном формате.

### Параметры пути

| Параметр | Тип | Описание |
|----------|-----|----------|
| `id` (path) | UUID | Идентификатор приложения. Получить — [`GET /v1/apps`](/docs/apps/list). |

### Заголовки запроса

| Заголовок | Обязательный | Описание |
|-----------|--------------|----------|
| `Content-Type` | да | Формат архива. Допустимые значения: `application/gzip`, `application/x-tar`, `application/zip`, `application/octet-stream`. Любое другое значение → `400 INVALID_CONTENT_TYPE`. Заявленный формат сверяется с первыми байтами архива: прямое противоречие (`application/gzip` против zip-байт и наоборот) → `415 UNSUPPORTED_ARCHIVE_FORMAT`. |
| `Content-Length` | да | Размер тела в байтах. Загрузка без него (`Transfer-Encoding: chunked`) не принимается → `411 MISSING_CONTENT_LENGTH`. |
| `X-Filename` | нет | Произвольное имя файла для отображения (например, `app-v1.tar.gz`). Если не указан — имя выводится из `Content-Type` (например, `source.tar.gz` для `application/gzip`). Символы вне набора `a-zA-Z0-9._-` → `400 INVALID_FILENAME`. |
| `X-Tags` | нет | Теги через запятую. Распознаются `manual` и `published` — они защищают версию от автоматической очистки. |
| `X-Note` | нет | Произвольный комментарий, который сохраняется в записи о версии. |
| `X-AI-Session-Id` | нет | Идентификатор AI-сессии. Группирует снапшоты в манифесте по сессии. |

### Тело запроса

Сырые байты архива, не `multipart/form-data`. Максимальный размер — 500 МБ.

Тело принимается потоком, поэтому длину нужно объявить заранее: запрос обязан нести `Content-Length`. Загрузка чанками (`Transfer-Encoding: chunked`, без длины) отклоняется кодом `411 MISSING_CONTENT_LENGTH` — платформа не станет копить архив в памяти, чтобы измерить его за клиента. Заявленная длина сверх потолка отбивается `413` ещё до чтения тела.

В `curl` длина проставляется сама, если тело берётся из файла (`--data-binary @app.tar.gz`). Ручной поток из пайпа (`... | curl --data-binary @-`) длины не даёт — сохраните архив во временный файл или задайте `Content-Length` явно.

### Ответ при успешном сохранении

`HTTP 201`:

```json
{
  "success": true,
  "data": {
    "versionId": "v3",
    "id": "cmszu9y12190j4bmj3hhsno39",
    "filename": "2026-05-21T10-15-30-000Z-v3.tar.gz",
    "contentType": "application/gzip",
    "sha256": "a3f5d8b2c1e9f4...",
    "size": 184320,
    "timestamp": "2026-05-21T10:15:30.000Z",
    "tags": [],
    "note": null,
    "deduplicated": false
  }
}
```

### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| `data.versionId` | string | Идентификатор версии вида `v<N>`. |
| `data.id` | string | Внутренний идентификатор записи снапшота. Приходит только в ответе на сохранение: список версий и метаданные одной версии этого поля не отдают. |
| `data.filename` | string | Имя файла архива в хранилище. |
| `data.contentType` | string | Тип архива: `application/gzip`, `application/zip`, `application/x-tar` или `application/octet-stream`. |
| `data.sha256` | string | SHA-256 содержимого архива. Используется для дедупликации в пределах владельца. |
| `data.size` | number | Размер архива в байтах. |
| `data.timestamp` | string | Время сохранения (ISO 8601, UTC). |
| `data.tags` | string[] | Активные теги версии: `manual`, `published`. |
| `data.note` | string \| null | Комментарий из заголовка `X-Note` или `null`. |
| `data.deduplicated` | boolean | `true`, если архив с тем же содержимым уже был сохранён у этого владельца — возвращена существующая версия. |

### Идемпотентность

Повторное сохранение архива с тем же содержимым даёт тот же `sha256` и не создаёт новую версию. Эндпоинт возвращает `HTTP 201` с `deduplicated: true` и `versionId` уже существующей версии. Если при повторном сохранении переданы новые теги или комментарий — они добавляются к существующей версии (теги объединяются, комментарий перезаписывается).

**Область действия — один владелец.** Совпадение ищется среди версий того же приложения для `POST /v1/apps/:id/sources` и того же сервера для `POST /v1/infra/servers/:id/sources`. Один и тот же архив, сохранённый на двух разных серверах или под двумя приложениями, даёт две версии и два объекта в хранилище — у каждого владельца своя история версий.

Совпадение определяется по содержимому, а значит — только после того, как тело целиком принято: платформа считает `sha256` на лету поверх приходящего потока. Поэтому повторное сохранение того же архива идёт столько же времени и передаёт столько же байт, сколько первое. Результат при этом прежний: новая версия не создаётся, в ответе `deduplicated: true`.

Дедуплицированное сохранение проверка перед `POST /v1/apps/:id/publish` принимает наравне с настоящим: сохранение отмечает версию как заново предъявленную, и возраст снапшота отсчитывается от этой отметки. Поле `data.timestamp` при этом остаётся временем создания версии — оно не сдвигается, поэтому имя файла в депо и место версии в политике хранения не меняются. Если после `409 SNAPSHOT_REQUIRED` вы сохранили те же самые исходники, повторный `publish` пройдёт — менять байты, чтобы обновить снапшот, не нужно.

### Ответ при отключённом сохранении

`HTTP 200` — снапшот не создан:

```json
{
  "success": true,
  "data": {
    "skipped": true,
    "reason": "DISABLED_GLOBALLY"
  }
}
```

`reason` — `"DISABLED_GLOBALLY"` (платформа не включила функцию) или `"DISABLED_FOR_PORTAL"` (владелец портала отключил для своего портала). При любом из вариантов `POST /v1/apps/:id/publish` не проверяет наличие снапшота.

### Примеры

#### curl — tar.gz

```bash
curl -X POST https://vibecode.bitrix24.tech/v1/apps/<APP_ID>/sources \
  -H "X-Api-Key: YOUR_APP_KEY" \
  -H "Content-Type: application/gzip" \
  -H "X-Note: Добавил OAuth-флоу" \
  -H "X-Tags: manual" \
  --data-binary @app-sources.tar.gz
```

#### curl — zip

```bash
curl -X POST https://vibecode.bitrix24.tech/v1/apps/<APP_ID>/sources \
  -H "X-Api-Key: YOUR_APP_KEY" \
  -H "Content-Type: application/zip" \
  --data-binary @app-sources.zip
```

#### JavaScript

```javascript
const archive = await fs.readFile('app-sources.tar.gz')

const res = await fetch(
  `https://vibecode.bitrix24.tech/v1/apps/${appId}/sources`,
  {
    method: 'POST',
    headers: {
      'X-Api-Key': process.env.VIBE_APP_KEY,
      'Content-Type': 'application/gzip',
      'X-Note': 'Добавил OAuth-флоу',
    },
    body: archive,
  },
)
const json = await res.json()
console.log(json.data.versionId, 'deduplicated:', json.data.deduplicated)
```

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

| HTTP | Код | Когда возвращается |
|------|-----|---------------------|
| 400 | `INVALID_CONTENT_TYPE` | `Content-Type` не входит в список допустимых форматов архива. Формат `multipart/form-data` не принимается — отправляйте сырые байты архива. |
| 400 | `INVALID_BLOB` | Тело запроса не является бинарным буфером (например, передан текст). |
| 400 | `INVALID_FILENAME` | Заголовок `X-Filename` содержит символы вне набора `a-zA-Z0-9._-`. |
| 400 | `INVALID_TAGS` | Тег из заголовка `X-Tags` содержит недопустимые символы (разрешены `a-zA-Z0-9_-`). |
| 400 | `STORAGE_UPLOAD_LENGTH_MISMATCH` | Объявленный `Content-Length` не совпал с фактическим размером тела. Проверяется в конце потока, поэтому отказ приходит после передачи байтов. Пересчитайте длину и повторите. |
| 402 | `BILLING_INSUFFICIENT` | Недостаточно средств на балансе — запись в хранилище приостановлена. Пополните баланс и повторите. |
| 403 | `SOURCE_APP_ID_MISMATCH` | Вызов сделан ключом авторизации `vibe_app_…`, выписанным на другое приложение. Такой ключ обращается только к снапшотам своего приложения, даже если оба приложения создал один автор. |
| 403 | `NOT_AUTHORIZED` | Только автор приложения, OAuth-ключ приложения или администратор портала могут управлять снапшотами. |
| 403 | `INFRA_FORBIDDEN_FOR_COWORK_KEY` | Вызов сделан ключом Cowork/Code — такой ключ работает только с данными, изменяющие операции ему закрыты. Что делать — [Проектный ключ для деплоя](/docs/cowork/deploy-key). |
| 404 | `APP_NOT_FOUND` | Приложение не существует, удалено или принадлежит другому порталу. |
| 411 | `MISSING_CONTENT_LENGTH` | Запрос пришёл без `Content-Length` (загрузка чанками). Объявите длину тела и повторите. |
| 413 | — | Размер архива превышает лимит в 500 МБ. |
| 415 | `UNSUPPORTED_ARCHIVE_FORMAT` | Первые байты архива прямо противоречат заявленному `Content-Type`: объявлен `application/gzip`, а прислан zip, или наоборот. Тело ответа несёт `error.hint.declared` и `error.hint.detected`. Типы `application/x-tar` и `application/octet-stream` под этот отказ не попадают — их сигнатура не читается в первых байтах. |
| 500 | `SOURCE_STORAGE_ERROR` | Внутренняя ошибка хранилища. |
| 503 | `STORAGE_STS_UNAVAILABLE` | Хранилище временно недоступно (сбой выдачи временных ключей доступа) — повторите запрос. |

Полный справочник кодов — [Коды ошибок](/docs/errors).

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

- [Хранилище исходного кода](/docs/source-storage)
- [Список версий и скачивание](/docs/source-storage/versions)
- [Теги и комментарии версии](/docs/source-storage/metadata)
- [Автоматическое сохранение при деплое](/docs/source-storage/auto-save)
- [Срок жизни версий и очистка](/docs/source-storage/retention)
