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

# TUS Resumable Uploads

> Upload videos to Bunny Stream using the TUS protocol for resumable, reliable uploads.

The TUS resumable upload endpoint allows resumable and presigned uploads of video files. This enables end-users to upload directly to Bunny Stream and greatly improves reliability on poor networks and mobile connections.

The endpoint uses the open [tus protocol](https://tus.io/) for resumable file uploads. Before a video can be uploaded through the TUS endpoint, a video object must be created through the [Create Video](/api-reference/stream) API call to obtain the video ID.

## TUS endpoint

```text theme={null}
https://video.bunnycdn.com/tusupload
```

## How it works

1. **Create a video object** using the [Create Video](/api-reference/stream) API to get a `videoId`.
2. **Generate a presigned signature** on your server using SHA256.
3. **Upload the file** from the client using a TUS client library with the presigned credentials.

This approach allows secure direct uploads from end-users without exposing your API key.

## Authentication

To authenticate a TUS upload request, the following headers must be included:

| Header                   | Description                                         |
| ------------------------ | --------------------------------------------------- |
| `AuthorizationSignature` | SHA256 signature for request validation             |
| `AuthorizationExpire`    | UNIX timestamp (in seconds) when the upload expires |
| `LibraryId`              | The ID of the video library                         |
| `VideoId`                | The GUID of the previously created video object     |

## Video metadata parameters

The following metadata can be passed with the TUS upload:

| Parameter       | Required | Description                                              |
| --------------- | -------- | -------------------------------------------------------- |
| `filetype`      | Yes      | The MIME type of the uploaded video (e.g., `video/mp4`)  |
| `title`         | Yes      | The title of the video                                   |
| `collection`    | No       | The GUID of the collection to upload to                  |
| `thumbnailTime` | No       | Time in milliseconds to extract the main video thumbnail |

## Generating the signature

The authorization signature is generated by hashing the concatenation of several values using SHA256:

```text theme={null}
SHA256(library_id + api_key + expiration_time + video_id)
```

<Warning>
  The signature must be generated on your server to keep your API key secure. Never expose your API key in client-side code.
</Warning>

<Note>
  A **401 Unauthorized** from the TUS endpoint usually means one of: the `AuthorizationExpire` timestamp has passed before the upload finished, the `AuthorizationSignature` was generated with a different value than the request actually sends (mismatched library ID, API key, expiration, or video ID), or one of the `LibraryId` / `VideoId` headers is missing. The values used to generate the signature on your server must match the headers sent on the client byte-for-byte.
</Note>

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "crypto";

  const libraryId = "your-library-id";
  const apiKey = "your-api-key";
  const videoId = "video-guid-from-create-call";
  const expirationTime = Math.floor(Date.now() / 1000) + 86400; // 24 hours from now

  const signatureString = `${libraryId}${apiKey}${expirationTime}${videoId}`;
  const signature = crypto
    .createHash("sha256")
    .update(signatureString)
    .digest("hex");
  ```

  ```php PHP theme={null}
  <?php

  $libraryId = "your-library-id";
  $apiKey = "your-api-key";
  $videoId = "video-guid-from-create-call";
  $expirationTime = time() + 86400; // 24 hours from now

  $signatureString = $libraryId . $apiKey . $expirationTime . $videoId;
  $signature = hash("sha256", $signatureString);
  ```

  ```python Python theme={null}
  import hashlib
  import time

  library_id = "your-library-id"
  api_key = "your-api-key"
  video_id = "video-guid-from-create-call"
  expiration_time = int(time.time()) + 86400  # 24 hours from now

  signature_string = f"{library_id}{api_key}{expiration_time}{video_id}"
  signature = hashlib.sha256(signature_string.encode()).hexdigest()
  ```
</CodeGroup>

### Examples

Once you have the presigned credentials from your server, use a TUS client library to upload the file. The official [tus-js-client](https://github.com/tus/tus-js-client) is recommended for browser uploads.

**Install the TUS client:**

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

#### Basic client-side upload

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

// These values come from your server after creating the video
const { videoId, libraryId, expirationTime, signature } =
  await fetchUploadCredentials();

const file = document.getElementById("fileInput").files[0];

const upload = new tus.Upload(file, {
  endpoint: "https://video.bunnycdn.com/tusupload",
  retryDelays: [0, 3000, 5000, 10000, 20000, 60000, 60000],
  headers: {
    AuthorizationSignature: signature,
    AuthorizationExpire: expirationTime,
    VideoId: videoId,
    LibraryId: libraryId,
  },
  metadata: {
    filetype: file.type,
    title: file.name,
  },
  onError: function (error) {
    console.error("Upload failed:", error);
  },
  onProgress: function (bytesUploaded, bytesTotal) {
    const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
    console.log(`Upload progress: ${percentage}%`);
  },
  onSuccess: function () {
    console.log("Upload complete!");
  },
});

// Check for previous uploads to resume
upload.findPreviousUploads().then(function (previousUploads) {
  if (previousUploads.length) {
    upload.resumeFromPreviousUpload(previousUploads[0]);
  }
  upload.start();
});
```

#### Next.js App Router

This example shows a complete implementation using Next.js with the App Router.

<CodeGroup>
  ```typescript app/api/upload/route.ts theme={null}
  import { type NextRequest, NextResponse } from "next/server";
  import { createHash } from "node:crypto";

  interface CreateVideoResponse {
    guid: string;
    title: string;
    libraryId: number;
  }

  interface UploadCredentials {
    videoId: string;
    libraryId: string;
    expirationTime: number;
    signature: string;
    embedUrl: string;
  }

  export async function POST(request: NextRequest) {
    const BUNNY_API_KEY = process.env.BUNNY_STREAM_API_KEY;
    const BUNNY_LIBRARY_ID = process.env.BUNNY_STREAM_LIBRARY_ID;

    if (!BUNNY_API_KEY || !BUNNY_LIBRARY_ID) {
      return NextResponse.json(
        { error: "Bunny Stream not configured" },
        { status: 500 },
      );
    }

    const { title } = (await request.json()) as { title?: string };

    // Step 1: Create a video object in Bunny Stream
    const createResponse = await fetch(
      `https://video.bunnycdn.com/library/${BUNNY_LIBRARY_ID}/videos`,
      {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          AccessKey: BUNNY_API_KEY,
        },
        body: JSON.stringify({
          title: title ?? "Untitled Video",
        }),
      },
    );

    if (!createResponse.ok) {
      const error = await createResponse.text();
      console.error("Failed to create Bunny video:", error);
      return NextResponse.json(
        { error: "Failed to create video" },
        { status: 500 },
      );
    }

    const video = (await createResponse.json()) as CreateVideoResponse;

    // Step 2: Generate TUS upload credentials
    const expirationTime = Math.floor(Date.now() / 1000) + 86400; // 24 hours

    const signature = createHash("sha256")
      .update(`${BUNNY_LIBRARY_ID}${BUNNY_API_KEY}${expirationTime}${video.guid}`)
      .digest("hex");

    return NextResponse.json({
      videoId: video.guid,
      libraryId: BUNNY_LIBRARY_ID,
      expirationTime,
      signature,
      embedUrl: `https://iframe.mediadelivery.net/embed/${BUNNY_LIBRARY_ID}/${video.guid}`,
    } satisfies UploadCredentials);
  }
  ```

  ```typescript components/VideoUploader.tsx theme={null}
  "use client";

  import { useState } from "react";
  import * as tus from "tus-js-client";

  interface UploadCredentials {
    videoId: string;
    libraryId: string;
    expirationTime: number;
    signature: string;
    embedUrl: string;
  }

  export function VideoUploader() {
    const [progress, setProgress] = useState(0);
    const [uploading, setUploading] = useState(false);
    const [videoUrl, setVideoUrl] = useState<string | null>(null);

    async function handleUpload(file: File) {
      setUploading(true);
      setProgress(0);

      // Get upload credentials from your API
      const response = await fetch("/api/upload", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title: file.name }),
      });

      if (!response.ok) {
        setUploading(false);
        throw new Error("Failed to get upload credentials");
      }

      const credentials = (await response.json()) as UploadCredentials;

      // Create TUS upload
      const upload = new tus.Upload(file, {
        endpoint: "https://video.bunnycdn.com/tusupload",
        retryDelays: [0, 3000, 5000, 10000, 20000, 60000],
        headers: {
          AuthorizationSignature: credentials.signature,
          AuthorizationExpire: credentials.expirationTime,
          VideoId: credentials.videoId,
          LibraryId: credentials.libraryId,
        },
        metadata: {
          filetype: file.type,
          title: file.name,
        },
        onError(error) {
          console.error("Upload error:", error);
          setUploading(false);
        },
        onProgress(bytesUploaded, bytesTotal) {
          setProgress(Math.round((bytesUploaded / bytesTotal) * 100));
        },
        onSuccess() {
          setVideoUrl(credentials.embedUrl);
          setUploading(false);
        },
      });

      // Resume previous upload if available
      const previousUploads = await upload.findPreviousUploads();
      if (previousUploads.length) {
        upload.resumeFromPreviousUpload(previousUploads[0]);
      }

      upload.start();
    }

    function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
      const file = event.target.files?.[0];
      if (file) {
        handleUpload(file);
      }
    }

    return (
      <div>
        <input
          type="file"
          accept="video/*"
          onChange={handleFileChange}
          disabled={uploading}
        />
        {uploading && <p>Uploading: {progress}%</p>}
        {videoUrl && (
          <iframe
            src={videoUrl}
            width="640"
            height="360"
            allow="autoplay; fullscreen"
          />
        )}
      </div>
    );
  }
  ```
</CodeGroup>

#### React with Express backend

**`Express server (server.js):`**

```javascript theme={null}
const express = require("express");
const crypto = require("crypto");
const cors = require("cors");

