API

Upload videos

Version 2026-11-01OpenAPI 3.1Download openapi.json

Send large files with chunks, retries and resumable transfers.

You will send bytes directly to an upload endpoint, resume after an interruption, and know when playback is ready. The API creates your video; tus 1.0 transfers its bytes.

Create an upload target

curl
response=$(curl --fail-with-body --request POST 'https://api.todayfiles.com/v1/videos' \
  --header "Authorization: Bearer $TODAYFILES_API_KEY" \
  --header 'Idempotency-Key: replace-with-a-unique-request-key' \
  --header 'Content-Type: application/json' \
  --data '{"title":"Launch trailer","source":{"type":"upload"}}')
printf '%s\n' "$response"
Node
const response = await fetch("https://api.todayfiles.com/v1/videos", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.TODAYFILES_API_KEY,
    "Idempotency-Key": "replace-with-a-unique-request-key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"title":"Launch trailer","source":{"type":"upload"}}),
});
if (!response.ok) throw new Error(await response.text());
const result = response.status === 204 ? null : await response.json();
console.log(result);
Python
import json
import os
import requests

response = requests.request(
    "POST", "https://api.todayfiles.com/v1/videos",
    headers={
        "Authorization": "Bearer " + os.environ["TODAYFILES_API_KEY"],
        "Idempotency-Key": "replace-with-a-unique-request-key",
    },
    json=json.loads("{\"title\":\"Launch trailer\",\"source\":{\"type\":\"upload\"}}"),
    timeout=30,
)
response.raise_for_status()
print(response.json() if response.content else None)
PHP
<?php
$client = curl_init("https://api.todayfiles.com/v1/videos");
curl_setopt_array($client, [
    CURLOPT_CUSTOMREQUEST => "POST",
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('TODAYFILES_API_KEY'),
        'Idempotency-Key: replace-with-a-unique-request-key',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => "{\"title\":\"Launch trailer\",\"source\":{\"type\":\"upload\"}}",
]);
$body = curl_exec($client);
if ($body === false) throw new RuntimeException(curl_error($client));
$status = curl_getinfo($client, CURLINFO_RESPONSE_CODE);
curl_close($client);
if ($status >= 400) throw new RuntimeException($body);
$result = $body === '' ? null : json_decode($body, true, 512, JSON_THROW_ON_ERROR);
print_r($result);
Go
package main

import (
    "encoding/json"
    "io"
    "net/http"
    "os"
    "strings"
    "time"
)

func main() {
    request, err := http.NewRequest("POST", "https://api.todayfiles.com/v1/videos", strings.NewReader("{\"title\":\"Launch trailer\",\"source\":{\"type\":\"upload\"}}"))
    if err != nil { panic(err) }
    request.Header.Set("Authorization", "Bearer " + os.Getenv("TODAYFILES_API_KEY"))
    request.Header.Set("Idempotency-Key", "replace-with-a-unique-request-key")
    request.Header.Set("Content-Type", "application/json")
    response, err := (&http.Client{Timeout: 30 * time.Second}).Do(request)
    if err != nil { panic(err) }
    defer response.Body.Close()
    body, err := io.ReadAll(io.LimitReader(response.Body, 1 << 20))
    if err != nil { panic(err) }
    if response.StatusCode >= 400 { panic(string(body)) }
    var result any
    if len(body) > 0 {
        if err := json.Unmarshal(body, &result); err != nil { panic(err) }
    }
    if err := json.NewEncoder(os.Stdout).Encode(result); err != nil { panic(err) }
}

Keep the response in memory. The upload target contains its own max_bytes, expires_at, endpoint, metadata and chunk-size guidance. The configured ceiling can be 50 GiB (53,687,091,200 bytes). The returned max_bytes is the ceiling for this target. A creation above it is refused before bytes are accepted.

Send the file

Use the endpoint exactly as returned. Your API key authenticates API requests; the returned metadata authenticates tus creation. Do not send your API key to the upload endpoint. No filename metadata is needed: set the title when you create the video.

