> ## Documentation Index
> Fetch the complete documentation index at: https://bunnynet-cb9733c2-nathan-draft-aug-24.mintlify.site/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](/api-reference/stream) call to obtain the video ID.

## TUS upload API endpoint

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

## How it works

1. **Create a video object** using the [Create Video API](/api-reference/stream) 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>

#### Resumable (TUS) upload FAQ

Common questions about how authorization and resumability behave when uploading video with the TUS resumable protocol.

<AccordionGroup>
  <Accordion title="Is AuthorizationExpire validated on every request, or only when the upload is created?">
    `AuthorizationExpire` is validated at the start of **every** `POST`, `HEAD`, and `PATCH` request - not only at upload creation. It is **not** rechecked during an active `PATCH`, so a chunk that is already streaming will not be interrupted mid-request if the signature expires while data is in flight.
  </Accordion>

  <Accordion title="Can I re-sign an existing video GUID to resume an in-progress upload instead of creating a new video object?">
    Yes. The same video GUID can be re-signed and used to resume through the existing TUS upload URL - there is no need to create a new video object.

    Note that a newly generated signature does **not** extend the upload resource's original expiry. Re-signing lets you continue an existing upload; it does not reset the clock on how long that upload remains available.
  </Accordion>

  <Accordion title="How long does an incomplete TUS upload stay resumable, and what's returned once it expires?">
    An incomplete upload remains resumable until the `AuthorizationExpire` value that was used when it was created, or after roughly **48 hours of inactivity** - whichever comes first.

    Once it expires, requesting the upload returns `404 Not Found `(not `410`).
  </Accordion>
</AccordionGroup>
