## Save a snapshot

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

Explicitly saves a source archive. Use it when you need to tag a version, record an intermediate result without deploying, or prepare a rollback.

When you deploy, a snapshot is created [automatically](/docs/source-storage/auto-save).

## Parameters

| Parameter | Type | Required | Description |
|----------|-----|:-----:|----------|
| `id` (path) | string | yes | Application identifier. Get it via [`GET /v1/apps`](/docs/apps/list). |
| `Content-Type` (header) | string | yes | Archive format. Allowed values: `application/gzip`, `application/x-tar`, `application/zip`, `application/octet-stream`. Any other value → `400 INVALID_CONTENT_TYPE`. The declared format is checked against the first bytes of the archive: a direct contradiction (`application/gzip` declared for zip bytes, or the other way round) → `415 UNSUPPORTED_ARCHIVE_FORMAT`. |
| `Content-Length` (header) | number | yes | Body size in bytes. An upload without it (`Transfer-Encoding: chunked`) is not accepted → `411 MISSING_CONTENT_LENGTH`. |
| `X-Filename` (header) | string | no | An arbitrary display filename, for example `app-v1.tar.gz`. If it is not provided, the name is derived from `Content-Type`, which gives `source.tar.gz` for `application/gzip`. Characters outside the `a-zA-Z0-9._-` set → `400 INVALID_FILENAME`. |
| `X-Tags` (header) | string | no | Comma-separated tags; spaces around commas are dropped. A tag may contain only Latin letters, digits, `_`, and `-`; any other character → `400 INVALID_TAGS`. `manual` and `published` protect the version from automatic cleanup; any other tags are free-form labels. |
| `X-Note` (header) | string | no | An arbitrary note that is saved in the version record. `fetch` in JavaScript accepts only Latin-1 characters in headers: Cyrillic text in `X-Note` throws a `TypeError` before the request is sent. |
| `X-AI-Session-Id` (header) | string | no | AI session identifier. Groups snapshots in the manifest by session. |

## Request fields (body)

The body is raw archive bytes, not `multipart/form-data`. The maximum size is 500 MB.

The body is accepted as a stream, so its length has to be declared up front: the request must carry `Content-Length`. A chunked upload (`Transfer-Encoding: chunked`, no length) is rejected with `411 MISSING_CONTENT_LENGTH` — the platform will not buffer the archive in memory to measure it for the client. A declared length above the cap is rejected with `413` before the body is read.

`curl` sets the length automatically with `--data-binary @app.tar.gz` and when reading from standard input with `--data-binary @-`.

## Examples

The JavaScript examples run in Node.js: save the code in an `.mjs` file. Replace `<APP_ID>` with your application ID and place `app-sources.tar.gz` in the same directory as the example file.

### curl — personal key

```bash
curl -X POST https://vibecode.bitrix24.com/v1/apps/<APP_ID>/sources \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/gzip" \
  -H "X-Note: OAuth sign-in" \
  -H "X-Tags: manual" \
  --data-binary @app-sources.tar.gz
```

### curl — OAuth application

```bash
curl -X POST https://vibecode.bitrix24.com/v1/apps/<APP_ID>/sources \
  -H "X-Api-Key: YOUR_APP_KEY" \
  -H "Authorization: Bearer USER_SESSION_TOKEN" \
  -H "Content-Type: application/gzip" \
  -H "X-Note: OAuth sign-in" \
  -H "X-Tags: manual" \
  --data-binary @app-sources.tar.gz
```

### JavaScript — personal key

```javascript
import { readFile } from 'node:fs/promises'

const appId = '<APP_ID>'
const archive = await readFile(new URL('./app-sources.tar.gz', import.meta.url))

const res = await fetch(
  `https://vibecode.bitrix24.com/v1/apps/${appId}/sources`,
  {
    method: 'POST',
    headers: {
      'X-Api-Key': 'YOUR_API_KEY',
      'Content-Type': 'application/gzip',
      'X-Note': 'OAuth sign-in',
      'X-Tags': 'manual',
    },
    body: archive,
  },
)
const json = await res.json()
console.log(json.data.versionId, 'deduplicated:', json.data.deduplicated)
```

### JavaScript — OAuth application

```javascript
import { readFile } from 'node:fs/promises'

