# Save a snapshot

Saving a source archive explicitly: 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).

Storage overview and the endpoint reference — [Source code storage](/docs/source-storage).

## Saving a snapshot

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

Accepts the raw bytes of the source code archive in the request body. The format is determined by the `Content-Type` header. Metadata (tags, note, filename) is passed via additional headers.

> **Binary upload only (contract v2).** The endpoint accepts the **raw archive bytes** in the request body (`--data-binary`). `multipart/form-data` is **no longer supported** — such a request returns `400 INVALID_CONTENT_TYPE`. Early client versions that sent `multipart/form-data` with form fields (`-F "tag=manual"`) must be updated: the body is the archive bytes themselves, and **the tags, note, and filename are passed via the `X-Tags` / `X-Note` / `X-Filename` headers** (not as form fields and not as query parameters). MCP clients use the `save_sources` tool, which packs and sends the archive in the correct format for you.

### Path parameters

| Parameter | Type | Description |
|----------|-----|----------|
| `id` (path) | UUID | Application identifier. Get it via [`GET /v1/apps`](/docs/apps/list). |

### Request headers

| Header | Required | Description |
|-----------|--------------|----------|
| `Content-Type` | 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` | yes | Body size in bytes. An upload without it (`Transfer-Encoding: chunked`) is not accepted → `411 MISSING_CONTENT_LENGTH`. |
| `X-Filename` | no | An arbitrary display filename (for example, `app-v1.tar.gz`). If it is not provided, the name is derived from `Content-Type` (for example, `source.tar.gz` for `application/gzip`). Characters outside the `a-zA-Z0-9._-` set → `400 INVALID_FILENAME`. |
| `X-Tags` | no | Comma-separated tags. `manual` and `published` are recognized — they protect the version from automatic cleanup. |
| `X-Note` | no | An arbitrary note that is saved in the version record. |
| `X-AI-Session-Id` | no | AI session identifier. Groups snapshots in the manifest by session. |

### Request body

Raw archive bytes, not `multipart/form-data`. Maximum size — 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.

In `curl` the length is set automatically when the body comes from a file (`--data-binary @app.tar.gz`). Piping the body in by hand (`... | curl --data-binary @-`) gives no length — save the archive to a temporary file or set `Content-Length` explicitly.

### Response on successful save

`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
  }
}
```

### Response fields

| Field | Type | Description |
|------|-----|----------|
| `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 within one owner. |
| `data.size` | number | Archive size in bytes. |
| `data.timestamp` | string | Save time (ISO 8601, UTC). |
| `data.tags` | string[] | Active version tags: `manual`, `published`. |
| `data.note` | string \| null | Note from the `X-Note` header or `null`. |
| `data.deduplicated` | boolean | `true` if an archive with the same contents was already saved for this owner — the existing version is returned. |

### Idempotency

Re-saving an archive with the same contents yields the same `sha256` and does not create a new version. The endpoint returns `HTTP 201` with `deduplicated: true` and the `versionId` of the existing version. If new tags or a note are passed on a re-save, they are added to the existing version (tags are merged, the note is overwritten).

**Scope — a single owner.** Matching is scoped to 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.

A match is determined by contents, so it can be established only after the body has been received in full: the platform computes `sha256` on the fly over the incoming stream. That is why re-saving the same archive takes as long as the first save and transfers the same number of bytes. The result is unchanged: no new version is created, and the response carries `deduplicated: true`.

The check before `POST /v1/apps/:id/publish` accepts a deduplicated save on a par with a real one: the save marks the version as presented again, and the snapshot's age is counted from that mark. 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.

### Response when saving is disabled

`HTTP 200` — 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.

### Examples

#### curl — tar.gz

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

#### curl — zip

```bash
curl -X POST https://vibecode.bitrix24.com/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.com/v1/apps/${appId}/sources`,
  {
    method: 'POST',
    headers: {
      'X-Api-Key': process.env.VIBE_APP_KEY,
      'Content-Type': 'application/gzip',
      'X-Note': 'Added OAuth flow',
    },
    body: archive,
  },
)
const json = await res.json()
console.log(json.data.versionId, 'deduplicated:', json.data.deduplicated)
```

### Error codes

| HTTP | Code | When returned |
|------|-----|---------------------|
| 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 refusal arrives 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. |
| 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 Bitrix24 account administrator can manage snapshots. |
| 403 | `INFRA_FORBIDDEN_FOR_COWORK_KEY` | The call was made with a Cowork/Code 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 signature cannot be read from the first bytes. |
| 500 | `SOURCE_STORAGE_ERROR` | Internal storage error. |
| 503 | `STORAGE_STS_UNAVAILABLE` | Storage is temporarily unavailable (a failure issuing temporary access credentials) — retry the request. |

The full code reference — [Error codes](/docs/errors).

## See also

- [Source code storage](/docs/source-storage)
- [Version list and download](/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)