const app = express();
app.use(cors());
app.use(express.json());

const BUNNY_API_KEY = process.env.BUNNY_STREAM_API_KEY;
const BUNNY_LIBRARY_ID = process.env.BUNNY_STREAM_LIBRARY_ID;

app.post("/api/create-upload", async (req, res) => {
  const { title } = req.body;

  // Create video object
  const response = await fetch(
    `https://video.bunnycdn.com/library/${BUNNY_LIBRARY_ID}/videos`,
    {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        AccessKey: BUNNY_API_KEY,
      },
      body: JSON.stringify({ title }),
    },
  );

  const video = await response.json();
  const videoId = video.guid;
  const expirationTime = Math.floor(Date.now() / 1000) + 86400;

  const signature = crypto
    .createHash("sha256")
    .update(`${BUNNY_LIBRARY_ID}${BUNNY_API_KEY}${expirationTime}${videoId}`)
    .digest("hex");

  res.json({
    videoId,
    libraryId: BUNNY_LIBRARY_ID,
    expirationTime,
    signature,
  });
});

app.listen(3001);
```

## Resuming uploads

One of the key benefits of TUS is the ability to resume interrupted uploads. The `tus-js-client` library handles this automatically:

```javascript theme={null}
upload.findPreviousUploads().then(function (previousUploads) {
  // If there are previous uploads, resume from the first one
  if (previousUploads.length) {
    upload.resumeFromPreviousUpload(previousUploads[0]);
  }
  upload.start();
});
```

The client stores upload progress in the browser's local storage by default. If an upload is interrupted (due to network issues, browser refresh, etc.), it can be resumed from where it left off.

## Error handling

Implement proper error handling to provide a good user experience:

```javascript theme={null}
const upload = new tus.Upload(file, {
  // ... other options
  onError: function (error) {
    if (error.originalRequest) {
      // Network or server error
      console.error("Server error:", error.message);
    } else {
      // Client-side error
      console.error("Upload error:", error.message);
    }
  },
  onShouldRetry: function (error, retryAttempt, options) {
    // Retry on network errors or 5xx server errors
    const status = error.originalResponse?.getStatus();
    if (status >= 500 && status < 600) {
      return true;
    }
    return true; // Retry by default
  },
});
```

<Info>
  Ensure any `AuthorizationExpire` timestamp is at least 1 hour (3600 seconds) or longer to make sure uploads are completed before authorization expires.
</Info>
