
## Upload a file

`POST /v1/infra/servers/:id/upload`

Writes a file to a BLACKHOLE server through the tunnel agent. Two source options: inline base64 content in the request body (up to 500 MB), or a URL from which the agent downloads the file itself (up to 500 MB). Automatic unpacking of `tar.gz` / `tar.bz2` / `zip` archives is supported — the format is detected by the file signature (magic bytes) or by the URL extension.

## Parameters

| Parameter | In | Type | Required | Description |
|----------|---|-----|:-----:|----------|
| `id` | path | string (UUID) | yes | BLACKHOLE server ID, `status: running`, `blackholeStatus: CONNECTED` |

## Request fields (body)

| Field | Type | Required | Description |
|------|-----|:-----:|----------|
| `path` | string | **yes** | Path on the server, 1–500 characters. For archives — where to place the uploaded archive before unpacking |
| `content` | string | yes (or `url`) | Base64 content of the file. Maximum 500 MB per request body |
| `url` | string | yes (or `content`) | HTTPS URL from which the agent downloads the file. Up to 500 MB |
| `mode` | string | no | File permissions in octal format: `0644`, `0755`. Applied to the file at `path` |
| `extract` | boolean | no | Unpack the archive after upload. Defaults to `false`. Requires `content` or `url` with an archive |
| `extractTo` | string | no | Directory for unpacking (when `extract: true`). Defaults to the directory from `path` |

You must pass exactly one of: `content` or `url`.

## Examples

### curl — personal key

```bash
# Uploading a file by URL with automatic unpacking
curl -X POST https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/upload \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://github.com/user/repo/archive/main.tar.gz",
    "path": "/opt/app/source.tar.gz",
    "extract": true,
    "extractTo": "/opt/app"
  }'

# Inline base64 content — a small file (package.json)
CONTENT=$(echo '{"name":"my-app","version":"1.0.0"}' | base64)
curl -X POST https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/upload \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"content\":\"$CONTENT\",\"path\":\"/opt/app/package.json\",\"mode\":\"0644\"}"
```

### curl — OAuth application

```bash
curl -X POST https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/upload \
  -H "X-Api-Key: YOUR_APP_KEY" \
  -H "Authorization: Bearer USER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/config.json","path":"/etc/myapp/config.json"}'
```

### JavaScript — personal key