Install the client for your language: npm install tus-js-client, pip install requests tuspy, composer require ankitpokhrel/tus-php, or go get github.com/eventials/go-tus. The curl example uses Bash, curl 7.84 or newer, Python 3, and GNU dd.

Each snippet continues with the creation response: Node uses result, Python uses response.json(), PHP uses $result. When combining the PHP snippets in one file, keep only the first <?php opening tag. Pipe the creation JSON into the Go program. For curl, capture the first request in a shell variable named response.

curl
set -euo pipefail
FILE=trailer.mp4
readarray -t target < <(printf '%s' "$response" | python3 -c '
import sys, json, base64
u = json.load(sys.stdin)["upload"]
print(u["tus"]["endpoint"])
print(",".join(k+" "+base64.b64encode(v.encode()).decode()
               for k,v in u["tus"]["metadata"].items()))
print(u["max_bytes"])
')
size=$(stat -c %s "$FILE")
(( size <= target[2] )) || { echo 'File exceeds max_bytes'; exit 1; }
if [[ -z ${UPLOAD_URL:-} ]]; then
  UPLOAD_URL=$(curl --fail-with-body --silent --show-error \
    --request POST "${target[0]}" --output /dev/null \
    --header 'Tus-Resumable: 1.0.0' --header "Upload-Length: $size" \
    --header "Upload-Metadata: ${target[1]}" \
    --write-out '%header{location}')
  printf 'Resume URL: %s\n' "$UPLOAD_URL"
fi
offset=$(curl --fail-with-body --silent --show-error --head \
  "$UPLOAD_URL" --header 'Tus-Resumable: 1.0.0' \
  --output /dev/null --write-out '%header{upload-offset}')
while (( offset < size )); do
  count=$(( size - offset < 16777216 ? size - offset : 16777216 ))
  dd if="$FILE" iflag=skip_bytes,count_bytes skip="$offset" count="$count" status=none |
    curl --fail-with-body --silent --show-error --request PATCH "$UPLOAD_URL" \
      --header 'Tus-Resumable: 1.0.0' \
      --header 'Content-Type: application/offset+octet-stream' \
      --header "Upload-Offset: $offset" --data-binary @- --output /dev/null
  offset=$((offset + count))
done
Node
import fs from "node:fs";
import * as tus from "tus-js-client";

const filePath = "trailer.mp4";
const target = result.upload;
if (fs.statSync(filePath).size > target.max_bytes) throw new Error("File exceeds max_bytes");
const storage = new tus.FileUrlStorage(".upload-urls");
// Persist URL records without the temporary metadata values.
const urlStorage = {
  findAllUploads: () => storage.findAllUploads(),
  findUploadsByFingerprint: fp => storage.findUploadsByFingerprint(fp),
  removeUpload: key => storage.removeUpload(key),
  addUpload: (fp, { metadata, ...record }) => storage.addUpload(fp, record),
};
await new Promise((resolve, reject) => {
  const upload = new tus.Upload(fs.createReadStream(filePath), {
    endpoint: target.tus.endpoint,
    metadata: target.tus.metadata,
    chunkSize: target.tus.chunk_size.recommended,
    retryDelays: [0, 1000, 3000, 5000, 10000, 30000, 60000],
    storeFingerprintForResuming: true,
    removeFingerprintOnSuccess: true,
    urlStorage,
    onError: reject,
    onSuccess: resolve,
  });
  upload.findPreviousUploads().then(previous => {
    const match = previous.find(p => p.uploadUrl.startsWith(target.tus.endpoint));
    if (match) upload.resumeFromPreviousUpload(match);
    upload.start();
  }).catch(reject);
});
Python
import os
from tusclient import client
from tusclient.storage.filestorage import FileStorage

target = response.json()["upload"]
path = "trailer.mp4"
if os.path.getsize(path) > target["max_bytes"]:
    raise ValueError("File exceeds max_bytes")
# This store keeps fingerprints and upload URLs, without temporary metadata.
urls = FileStorage(".upload-urls.json")
try:
    uploader = client.TusClient(target["tus"]["endpoint"]).uploader(
        path, metadata=target["tus"]["metadata"],
        chunk_size=target["tus"]["chunk_size"]["recommended"],
        retries=6, retry_delay=10, store_url=True, url_storage=urls,
    )
    uploader.upload()
finally:
    urls.close()
PHP
<?php
require 'vendor/autoload.php';

$target = $result['upload'];
$path = 'trailer.mp4';
if (filesize($path) > $target['max_bytes']) throw new RuntimeException('File exceeds max_bytes');
$uploader = new \TusPhp\Tus\Client($target['tus']['endpoint']);
$uploader->setApiPath($target['tus']['endpoint']);
$uploader->setKey($result['video']['id'])->file($path);
// Replace the library's default metadata after file() sets it.
$uploader->setMetadata($target['tus']['metadata']);
do {
    $offset = $uploader->upload($target['tus']['chunk_size']['recommended']);
} while ($offset < filesize($path));
Go
package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "time"
    tus "github.com/eventials/go-tus"
    "github.com/eventials/go-tus/memorystore"
)

