Skip to main content
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 (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 wire protocol — chunked, resumable, connection-drop-safe.
Assets are scoped to a 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

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.) 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:
{
  "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.)

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 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. 5 KB gzipped, handles chunking and resume natively. Install:
npm install tus-js-client
Two-step drop-in for a typical <input type="file"> upload:
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 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:
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.
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:
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):
curl -sf "$API/v1/assets/$ASSET_ID" \
  -H "Authorization: Bearer $TOKEN"
{
  "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:
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:
curl "$API/v1/assets/$ASSET_ID" \
  -H "Authorization: Bearer $TOKEN"
{
  "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.

What’s next

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