const appId = '<APP_ID>'
const archive = await readFile(new URL('./app-sources.tar.gz', import.meta.url))

const res = await fetch(
  `https://vibecode.bitrix24.com/v1/apps/${appId}/sources`,
  {
    method: 'POST',
    headers: {
      'X-Api-Key': 'YOUR_APP_KEY',
      'Authorization': 'Bearer USER_SESSION_TOKEN',
      'Content-Type': 'application/gzip',
      'X-Note': 'OAuth sign-in',
      'X-Tags': 'manual',
    },
    body: archive,
  },
)
const json = await res.json()
console.log(json.data.versionId, 'deduplicated:', json.data.deduplicated)
```

For a zip archive, only one value changes in the same examples: `Content-Type: application/zip`, and the body is the bytes of the zip file.

## Response fields

| Field | Type | Description |
|------|-----|----------|
| `success` | boolean | `true` for a successful response, including when saving is skipped. |
| `data` | object | Save result: the version fields, or `skipped` and `reason` if saving is disabled. |
| `data.versionId` | string | Version identifier of the form `v<N>`. |
| `data.id` | string | Internal identifier of the snapshot record. Returned only in the save response: the version list and the single-version metadata do not carry this field. |
| `data.filename` | string | Name of the archive file in storage. |
| `data.contentType` | string | Archive type: `application/gzip`, `application/zip`, `application/x-tar`, or `application/octet-stream`. |
| `data.sha256` | string | SHA-256 of the archive contents. Used for deduplication of archives saved for the same owner. |
| `data.size` | number | Archive size in bytes. |
| `data.timestamp` | string | Save time (ISO 8601, UTC). |
| `data.tags` | string[] | Version tags. `manual` and `published` protect the version from automatic cleanup; any other tags are free-form labels from `X-Tags`. If the archive was already saved (`deduplicated: true`), the tags from the request are added to the existing version's tags. |
| `data.note` | string \| null | Note from the `X-Note` header or `null`. When the same archive is saved again, a note provided in the request replaces the previous note. |
| `data.deduplicated` | boolean | `true` if an archive with the same contents was already saved for this owner — the existing version is returned. |
| `data.skipped` | boolean | Returned instead of the version fields when saving is disabled. Always `true`. |
| `data.reason` | string | Why the save was skipped: `DISABLED_GLOBALLY` or `DISABLED_FOR_PORTAL`. Returned together with `skipped`. |

## Response example

`HTTP 201` — the snapshot was saved. Saving the same archive again also returns `201`, with `data.deduplicated` set to `true`:

```json
{
  "success": true,
  "data": {
    "versionId": "v3",
    "id": "cmuye5vkc0kd74bou8q1fnreo",
    "filename": "2026-10-07T17-38-38-988Z-v3-manual.tar.gz",
    "contentType": "application/gzip",
    "sha256": "4cd663d7b491eb3dcbe46f433a3822077410873e302737ce093c84c7a915af08",
    "size": 159,
    "timestamp": "2026-10-07T17:38:38.988Z",
    "tags": ["manual"],
    "note": "OAuth sign-in",
    "deduplicated": false
  }
}
```

`HTTP 200` — saving is disabled, no snapshot created:

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

`reason` — `"DISABLED_GLOBALLY"` (the platform has not enabled the feature) or `"DISABLED_FOR_PORTAL"` (the Bitrix24 account owner disabled it for their account). In either case `POST /v1/apps/:id/publish` does not check for a snapshot.

## Error response example

400 — `Content-Type` is not in the list of allowed archive formats:

```json
{
  "success": false,
  "error": {
    "code": "INVALID_CONTENT_TYPE",
    "message": "Content-Type \"text/plain\" not allowed. Allowed: application/gzip, application/x-tar, application/zip, application/octet-stream",
    "hint": {
      "method": "binary (not multipart)",
      "acceptedContentTypes": [
        "application/gzip",
        "application/x-tar",
        "application/zip",
        "application/octet-stream"
      ],
      "mcpToolName": "save_sources",
      "docsUrl": "/docs/source-storage"
    }
  }
}
```

The main `hint` fields are shown. The full response also carries `explanation` with a breakdown of the format, `metadataHeaders` with the list of metadata headers, and `exampleCurl` with a ready-made command.

## Errors

| HTTP | Code | Description |
|------|-----|----------|
| 400 | `INVALID_CONTENT_TYPE` | `Content-Type` is not in the list of allowed archive formats. `multipart/form-data` is not accepted — send the raw archive bytes. |
| 400 | `INVALID_BLOB` | The request body is not a binary buffer — for example, text was passed. |
| 400 | `INVALID_FILENAME` | The `X-Filename` header contains characters outside the `a-zA-Z0-9._-` set. |
| 400 | `INVALID_TAGS` | A tag from the `X-Tags` header contains invalid characters (`a-zA-Z0-9_-` are allowed). |
| 400 | `STORAGE_UPLOAD_LENGTH_MISMATCH` | The declared `Content-Length` did not match the actual body size. The check happens at the end of the stream, so the error is returned after the bytes have been sent. Recompute the length and retry. |
| 402 | `BILLING_INSUFFICIENT` | Insufficient balance — storage writes are paused. Top up the balance and retry. |
| 507 | `STORAGE_QUOTA_EXCEEDED` | The snapshot does not fit into the portal's remaining storage cap. The cap is optional: it is switched on by the platform and applies only to portals that have no commercial Bitrix24 plan and never had one. Usage is counted across the whole portal, not per application. |
| 403 | `SOURCE_APP_ID_MISMATCH` | The call was made with an authorization key `vibe_app_…` issued for a different application. Such a key can access only its own application's snapshots, even when both applications were created by the same author. |
| 403 | `NOT_AUTHORIZED` | Only the application author, the application OAuth key, or a portal administrator can manage snapshots. |
| 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. To issue a key that can, see [Project key for deploy](/docs/cowork/deploy-key). |
| 404 | `APP_NOT_FOUND` | The application does not exist, was deleted, or belongs to another portal. |
| 411 | `MISSING_CONTENT_LENGTH` | The request arrived without `Content-Length` (a chunked upload). Declare the body length and retry. |
| 413 | — | The archive size exceeds the 500 MB limit. |
| 415 | `UNSUPPORTED_ARCHIVE_FORMAT` | The first bytes of the archive directly contradict the declared `Content-Type`: `application/gzip` was declared while a zip was sent, or the other way round. The response body carries `error.hint.declared` and `error.hint.detected`. The types `application/x-tar` and `application/octet-stream` never trigger this refusal — their signatures are not checked in the first bytes. |
| 500 | `SOURCE_STORAGE_ERROR` | Internal storage error. |
| 503 | `STORAGE_STS_UNAVAILABLE` | Storage is temporarily unavailable (a failure to issue temporary access credentials) — retry the request. |

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

## Known specifics

**Deduplication is scoped to a single owner.** Matching happens only among the versions of the same application for `POST /v1/apps/:id/sources` and the same server for `POST /v1/infra/servers/:id/sources`. The very same archive saved on two different servers or under two applications yields two versions and two objects in storage — each owner keeps its own version history.

**The entire archive is sent again on a repeat save.** The platform computes `sha256` as it receives the data and can identify a match only after receiving the entire request body.

**A deduplicated save clears `409 SNAPSHOT_REQUIRED`.** The check before `POST /v1/apps/:id/publish` treats a deduplicated save like a new save: it records when the version was submitted again and measures the snapshot's age from that time. The `data.timestamp` field still reports when the version was created — it does not move, so the stored file name and the version's place in the retention policy stay the same. If you re-save the very same sources after a `409 SNAPSHOT_REQUIRED`, the next publish goes through — you do not have to change the bytes to refresh the snapshot.

## See also

- [Source code storage](/docs/source-storage)
- [List versions](/docs/source-storage/versions)
- [Version tags and notes](/docs/source-storage/metadata)
- [Automatic save on deploy](/docs/source-storage/auto-save)
- [Version lifetime and cleanup](/docs/source-storage/retention)
