## Direct file upload

`POST /v1/storage/objects/upload`

Uploads a file up to 10 MB in a single `multipart/form-data` request — the server accepts the file and returns the resulting storage object.

**Uploading again under the same `key` replaces the object content.** This also applies to a soft-deleted object when it belongs to the same owner tuple, has `COMPLETED` status, and has no open multipart session. The row is revived with the same `object.id`, `createdAt`, `key`, physical address, owner tuple, and `visibility`; `deletedAt` is cleared, while `sizeBytes`, `sha256`, `contentType`, and `contentUpdatedAt` are updated to match the new bytes.

This does **not restore** the deleted content: Path A writes new bytes over the old ones. For a `PUBLIC` object, a previously issued anonymous URL with the same `object.id` starts working again and serves the new bytes. To replace or revive an object, **omit** `visibility` or pass the stored value — visibility cannot change (`STORAGE_VISIBILITY_MISMATCH`). Deleted `PENDING` rows and rows with a multipart session stay occupied and return `409 STORAGE_KEY_DELETED`.

Replacement and revival work the same way for an app-bound key and for a personal developer key: uploading again under the same `key` does not create a second object.

For Path A direct uploads, concurrent requests to the same physical address run one at a time. A request waits for its turn for no more than 60 seconds; if safe write ownership cannot be confirmed, the server returns `409 STORAGE_KEY_CONFLICT` before writing to object storage. Retry the entire request. The guarantee also covers a personal key (`appId = null`). Path C serializes every key, but replaces a live object only for an app-bound key; it accepts a personal key after explicit DELETE. Path B is excluded.

## Request fields (body)

The request body must be in `multipart/form-data` format.

| Parameter | Type | Required | Default | Description |
|----------|-----|:-----:|-----------|---------|
| `file` | file | yes | — | Binary file no larger than 10 MB. The MIME type is determined from the `Content-Type` header of the form part. If the header is absent, `application/octet-stream` is used |
| `key` | string | yes | — | Logical object key: 1 to 1024 characters. Allowed characters: `a-z`, `A-Z`, `0-9`, `.`, `_`, `/`, `-`. Must not start with `/` or `.` and must not contain `..` |
| `visibility` | string | no | `PRIVATE` | Object visibility: `PRIVATE` or `PUBLIC`. On a **new** object an omitted field means `PRIVATE`. When **replacing or reviving** an existing object visibility does not change: omit the field or pass the stored value |

## Examples

### curl — personal key

```bash
curl -X POST https://vibecode.bitrix24.com/v1/storage/objects/upload \
  -H "X-Api-Key: YOUR_API_KEY" \
  -F "file=@avatar.png;type=image/png" \
  -F "key=users/42/avatar.png" \
  -F "visibility=PRIVATE"
```

### curl — OAuth application

```bash
curl -X POST https://vibecode.bitrix24.com/v1/storage/objects/upload \
  -H "X-Api-Key: YOUR_APP_KEY" \
  -H "Authorization: Bearer USER_SESSION_TOKEN" \
  -F "file=@avatar.png;type=image/png" \
  -F "key=users/42/avatar.png" \
  -F "visibility=PRIVATE"
```

### JavaScript — personal key

```javascript
const formData = new FormData()
formData.append('file', fileInput.files[0])
formData.append('key', 'users/42/avatar.png')
formData.append('visibility', 'PRIVATE')

const res = await fetch('https://vibecode.bitrix24.com/v1/storage/objects/upload', {
  method: 'POST',
  headers: {
    'X-Api-Key': 'YOUR_API_KEY',
  },
  body: formData,
})

if (!res.ok) {
  const { error } = await res.json()
  console.error(error.code, error.message)
  return
}
const { object } = await res.json()
console.log('Object ID:', object.id)
```

### JavaScript — OAuth application

```javascript
const formData = new FormData()
formData.append('file', fileInput.files[0])
formData.append('key', 'users/42/avatar.png')
formData.append('visibility', 'PRIVATE')

const res = await fetch('https://vibecode.bitrix24.com/v1/storage/objects/upload', {
  method: 'POST',
  headers: {
    'X-Api-Key': 'YOUR_APP_KEY',
    'Authorization': 'Bearer USER_SESSION_TOKEN',
  },
  body: formData,
})

const { object } = await res.json()
```

## Response fields

| Field | Type | Description |
|------|-----|---------|
| `object.id` | string | Object identifier |
| `object.key` | string | Logical object key |
| `object.contentType` | string | File MIME type |
| `object.sizeBytes` | string | File size in bytes (string) |
| `object.sha256` | string | File SHA-256 hash. Always computed on direct upload |
| `object.visibility` | string | Visibility: `PRIVATE` or `PUBLIC` |
| `object.uploadStatus` | string | Upload status: `COMPLETED` |
| `object.createdAt` | string | Creation date (ISO 8601) |
| `object.deletedAt` | string | Deletion date or `null` |
| `object.contentUpdatedAt` | string (ISO 8601) | When the content last became current: a replacement, or the first completion of the upload. Service updates do not move it. `null` for objects last written before this field existed |

