> ## Documentation Index
> Fetch the complete documentation index at: https://docs.roughy.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Upload an asset

> Upload audio or video to Roughy via the create-asset → TUS pattern — code examples for JavaScript, Python, and raw cURL.

Roughy uses a two-step upload pattern:

1. **Create** the asset with a plain REST `POST /v1/assets` — the response body is the new [asset](/concepts/asset) (in `pending_upload`), and the TUS transfer URL comes back in the `Location` header.
2. **Stream** the bytes to that URL using the [TUS 1.0.0](https://tus.io/) wire protocol — chunked, resumable, connection-drop-safe.

Assets are scoped to a [project](/concepts/project): the project id goes in the body of step 1. Create the project once with `POST /v1/projects` and reuse its id for every asset in that workflow.

`Tus-Max-Size` is 50 GiB per upload. There is no per-chunk cap; the server streams every chunk straight through to durable storage, so any chunk size your TUS client uses is accepted (typical clients default to 5–50 MiB). The minimum upload size is 64 bytes; the server validates the file format from the head of the first PATCH and rejects anything smaller.

Asset type detection runs on the **first chunk**, not at finalize: if the bytes don't match a supported media format (audio or video), the upload is rejected after that first PATCH with `409 Conflict`, and the upstream resumable session is cancelled. A bogus upload therefore pays at most a single chunk's bandwidth — not the full `Upload-Length`. The first PATCH must carry at least 64 bytes for the format check to run; TUS clients with default chunk sizes (MB-class) sail through this without configuration.

## Authentication

Every request requires your Roughy API key as a Bearer token:

```
Authorization: Bearer sk_…
```

The same key authorizes the initial REST `POST` and every subsequent `PATCH`/`HEAD`/`DELETE`. TUS clients let you set headers once; they're attached to every request automatically.

## Step 1 — Create the asset

```bash theme={null}
POST https://api.roughy.ai/v1/assets
Content-Type: application/json

{
  "project_id": "<project_id>",
  "size_bytes": <file_size_in_bytes>,
  "language": "en"         // optional ISO 639-1 hint; omit for auto-detect
}
```

`size_bytes` is required — pass the exact byte count of the file you're about to upload. `language` is optional and **immutable** once set: it hints the spoken language of the media. (The cut's reference `script` is **not** an asset field — you supply it per `POST /v1/cuts`; see [Cut](/concepts/cut).)

The response is `201 Created` and the body **is the asset** (`pending_upload`); the byte-derived fields (`type`, `content_type`, `extension`, `size_bytes`) stay `null` until the first chunk identifies the media:

```json theme={null}
{
  "id": "019de7f7-3a52-74d1-9eeb-323e0bfb7bb9",
  "project_id": "019de7f5-7e21-7402-92a8-1c0e1fa84411",
  "type": null,
  "state": "pending_upload",
  "extension": null,
  "size_bytes": null,
  "content_type": null,
  "duration_seconds": null,
  "language": "en",
  "error_code": null,
  "error_message": null,
  "created_at": "2026-05-02T08:54:52.423000Z"
}
```

The transfer URL rides in the response **headers**, not the body:

```
Location: https://api.roughy.ai/v1/uploads/019de7f7-3a52-74d1-9eeb-323e0bfb7bb9
Upload-Expires: Sat, 09 May 2026 08:54:55 GMT
Tus-Resumable: 1.0.0
```

