# Uploading files

> How to upload videos, photos and audio to Slicer through the API — a one-time link for curl -T, the whole file, in parts, or imported from a public URL.

Source: https://docs.slicer.media/en/api/uploads/

A montage is built from uploaded materials: videos, photos and audio. Every file first becomes an **upload** — it has an `id`, a status and media details. Ready uploads (`status: ready`) go into `uploadIds` when you [create a project](https://docs.slicer.media/en/api/montage/).

## Four ways

| Way | Best for | Token when sending the file |
| --- | --- | --- |
| One-time link `uploadUrl` | the simplest option; another program or an AI agent sends the file | not needed |
| Whole file | the file is on your service, one request | needed |
| In parts | large files and unreliable networks: parallel parts, retrying a part | needed |
| Import from a URL | the file is already at a public `https` URL | not needed — Slicer downloads it |

## Create an upload

For the first three ways the upload is created in advance — the server needs to know the file name and size:

Terminal:

```bash
curl https://api.slicer.media/v1/uploads \
  -H "Authorization: Bearer $SLICER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fileName":"interview.mp4","size":48211337}'
```

Response:

```json
{
  "ok": true,
  "data": {
    "upload": {
      "id": "7d0c6a4e-2f7b-4f5e-9a0e-3b8f2c1d9e61",
      "fileName": "interview.mp4",
      "purpose": "source",
      "origin": "client",
      "sizeBytes": 48211337,
      "status": "uploading",
      "partSize": 8388608,
      "partCount": 6,
      "uploadedParts": [],
      "progress": null,
      "media": null,
      "error": null,
      "createdAt": "2026-10-16T09:12:04.000Z"
    },
    "uploadUrl": "https://api.slicer.media/v1/upload-links/slc_ul_…",
    "uploadUrlExpiresAt": "2026-10-16T10:12:04.000Z"
  }
}
```

-   `fileName` — with an extension: it determines the kind of material (video, photo or audio).
-   `size` — the exact file size in bytes.
-   `purpose` — what the file is for; montage materials use `source` (the default).

## One-time link

`uploadUrl` accepts a `PUT` of the whole file **without a token** — the permission is built into the link:

Terminal:

```bash
curl -T interview.mp4 "$UPLOAD_URL"
```

Link properties:

-   **one attempt** — the link expires on the first request, even if it was interrupted; to retry, create a new upload or send the whole file to the same upload with your token (below);
-   **at most an hour** — the expiry time is in `uploadUrlExpiresAt`;
-   **write-only** — you can't read anything through the link or learn anything about the account;
-   the body size must match the declared `size`, and `Content-Length` is required (`curl -T` sets it for you).

Once the file is received, the upload completes on its own and the response is the upload object. The link is handy to give to an AI agent or a script on another machine without revealing your token.

## Whole file

The same idea, but with the token and at the upload's permanent address:

Terminal:

```bash
curl -X PUT "https://api.slicer.media/v1/uploads/$UPLOAD_ID/content" \
  -H "Authorization: Bearer $SLICER_TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @interview.mp4
```

The server splits the stream into parts, encrypts them and completes the upload. `Content-Length` must equal the declared size.

## In parts

For large files: parts of `partSize` bytes (8 MiB), numbered from 1, the last one is the remainder. Parts can go in parallel and be retried — received parts are listed in `uploadedParts`. When all parts are in, call `complete`.

**JavaScript**

upload-parts.mjs:

```js
// Node.js 20+: node upload-parts.mjs video.mp4
import { open, stat } from 'node:fs/promises';
import { basename } from 'node:path';

const API = 'https://api.slicer.media/v1';
const auth = { Authorization: `Bearer ${process.env.SLICER_TOKEN}` };
const file = process.argv[2];

async function call(method, path, body, headers = {}) {
  const response = await fetch(`${API}${path}`, { method, headers: { ...auth, ...headers }, body });
  const json = await response.json();
  if (!json.ok) throw new Error(`${response.status} ${json.error.code}: ${json.error.message}`);
  return json.data;
}

const { size } = await stat(file);
const { upload } = await call('POST', '/uploads', JSON.stringify({ fileName: basename(file), size }), {
  'Content-Type': 'application/json',
});

const handle = await open(file);
const queue = Array.from({ length: upload.partCount }, (_, i) => i + 1);

async function worker() {
  for (let part = queue.shift(); part != null; part = queue.shift()) {
    const offset = (part - 1) * upload.partSize;
    const length = Math.min(upload.partSize, size - offset);
    const { buffer } = await handle.read(Buffer.alloc(length), 0, length, offset);
    for (let attempt = 1; ; attempt += 1) {
      try {
        await call('PUT', `/uploads/${upload.id}/parts/${part}`, buffer, { 'Content-Type': 'application/octet-stream' });
        break;
      } catch (error) {
        if (attempt === 3) throw error;
      }
    }
  }
}

await Promise.all(Array.from({ length: 4 }, worker)); // 4 parts at a time
await handle.close();
const ready = await call('POST', `/uploads/${upload.id}/complete`);
console.log(ready.status, ready.media);
```

**Python**

upload_parts.py:

```python
# python upload_parts.py video.mp4
import os
import sys
from concurrent.futures import ThreadPoolExecutor

import requests

API = "https://api.slicer.media/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SLICER_TOKEN']}"}
path = sys.argv[1]
size = os.path.getsize(path)

def call(method, url, **kwargs):
    body = requests.request(method, API + url, headers=HEADERS, timeout=300, **kwargs).json()
    if not body["ok"]:
        raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
    return body["data"]

upload = call("POST", "/uploads", json={"fileName": os.path.basename(path), "size": size})["upload"]

def send(part):
    offset = (part - 1) * upload["partSize"]
    with open(path, "rb") as file:
        file.seek(offset)
        chunk = file.read(min(upload["partSize"], size - offset))
    for attempt in range(3):
        try:
            return call("PUT", f"/uploads/{upload['id']}/parts/{part}", data=chunk)
        except (RuntimeError, requests.RequestException):
            if attempt == 2:
                raise

with ThreadPoolExecutor(max_workers=4) as pool:  # 4 parts at a time
    list(pool.map(send, range(1, upload["partCount"] + 1)))

ready = call("POST", f"/uploads/{upload['id']}/complete")
print(ready["status"], ready["media"])
```

Parts count against a separate rate-limit bucket — 1200 requests per minute ([Rate limits](https://docs.slicer.media/en/api/rate-limits/)). An upload started in parts can't be continued as a whole file (`409 upload_in_parts`) — keep sending parts.

## Import from a URL

If the file is already on the internet, Slicer will download it:

Terminal:

```bash
curl https://api.slicer.media/v1/uploads/import \
  -H "Authorization: Bearer $SLICER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://media.example.com/clips/product-demo.mp4"}'
```

-   Only `https` and only public addresses: internal network addresses are rejected, and every redirect is checked again.
-   The file name is taken from the URL. If the URL has no extension (`…/download?id=42`), pass `fileName`: `"fileName": "product-demo.mp4"`.
-   The import runs in the background: the upload comes back right away with the `uploading` status, and `progress` shows the downloaded share (0–1). Then come `processing` and `ready`, or `failed` with the reason in `error`.
-   Up to 3 imports at a time per account; a download may take up to 30 minutes. Size and format limits are the same as for regular uploads.

Links to video hosting pages (rather than to the file itself) won't work: you need a direct link to the file.

## Upload statuses

| Status | What's happening |
| --- | --- |
| `uploading` | waiting for the file or its parts; for imports — downloading |
| `processing` | the file has arrived; the server checks the format and makes a cover |
| `ready` | can be used in a montage; file details are in `media` |
| `failed` | the file was rejected; the reason is in `error` |
| `aborted` | the upload was cancelled (`DELETE /v1/uploads/{id}`) |

Check the status with `GET /v1/uploads/{id}` or the `upload.ready` and `upload.failed` [webhooks](https://docs.slicer.media/en/api/webhooks/). The `media` field of a ready upload: `kind` (`video`, `image`, `audio`), `durationSec`, `width`, `height`, `hasAudio`.

## Formats and limits

The limits are the same as in the app and are listed in the catalog `GET /v1/styles` → `materialLimits`:

-   video — `mp4`, `mov`, `m4v`, `webm`, `mkv`, `avi`; photos — `jpg`, `jpeg`, `png`, `webp`; audio — `mp3`, `m4a`, `aac`, `wav`, `ogg`, `opus`, `flac`;
-   up to 20 materials per project, with separate limits for videos, photos and audio;
-   the format is checked not only by extension but also by the file's actual content.

More on sizes, durations and resolutions — [File formats and limits](https://docs.slicer.media/en/guide/help/files/). Unfinished uploads are removed after a day, project sources are kept for 7 days after the montage (`sourcesAvailable` on the project), and finished videos are kept until you delete them.

## Upload errors

| Code | Status | Reason |
| --- | --- | --- |
| `unsupported_format` | 400 | the extension or file content isn't supported |
| `file_too_large` | 400 | the file exceeds the limit for its kind |
| `empty_file` | 400 | the file is empty |
| `not_a_video`, `not_an_image`, `not_audio`, `media_unreadable` | 400 | the content doesn't match the extension or the file is damaged |
| `resolution_too_low`, `resolution_too_high`, `image_too_large` | 400 | the resolution is outside the allowed range |
| `audio_too_long` | 400 | the audio is longer than allowed |
| `too_many_uploads`, `daily_upload_limit` | 400 | too many unfinished uploads or the daily volume is used up |
| `bad_part`, `bad_part_size` | 400 | wrong part number or size |
| `parts_missing` | 400 | `complete` was called before all parts were sent |
| `length_required` | 411 | no `Content-Length` header |
| `bad_content_length`, `body_too_short`, `body_too_long` | 400 | the body size doesn't match the declared `size` |
| `upload_not_active` | 409 | the upload is already completed or cancelled |
| `upload_in_parts` | 409 | the upload is already going in parts |
| `link_not_found` | 404 | the upload link wasn't found |
| `link_expired`, `link_used` | 410 | the link has expired or was already used |
| `bad_url`, `file_name_required` | 400 | the import URL isn't `https` or has no file name |
| `too_many_imports` | 429 | 3 imports are already running — wait for them to finish |

## What's next

[Montage and pricingStart a montage from ready uploads.](https://docs.slicer.media/en/api/montage/)[Reference: uploadsEvery upload endpoint.](https://docs.slicer.media/en/api/reference/uploads/)