func main() {
    var created struct {
        Upload struct {
            MaxBytes int64 `json:"max_bytes"`
            TUS struct {
                Endpoint string `json:"endpoint"`
                Metadata map[string]string `json:"metadata"`
            } `json:"tus"`
        } `json:"upload"`
    }
    if err := json.NewDecoder(os.Stdin).Decode(&created); err != nil { panic(err) }
    file, err := os.Open("trailer.mp4")
    if err != nil { panic(err) }
    defer file.Close()
    info, err := file.Stat()
    if err != nil { panic(err) }
    if info.Size() > created.Upload.MaxBytes { panic("File exceeds max_bytes") }
    urls, err := memorystore.NewMemoryStore()
    if err != nil { panic(err) }
    config := &tus.Config{ChunkSize: 16 << 20, Resume: true, Store: urls,
        HttpClient: &http.Client{Timeout: 90 * time.Second}}
    client, err := tus.NewClient(created.Upload.TUS.Endpoint, config)
    if err != nil { panic(err) }
    upload, err := tus.NewUploadFromFile(file)
    if err != nil { panic(err) }
    upload.Metadata = tus.Metadata(created.Upload.TUS.Metadata)
    uploader, err := client.CreateOrResumeUpload(upload)
    if err != nil { panic(err) }
    if err := uploader.Upload(); err != nil { panic(err) }
    fmt.Println("Bytes sent; wait for video.ready")
}

The Go example resumes within one process. Use a durable implementation of the library's Store interface to resume after a restart. With PHP, keep the same key and URL cache between runs and configure its cache lifetime for your pause duration. The Python client, PHP client, and Go client document their storage interfaces.

Choose a chunk size

Setting Bytes Use
Minimum 5,242,880 5 MiB; the final chunk may be smaller
Recommended 16,777,216 16 MiB; the dashboard uses this size
Maximum on the upload hostname 67,108,864 64 MiB

The upload hostname passes through Cloudflare, whose request-body cap is 100 MB. Keep individual PATCH requests at or below the returned chunk_size.max. A request above the edge cap can return an HTML 413. A target on a direct IP reports a null maximum; use the recommended size for predictable memory use.

Resume after a network change

Keep the video id, the created tus upload URL, and a fingerprint of the original file. Resume that URL with the same file: a tus HEAD request returns Upload-Offset, and the client continues from that byte. Do not create another video for a temporary interruption.

The curl example accepts an existing UPLOAD_URL. Node and Python find their saved URL. PHP uses its saved key and URL cache. A changed filename, size or modification time can prevent a fingerprint match; offer your user a way to select the original file again.