```javascript
// Uploading and unpacking an archive
const res = await fetch(
  `https://vibecode.bitrix24.com/v1/infra/servers/${serverId}/upload`,
  {
    method: 'POST',
    headers: {
      'X-Api-Key': 'YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url: 'https://github.com/user/app/archive/main.tar.gz',
      path: '/opt/app/source.tar.gz',
      extract: true,
      extractTo: '/opt/app',
    }),
  }
)
const { data } = await res.json()
console.log(`Wrote ${data.size} bytes at ${data.path}${data.extracted ? ', unpacked' : ''}`)
```

### JavaScript — OAuth application

```javascript
// Inline base64 content — a small config
const content = Buffer.from('NODE_ENV=production\n').toString('base64')
await fetch(
  `https://vibecode.bitrix24.com/v1/infra/servers/${serverId}/upload`,
  {
    method: 'POST',
    headers: {
      'X-Api-Key': 'YOUR_APP_KEY',
      'Authorization': 'Bearer USER_SESSION_TOKEN',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      content,
      path: '/opt/app/.env',
      mode: '0600',
    }),
  }
)
```

## Response fields

| Field | Type | Description |
|------|-----|----------|
| `success` | boolean | `true` on a successful write |
| `data.path` | string | The final file path on the server |
| `data.size` | number | Size in bytes |
| `data.extracted` | boolean | `true` if the archive was unpacked after upload |

## Response example

```json
{
  "success": true,
  "data": {
    "path": "/opt/app/source.tar.gz",
    "size": 1048576,
    "extracted": true
  }
}
```

## Error response example

403 — path forbidden for upload:

```json
{
  "success": false,
  "error": {
    "code": "UPLOAD_PATH_DENIED",
    "message": "Upload to system path is not allowed"
  }
}
```

## Errors

| HTTP | Code | Description |
|------|-----|----------|
| 400 | `VALIDATION_ERROR` | Schema violation: both `content` and `url` at once, or neither; an invalid `path`; a body larger than 500 MB |
| 400 | `NOT_BLACKHOLE` | Server in OPEN mode — the Deploy API is unavailable |
| 401 | `MISSING_API_KEY` | The `X-Api-Key` header was not provided |
| 401 | `INVALID_API_KEY` | Invalid or expired API key |
| 403 | `UPLOAD_PATH_DENIED` | Uploading to system directories (`/root/.ssh`, `/etc/passwd`, `/boot`, …) is forbidden |
| 404 | `NOT_FOUND` | The server does not exist, was deleted, or belongs to another API key |
| 409 | `SERVER_NOT_READY` | The server is not ready for the operation: it is not running or the tunnel is not connected. The response carries a `hint` field with the reason and the next step. Wake the server or call [`/repair`](/docs/infra/lifecycle/repair) and repeat the request. The "listed as connected but the Gateway has no live tunnel" case on this route arrives as `502 TUNNEL_NOT_FOUND` (see below) |
| 429 | `RATE_LIMITED` | The limit of 10 operations per minute per server was exceeded |
| 429 | `DEPLOY_BACKEND_BUSY` | Too many concurrent uploads that carry content in the request body. The platform caps the number of concurrent `/upload` and `/deploy` calls with inline content so that memory is not exhausted. The response carries the `Retry-After: 30` header — repeat in 30 seconds. Or pass the archive by link (`url`): uploads by link have no concurrency cap |
| 502 | `UNZIP_PREFLIGHT_FAILED` | Failed to install `unzip` on the server before extracting the zip archive. To avoid depending on this step, use a `.tar.gz` archive |
| 500 | agent code | A file write error on the server: out of disk space, permissions, etc. The specific code arrives from the agent in the `error.code` field |
| 502 | `TUNNEL_NOT_FOUND` / `GATEWAY_UNREACHABLE: …` | No live tunnel to the server, or the Gateway is unreachable — call [`/repair`](/docs/infra/lifecycle/repair) and retry. `GATEWAY_UNREACHABLE` carries details after the colon — match `error.code` by prefix |
| 503 | `GATEWAY_TIMEOUT: …` | The Gateway did not respond in time. The code carries details after the colon — compare by prefix |

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

## Known specifics

- **Automatic archive format detection with `extract: true`.** The agent detects the format by the file signature (magic bytes, the `detectArchiveExt()` function): it supports `.tar.gz`, `.zip`, `.tar.bz2`. The format does not need to be specified explicitly. For a URL — the agent also checks the extension.
- **macOS archives are cleaned automatically.** If the archive contains AppleDouble sidecars (`._*`) or `.DS_Store`, the agent removes them after unpacking — this prevents false positives from scanners like Tailwind v4 oxide. When creating an archive on macOS we recommend using `COPYFILE_DISABLE=1 tar -czf ...` so that the sidecars never end up in the archive.
- **Windows PowerShell ZIP — works on agent ≥ 1.2.3.** `Compress-Archive` on Windows writes a ZIP with literal `\` in file names. Since version 1.2.3 the agent automatically normalizes these paths via the `normalize_windows_paths` step. For older agent versions use `tar.gz` (via WSL / Git Bash) or host a ready-made tar archive and upload it via `url`.
- **List of forbidden paths.** The agent blocks uploads to: `/root/.ssh/` (protecting SSH keys), `/boot`, `/etc/shadow`, `/etc/passwd`, system service files. For the application use `/opt/<app>/` or `/var/lib/<app>/`.
- **`mode` is applied to the archive, not to the unpacked files.** With `extract: true` the permissions of the unpacked files are determined by the archive's contents, and `mode` is set only on the saved archive. If you need to change permissions inside — do it via [`/exec`](./exec.md) with `chmod`.
- **10-minute timeout for uploading via `url`.** That should be enough for 500 MB. If the source is slow, preload the archive and host it on your own CDN.

## See also

- [Full deploy](./deploy.md) — in a single request instead of the `upload` + `exec` chain.
- [Run a command](./exec.md) — after the upload you can unpack/install manually.
- [Service logs](./logs.md) — check that the service picked up the new files.