## Response example

```json
{
  "object": {
    "id": "cmpf9b1gp003omr0zkc0qjojn",
    "key": "users/42/avatar.png",
    "contentType": "image/png",
    "sizeBytes": "20480",
    "sha256": "10068f1908d45621f11b50a1d23ddc0720a669c15f45a43d883604e7793537cd",
    "visibility": "PRIVATE",
    "uploadStatus": "COMPLETED",
    "createdAt": "2026-05-21T08:56:32.809Z",
    "deletedAt": null
  }
}
```

## Error response example

415 — the request passed a MIME type that is not allowed for `PUBLIC` objects because of cross-site scripting risks:

```json
{
  "success": false,
  "error": {
    "code": "STORAGE_FORBIDDEN_CONTENT_TYPE",
    "message": "Content-Type text/html is not allowed for PUBLIC objects (XSS-prone)"
  }
}
```

## Errors

| HTTP | Code | Description |
|------|-----|---------|
| 403 | `STORAGE_SCOPE_REQUIRED` | The API key lacks the `vibe:storage` scope |
| 401 | `STORAGE_NO_AUTH_CONTEXT` | The request was made without authorization |
| 400 | `STORAGE_FILE_REQUIRED` | The `file` field was not provided |
| 400 | `STORAGE_INVALID_CONTENT_TYPE` | The content type of the `file` part is not a single `type/subtype` media type, for example it is a comma-separated list |
| 400 | `STORAGE_KEY_REQUIRED` | The `key` field was not provided |
| 400 | `STORAGE_INVALID_KEY` | The `key` value violates the format rules |
| 400 | `STORAGE_INVALID_VISIBILITY` | Invalid `visibility` value |
| 400 | `STORAGE_VISIBILITY_MISMATCH` | Replacing content cannot change object visibility. Omit the field or pass the current value — it is named in the error message |
| 409 | `STORAGE_KEY_DELETED` | The deleted row is not eligible for Path A revival: it is not a `COMPLETED` object without a multipart session. An eligible deleted object returns 200 with the same `id` and `visibility` instead |
| 409 | `STORAGE_MULTIPART_IN_PROGRESS` | The object has a multipart upload in progress — complete or abort it |
| 409 | `STORAGE_UPLOAD_PENDING` | A presigned URL was issued for this key and the upload is not confirmed yet |
| 409 | `STORAGE_KEY_OWNED_ELSEWHERE` | The name is taken by an object of the other ownership kind (app-shared vs per-employee). Another employee does not block the same name |
| 409 | `STORAGE_KEY_CONFLICT` | The object state changed, or a concurrent direct upload (Path A) did not obtain write ownership within 60 seconds or the concurrency limit. Object storage is untouched until confirmation; retry the entire request. Serialization also covers a personal key (`appId = null`). Path C serializes every key, but replaces a live object only for an app-bound key; it accepts a personal key after explicit DELETE. Path B is excluded |
| 400 | `STORAGE_MULTIPART_PARSE_FAILED` | Failed to parse the request body as `multipart/form-data` |
| 413 | `STORAGE_UPLOAD_TOO_LARGE` | The file exceeds 10 MB — use multipart upload (Path B is temporarily disabled) |
| 415 | `STORAGE_FORBIDDEN_CONTENT_TYPE` | For `PUBLIC` objects the types a browser renders as a page, runs or sniffs are forbidden: HTML and XHTML, SVG, JavaScript, any XML (`*/xml`, `*+xml`), XSL, `multipart/*`, `message/*`, `unknown/unknown`, `application/unknown`, `*/*`, with or without parameters |
| 402 | `BILLING_INSUFFICIENT` | Insufficient balance |
| 507 | `STORAGE_QUOTA_EXCEEDED` | The file size does not fit the remaining portal storage volume cap. The cap is optional, 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 app |
| 503 | `STORAGE_FEATURE_DISABLED` | Storage is disabled for the portal |
| 502 | `STORAGE_BUCKET_ERROR` | Error while interacting with storage |

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

## Known specifics

**Files larger than 10 MB.** Path B is temporarily disabled and answers `503 STORAGE_PRESIGNED_UPLOAD_DISABLED`, so upload anything above 10 MB with [multipart upload](/docs/storage/upload/multipart-create) (Path C).

**Type check for `PUBLIC` objects.** The check runs against the declared `Content-Type` of the form part before the file is written — if it fails, the request is rejected without accessing storage.

## See also

- [Upload](/docs/storage/upload)
- [Create presigned URL](/docs/storage/upload/presigned)
- [Complete upload](/docs/storage/upload/complete)
- [Multipart upload](/docs/storage/upload/multipart-create)
- [Storage](/docs/storage)