expires_at is the deadline to create an upload, normally two hours after the target is issued. Incomplete transfers have no announced tus expiration. Continued PATCH access after the creation token expires still requires live verification; do not promise a longer lifetime from this deadline alone. To finish an interrupted transfer, keep resuming the exact same tus URL: an upload that has already started continues there, and a tus HEAD request tells you the offset. A new target from POST /videos/{id}/upload is available only while the video is waiting_for_upload; if a resume is refused at that point, request one and send the file again for the same video. For a video that is processing, ready or failed, that request returns 409 video_not_uploadable; create a new video and upload the file again.

curl
curl --fail-with-body --request POST 'https://api.todayfiles.com/v1/videos/h87x/upload' \
  --header "Authorization: Bearer $TODAYFILES_API_KEY" \
  --header 'Idempotency-Key: replace-with-a-unique-request-key'
Node
const response = await fetch("https://api.todayfiles.com/v1/videos/h87x/upload", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.TODAYFILES_API_KEY,
    "Idempotency-Key": "replace-with-a-unique-request-key",
  },
});
if (!response.ok) throw new Error(await response.text());
const result = response.status === 204 ? null : await response.json();
console.log(result);
Python
import json
import os
import requests

response = requests.request(
    "POST", "https://api.todayfiles.com/v1/videos/h87x/upload",
    headers={
        "Authorization": "Bearer " + os.environ["TODAYFILES_API_KEY"],
        "Idempotency-Key": "replace-with-a-unique-request-key",
    },
    timeout=30,
)
response.raise_for_status()
print(response.json() if response.content else None)
PHP
<?php
$client = curl_init("https://api.todayfiles.com/v1/videos/h87x/upload");
curl_setopt_array($client, [
    CURLOPT_CUSTOMREQUEST => "POST",
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('TODAYFILES_API_KEY'),
        'Idempotency-Key: replace-with-a-unique-request-key',
    ],
]);
$body = curl_exec($client);
if ($body === false) throw new RuntimeException(curl_error($client));
$status = curl_getinfo($client, CURLINFO_RESPONSE_CODE);
curl_close($client);
if ($status >= 400) throw new RuntimeException($body);
$result = $body === '' ? null : json_decode($body, true, 512, JSON_THROW_ON_ERROR);
print_r($result);
Go
package main

import (
    "encoding/json"
    "io"
    "net/http"
    "os"
    "time"
)

func main() {
    request, err := http.NewRequest("POST", "https://api.todayfiles.com/v1/videos/h87x/upload", nil)
    if err != nil { panic(err) }
    request.Header.Set("Authorization", "Bearer " + os.Getenv("TODAYFILES_API_KEY"))
    request.Header.Set("Idempotency-Key", "replace-with-a-unique-request-key")
    response, err := (&http.Client{Timeout: 30 * time.Second}).Do(request)
    if err != nil { panic(err) }
    defer response.Body.Close()
    body, err := io.ReadAll(io.LimitReader(response.Body, 1 << 20))
    if err != nil { panic(err) }
    if response.StatusCode >= 400 { panic(string(body)) }
    var result any
    if len(body) > 0 {
        if err := json.Unmarshal(body, &result); err != nil { panic(err) }
    }
    if err := json.NewEncoder(os.Stdout).Encode(result); err != nil { panic(err) }
}

Upload from a URL

Create a video with source.type set to url and source.url set to an HTTP or HTTPS URL, at most 2,000 characters. Keep the URL reachable until processing finishes. URL ingest has a separate service size limit; the direct-upload ceiling does not establish the fetch ceiling.

json
{"title":"Launch trailer","source":{"type":"url","url":"https://example.com/trailer.mp4"}}

Wait for playback

The video changes from waiting_for_upload to processing, then to ready or failed. Subscribe to video.ready and video.failed for production. For a manual test, poll GET /videos/{id} no more often than every five seconds. GET /videos/{id}/upload follows processing; the tus client reports the transfer progress. A video that shows no upload or processing activity for a day also becomes failed with failure.reason processing_failed; create a new video and upload again. If the service finishes it later, including when the complete file arrives through the old tus URL, the video becomes ready and video.ready is sent.

What's next

Receive a signed webhook or publish the player.