Pass the `Location` value directly to your TUS client as the upload URL — no further URL construction needed. (Lost it? See [Recovering a lost upload](#recovering-a-lost-upload).)

## Step 2 — Stream chunks via TUS

Point your TUS client at the `Location` URL (not at the create endpoint). The TUS server implements [TUS 1.0.0](https://tus.io/protocols/resumable-upload) Transfer + Termination + Expiration extensions:

* `OPTIONS /v1/uploads` — capability discovery (no auth, no body). Response advertises `Tus-Resumable: 1.0.0`, `Tus-Version: 1.0.0`, `Tus-Extension: termination,expiration`, `Tus-Max-Size: <50 GiB>`.
* `HEAD /v1/uploads/{upload_id}` — returns the canonical `Upload-Offset` for resume. Always carries `Cache-Control: no-store`.
* `PATCH /v1/uploads/{upload_id}` — uploads one chunk. Requires `Content-Type: application/offset+octet-stream`, `Upload-Offset` matching the server's current offset (mismatch → `409 Conflict`; HEAD to recover), and `Tus-Resumable: 1.0.0`. Response is `204 No Content` with the new `Upload-Offset`.
* `DELETE /v1/uploads/{upload_id}` — Termination extension. Cancels the upload and removes the asset row. Idempotent — repeat calls also return `204`.

Uploads are created in step 1 via `POST /v1/assets` — the transfer endpoint only streams chunks, so there's no TUS `creation` request to make here.

The `Upload-Expires` response header uses RFC 9110 HTTP-date format (e.g. `Wed, 25 Jun 2014 16:00:00 GMT`) per the TUS spec. Each successful PATCH refreshes the expiry by 48 h; an upload left idle past that window is reaped.

## Upload metadata

Asset type (`audio` vs `video`), MIME type, and file extension are detected from the uploaded bytes on the first chunk; you don't declare them. The only create-time fields are `project_id`, `size_bytes`, and the optional `language`.

## JavaScript / Browser

The most-installed TUS client is
[`tus-js-client`](https://github.com/tus/tus-js-client). 5 KB
gzipped, handles chunking and resume natively. Install:

```bash theme={null}
npm install tus-js-client
```

Two-step drop-in for a typical `<input type="file">` upload:

```js theme={null}
import * as tus from "tus-js-client"

const PROJECT_ID = "019de7f5-7e21-7402-92a8-1c0e1fa84411"

// Step 1 — create the asset via the REST endpoint.
const createResp = await fetch("https://api.roughy.ai/v1/assets", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${ROUGHY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    project_id: PROJECT_ID,
    size_bytes: file.size,
  }),
})
if (!createResp.ok) throw new Error(`create failed: ${createResp.status}`)
const asset = await createResp.json()
const uploadUrl = createResp.headers.get("Location") // the TUS transfer URL

// Step 2 — stream the file to the pre-allocated TUS URL.
// `uploadUrl` (not `endpoint`) tells tus-js-client to use an existing upload.
const upload = new tus.Upload(file, {
  uploadUrl,
  headers: {
    Authorization: `Bearer ${ROUGHY_API_KEY}`,
  },
  chunkSize: 50 * 1024 * 1024, // 50 MiB — sensible default for residential uplinks
  retryDelays: [0, 3000, 5000, 10000, 20000],
  onProgress: (sent, total) => {
    console.log(`${((sent / total) * 100).toFixed(1)}%`)
  },
  onSuccess: () => {
    console.log("uploaded asset:", asset.id)
  },
  onError: (err) => {
    console.error("upload failed:", err)
  },
})

upload.start()
```

`tus-js-client` automatically PATCHes chunks, advances on
`Upload-Offset` from the server response, and resumes from the
last acknowledged offset on connection drop.

## Python

[`tus-py-client`](https://github.com/tus/tus-py-client) is the
official Python client (`pip install tuspy`). It doesn't natively support
pre-allocated upload URLs, so the cleanest approach is to create the asset
via `httpx` / `requests`, then hand the `Location` URL to the
uploader for the PATCH loop:

```python theme={null}
import httpx
from tusclient.uploader import Uploader

PROJECT_ID = "019de7f5-7e21-7402-92a8-1c0e1fa84411"
FILE_PATH = "/path/to/video.mp4"
FILE_SIZE = os.path.getsize(FILE_PATH)
HEADERS = {"Authorization": f"Bearer {ROUGHY_API_KEY}"}

# Step 1 — create the asset.
resp = httpx.post(
    "https://api.roughy.ai/v1/assets",
    headers=HEADERS,
    json={
        "project_id": PROJECT_ID,
        "size_bytes": FILE_SIZE,
    },
)
resp.raise_for_status()
asset_id = resp.json()["id"]
upload_url = resp.headers["Location"]  # the TUS transfer URL

# Step 2 — stream the file to the pre-allocated TUS URL.
uploader = Uploader(
    FILE_PATH,
    url=upload_url,
    headers=HEADERS,
    chunk_size=50 * 1024 * 1024,  # 50 MiB
)
uploader.upload()  # blocks; resumes automatically on retry
print(f"done — asset_id={asset_id}")
```

## Raw cURL (debug / one-off)

When you don't have a TUS client handy — debug sessions, CI checks,
shell scripts — the protocol is small enough to drive by hand.
Each chunk is a `PATCH` with the running offset.

```bash theme={null}
TOKEN="sk_…"
API="https://api.roughy.ai"
PROJECT_ID="019de7f5-7e21-7402-92a8-1c0e1fa84411"
FILE="/path/to/video.mp4"
CHUNK_SIZE=$((64 * 1024 * 1024))   # 64 MiB

SIZE=$(stat -f%z "$FILE")  # macOS; on Linux: stat -c%s "$FILE"

# 1. Create the asset — the asset is the body, the transfer URL is the
#    Location header. -D dumps the response headers so we can read it.
BODY=$(curl -sf -X POST "$API/v1/assets" \
  -D /tmp/roughy-headers.txt \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"project_id\":\"$PROJECT_ID\",\"size_bytes\":$SIZE}")

ASSET_ID=$(echo "$BODY" | jq -r '.id')
UPLOAD_URL=$(awk -F': ' 'tolower($1) == "location" { gsub(/\r/, "", $2); print $2 }' /tmp/roughy-headers.txt)

# 2. PATCH chunks to the pre-allocated TUS URL.
OFFSET=0
while [ $OFFSET -lt $SIZE ]; do
  REMAINING=$((SIZE - OFFSET))
  THIS=$((REMAINING < CHUNK_SIZE ? REMAINING : CHUNK_SIZE))
  tail -c +$((OFFSET + 1)) "$FILE" | head -c "$THIS" | \
    curl -sf -X PATCH "$UPLOAD_URL" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Tus-Resumable: 1.0.0" \
      -H "Upload-Offset: $OFFSET" \
      -H "Content-Type: application/offset+octet-stream" \
      -H "Content-Length: $THIS" \
      --data-binary @-
  OFFSET=$((OFFSET + THIS))
done

echo "Uploaded $OFFSET / $SIZE bytes; asset_id=$ASSET_ID"
```

To **resume** after an aborted run, ask the server for the current
offset with `HEAD` and continue from there:

```bash theme={null}
UPLOAD_URL="https://api.roughy.ai/v1/uploads/<upload_id>"

OFFSET=$(curl -sf -I -X HEAD "$UPLOAD_URL" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Tus-Resumable: 1.0.0" \
  | awk -F': ' 'tolower($1) == "upload-offset" { gsub(/\r/, "", $2); print $2 }')

echo "Resume from offset $OFFSET"
# → resume the PATCH loop with $OFFSET as the starting point
```

## Recovering a lost upload

If you didn't keep the `Location` URL from step 1, you don't have to start
over — re-fetch the asset and read its `upload_url`. While the asset is
`pending_upload`, its body carries the same TUS transfer URL (the `asset_id`
is in the `POST /v1/assets` response body):

```bash theme={null}
curl -sf "$API/v1/assets/$ASSET_ID" \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={null}
{
  "id": "019de7f7-3a52-74d1-9eeb-323e0bfb7bb9",
  "state": "pending_upload",
  "upload_url": "https://api.roughy.ai/v1/uploads/019de7f7-3a52-74d1-9eeb-323e0bfb7bb9"
}
```

`HEAD` the `upload_url` for the canonical offset, then resume the PATCH
loop. `upload_url` is `null` once the upload finalises. If you lost the
asset id too, `GET /v1/assets?state=pending_upload` lists every asset still
awaiting its bytes.

## Cancelling an in-flight upload

Sending `DELETE` to the upload URL cancels the upload and
deletes the underlying asset:

```bash theme={null}
curl -X DELETE "$UPLOAD_URL" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Tus-Resumable: 1.0.0"
```

Idempotent — a `DELETE` of an upload that's already gone is also
a `204`.

## Verifying the upload

After the final chunk the asset moves to `processing` while Roughy
prepares it. Poll `GET /v1/assets/{id}` until `state` is `ready`:

```bash theme={null}
curl "$API/v1/assets/$ASSET_ID" \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={null}
{
  "id": "019deafa-…",
  "project_id": "019de7f5-7e21-7402-92a8-1c0e1fa84411",
  "type": "video",
  "state": "ready",
  "extension": "mp4",
  "size_bytes": 14956800000,
  "content_type": "video/mp4",
  "duration_seconds": "3782.413",
  "language": "en",
  "error_code": null,
  "error_message": null,
  "created_at": "2026-05-02T08:54:52.423000Z",
  "download_url": "https://fly.storage.tigris.dev/assets/projects/019de7f5-…/019deafa-….mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=…"
}
```

`duration_seconds` is measured while the asset is `processing`, so it's
present by the time the asset is `ready`. Reaching `ready` settles the
per-minute charge — then the asset is ready to [cut](/concepts/cut).

## What's next

* [Cut](/concepts/cut) — turn the uploaded asset into a kept-segment plan.
* [Asset concept](/concepts/asset) — types, states, and lifecycle
  details.
* TUS protocol details: [tus.io](https://tus.io/) and the
  [implementations index](https://tus.io/implementations) for
  clients in other languages (Go, Rust, Ruby, PHP, iOS, Android).
