# Upload through the API

> S3 uploads signed by the API, in one PUT or in parts, from scripts, servers and browsers.

No AWS account is needed. The API signs an upload into the project's `input/` folder and your
client sends the bytes straight to S3, one of two ways:

| | One `PUT` | Parts |
|---|---|---|
| Calls | 2: start, `PUT` the file | 3 + one per part: start, sign and `PUT` each part, complete |
| Largest file | 5 GB | 5 TB |
| Interrupted | Start again | Resumable: list the parts S3 holds and send the rest |
| Replacing a file | Delete it first | `overwrite: true` |
| For | Scripts, a first integration | Browsers, large files, pipelines that resume |

A project that reads a [feed](/vod-express/concepts/inputs#feeds) takes no uploads.

## One PUT

Start the upload, then `PUT` the whole file to the `url` it answers:

```bash
START=$(curl -s -X POST $API/projects/$PROJECT/uploads -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"name": "episode 01.mp4", "size": 734003200}')
curl -T "episode 01.mp4" "$(jq -r .data.url <<< "$START")"
```

S3 answers `200` with an empty body, and the file is in `input/`, listed by `GET /projects/{id}/files`
and processed by a running project. Nothing to complete. Until the `PUT` finishes, S3 shows
nothing, so the project never sees half a file. The URL works until `urlExpiresAt`, an hour at
most and sometimes less, and needs no header: a `Content-Type` you do send is stored with the file.

`url` is absent from the answer in two cases, and then the file goes in parts:

- The file is over 5 GB, the most S3 takes in one request.
- A file of that name is already in `input/`. Replacing it in one `PUT` is not offered: it is the
  complete call of a parts upload that lets the project process the new content. Delete the file
  first (`DELETE /projects/{id}/files/{name}`), then upload it.

A project that stopped itself after its last file starts again within a minute of the `PUT`. Any
other `stopped` project is not started by it (completing a parts upload does that):
`POST /projects/{id}/start` afterwards, which is harmless on a running project. A project whose
[`startOnNewFiles`](/vod-express/concepts/projects#starting-again-on-a-new-file) is off is started by neither.

## Parts

The API starts an S3 multipart upload, signs a URL for each part, and completes it; any size, and
resumable.

```mermaid
sequenceDiagram
  participant C as Your client
  participant A as VOD Express API
  participant S as S3
  C->>A: POST /projects/{id}/uploads {name, size}
  A-->>C: uploadId, key, partSize, partCount
  loop every part
    C->>A: GET …/uploads/{uploadId}/parts/{n}?key=
    A-->>C: presigned url
    C->>S: PUT url (the part's bytes)
    S-->>C: 200, ETag header
  end
  C->>A: POST …/uploads/{uploadId}/complete {key, parts}
  A-->>C: {key} — the file is in input/
```

### The calls

### 1. Start

```bash
curl -s -X POST $API/projects/$PROJECT/uploads -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"name": "season 2/episode 01.mp4", "size": 734003200, "type": "video/mp4"}'
```

```json 201
{
  "data": {
    "uploadId": "kuoFY0gBzJF3QDKXnQxnv0epFyjyDyUuF20w4yjsjCE4Sq9hkf7Fn.QWqeFXYSSx03HDx…",
    "key": "season 2/episode 01.mp4",
    "partSize": 16777216,
    "partCount": 44,
    "url": "https://sw-vod-express.s3.us-east-1.amazonaws.com/projects/141/…?X-Amz-Signature=…",
    "urlExpiresAt": "2026-09-28T07:10:46.000Z"
  }
}
```

| Field | | |
|---|---|---|
| `name` | required | The file's name under `input/`. A `/` puts it in a subfolder. |
| `size` | required | Its size in bytes; it decides `partSize` and `partCount`. |
| `type` | optional | Its media type, stored with the file. |
| `overwrite` | `false` | Replace a file of the same name, which is then processed again. |

`key` is the name to send back in every later call. Cut the file into `partCount` parts of
`partSize` bytes; the last one is shorter. `url` and `urlExpiresAt` are the
[one-`PUT`](#one-put) alternative, present when it is open to the file; a parts upload ignores
them.

### 2. Send each part

Sign a part, then `PUT` its bytes to the URL with no other headers:

```bash
curl -s -H "$AUTH" "$API/projects/$PROJECT/uploads/$UPLOAD_ID/parts/1?key=season%202%2Fepisode%2001.mp4"
```

```json
{ "data": { "url": "https://sw-vod-express.s3.us-east-1.amazonaws.com/projects/141/…?partNumber=1&uploadId=…&X-Amz-Signature=…", "expiresAt": "2026-09-28T06:43:02.000Z" } }
```

S3 answers `200` with an `ETag` header: keep it. Sign each part just before you send it; a URL
works until `expiresAt`, an hour at most and sometimes less. Part signing has a budget of its own, 10,000 parts a
minute per account, so it never uses up the API's; parts may be sent in parallel, in any order.

### 3. Complete

```bash
curl -s -X POST $API/projects/$PROJECT/uploads/$UPLOAD_ID/complete -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"key": "season 2/episode 01.mp4", "parts": [{"partNumber": 1, "etag": "\"08b46181d7094b5ece88bb389c7499af\""}, …]}'
```

```json 200
{ "data": { "key": "season 2/episode 01.mp4", "projectStatus": "running" } }
```

The file is now in `input/` and listed by `GET /projects/{id}/files`. Until this call, S3 shows
nothing, so the project never sees half a file. A project that ran before and is stopped now
(auto-stopped after its last file, say) is started again, unless its `startOnNewFiles` is off. `projectStatus` is the project's
state afterwards: `stopped` for a project created with `autoStart: false` that has never run
(start it once every file is in), `error` if its last start failed. Repeating the call with the
same body is safe.

`uploadId` and `key` go into URLs, so URL-encode them (`encodeURIComponent`, `jq @uri`,
`urllib.parse.quote(…, safe="")`).

## Scripts

Complete uploaders in parts, with no dependency beyond the language's standard library (and
`requests` for Python). Each uploads one file of any size into a project and prints its entry from
the files listing. For a one-`PUT` upload, the [quick start](/vod-express/quickstart) has the
three lines.

```javascript upload.mjs (Node.js 20+)
// node upload.mjs <projectId> <file> [name under input/]
import { open, stat } from 'node:fs/promises';
import { basename } from 'node:path';

const API = process.env.SW_VOD_API ?? 'https://api-vod-express.syncwords.com/v1';
const TOKEN = process.env.SW_TOKEN;
const [projectId, file, name = basename(file)] = process.argv.slice(2);

async function api(method, path, body) {
const response = await fetch(`${API}${path}`, {
  method,
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    Accept: 'application/json',
    ...(body ? { 'Content-Type': 'application/json' } : {}),
  },
  body: body ? JSON.stringify(body) : undefined,
});
const json = await response.json();
if (!response.ok) throw new Error(`${method} ${path}: ${response.status} ${JSON.stringify(json)}`);
return json.data;
}

const { size } = await stat(file);
const upload = await api('POST', `/projects/${projectId}/uploads`, { name, size });
const base = `/projects/${projectId}/uploads/${encodeURIComponent(upload.uploadId)}`;
const key = encodeURIComponent(upload.key);

const handle = await open(file);
const parts = [];
try {
for (let partNumber = 1; partNumber <= upload.partCount; partNumber++) {
  const buffer = Buffer.alloc(Math.min(upload.partSize, size - (partNumber - 1) * upload.partSize));
  await handle.read(buffer, 0, buffer.length, (partNumber - 1) * upload.partSize);

  const { url } = await api('GET', `${base}/parts/${partNumber}?key=${key}`);
  const put = await fetch(url, { method: 'PUT', body: buffer });
  if (!put.ok) throw new Error(`part ${partNumber}: S3 answered ${put.status}`);

  parts.push({ partNumber, etag: put.headers.get('etag') });
  console.log(`part ${partNumber}/${upload.partCount}`);
}
} catch (error) {
await api('DELETE', `${base}?key=${key}`); // discard what was sent
throw error;
} finally {
await handle.close();
}

await api('POST', `${base}/complete`, { key: upload.key, parts });
const files = await api('GET', `/projects/${projectId}/files`);
console.log(files.find((f) => f.name === upload.key));
```

```python upload.py (Python 3.9+)
# python upload.py <projectId> <file> [name under input/]
import os
import sys
from urllib.parse import quote

import requests

API = os.environ.get("SW_VOD_API", "https://api-vod-express.syncwords.com/v1")
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {os.environ['SW_TOKEN']}", "Accept": "application/json"})

def api(method, path, body=None):
  response = session.request(method, API + path, json=body)
  if not response.ok:
      raise RuntimeError(f"{method} {path}: {response.status_code} {response.text}")
  return response.json().get("data") if response.content else None

project_id, file = sys.argv[1], sys.argv[2]
name = sys.argv[3] if len(sys.argv) > 3 else os.path.basename(file)
size = os.path.getsize(file)

upload = api("POST", f"/projects/{project_id}/uploads", {"name": name, "size": size})
base = f"/projects/{project_id}/uploads/{quote(upload['uploadId'], safe='')}"
key = quote(upload["key"], safe="")

parts = []
try:
  with open(file, "rb") as handle:
      for part_number in range(1, upload["partCount"] + 1):
          chunk = handle.read(upload["partSize"])
          url = api("GET", f"{base}/parts/{part_number}?key={key}")["url"]
          put = requests.put(url, data=chunk)
          put.raise_for_status()
          parts.append({"partNumber": part_number, "etag": put.headers["ETag"]})
          print(f"part {part_number}/{upload['partCount']}")
except Exception:
  api("DELETE", f"{base}?key={key}")  # discard what was sent
  raise

api("POST", f"{base}/complete", {"key": upload["key"], "parts": parts})
files = api("GET", f"/projects/{project_id}/files")
print(next(f for f in files if f["name"] == upload["key"]))
```

For speed, send 4 to 8 parts at a time: each part is independent.

## Resume an interrupted upload

An upload stays open for three days. To resume it, ask S3 which parts it already holds, and send
only the others:

```bash
curl -s -H "$AUTH" "$API/projects/$PROJECT/uploads/$UPLOAD_ID/parts?key=$KEY"
```

```json
{ "data": [{ "partNumber": 1, "size": 16777216, "etag": "08b46181d7094b5ece88bb389c7499af" }] }
```

Complete with every part, the listed ones included. To give up instead, abort, which discards the
parts:

```bash
curl -s -X DELETE -H "$AUTH" "$API/projects/$PROJECT/uploads/$UPLOAD_ID?key=$KEY"   # 204
```

## From a browser

The calls map one to one onto the callbacks of [Uppy](https://uppy.io)'s `@uppy/aws-s3` plugin
(version 5), so a web app gets chunking, parallel parts, retries and resume without a server of
its own:

```javascript
import Uppy from '@uppy/core';
import AwsS3 from '@uppy/aws-s3';

const api = (path, init = {}) =>
  fetch(`${API}/projects/${projectId}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
  }).then(async (r) => (r.status === 204 ? null : (await r.json()).data));
const q = encodeURIComponent;

new Uppy().use(AwsS3, {
  shouldUseMultipart: true,
  getChunkSize: (file) => Math.max(16 * 1024 * 1024, Math.ceil(file.size / 10000)),
  createMultipartUpload: (file) =>
    api('/uploads', { method: 'POST', body: JSON.stringify({ name: file.name, size: file.size, type: file.type }) })
      .then(({ uploadId, key }) => ({ uploadId, key })),
  signPart: (file, { uploadId, key, partNumber }) =>
    api(`/uploads/${q(uploadId)}/parts/${partNumber}?key=${q(key)}`).then(({ url }) => ({ url })),
  listParts: (file, { uploadId, key }) =>
    api(`/uploads/${q(uploadId)}/parts?key=${q(key)}`).then((parts) =>
      parts.map((p) => ({ PartNumber: p.partNumber, Size: p.size, ETag: p.etag }))),
  completeMultipartUpload: (file, { uploadId, key, parts }) =>
    api(`/uploads/${q(uploadId)}/complete`, {
      method: 'POST',
      body: JSON.stringify({ key, parts: parts.map((p) => ({ partNumber: p.PartNumber, etag: p.ETag })) }),
    }).then(() => ({})),
  abortMultipartUpload: (file, { uploadId, key }) =>
    api(`/uploads/${q(uploadId)}?key=${q(key)}`, { method: 'DELETE' }),
});
```

> **Warning:**
> The browser sends the parts to S3 itself, so S3 must allow your web app's origin. Send
> [support](/vod-express/support) the origins you upload from (for example `https://media.example.com`) to
> have them added, for each environment. Until
> then, part uploads fail in the browser with a CORS error while the API calls succeed. Scripts
> and servers are not affected.

## Errors

| Call | Status | Meaning |
|---|---|---|
| start | `409` | A file of that name is already in `input/` (`file_exists`). Send `overwrite: true` to replace it. |
| start | `409` | `overwrite: true`, but the running project may be processing that file (`file_processing`). Wait until it is `done` or `failed`, or stop the project. |
| start | `409` | The project reads its media from a feed (`project_reads_feed`), and takes no uploads. |
| start | `422` | A bad name (`errors.name`): not a media extension, `..` or empty segments, a `${`, over 255 bytes a segment; or its outputs would be written by another input already (`trailer.mov` beside `trailer.mkv`). |
| part `PUT` | `403` from S3 | The URL expired. Sign the part again and resend it. |
| complete | `422` | S3 refused the parts (`errors.parts`): a wrong or missing ETag, a missing part, or a part under 5 MiB other than the last. |
| list, complete, abort | `404` | `{"message": "Upload not found."}`: the upload was completed, aborted, collected after three days, or never started. Start again. |
