Skip to main content
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 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 call to obtain the video ID.

TUS endpoint

https://video.bunnycdn.com/tusupload

How it works

  1. Create a video object using the Create Video 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:
HeaderDescription
AuthorizationSignatureSHA256 signature for request validation
AuthorizationExpireUNIX timestamp (in seconds) when the upload expires
LibraryIdThe ID of the video library
VideoIdThe GUID of the previously created video object

Video metadata parameters

The following metadata can be passed with the TUS upload:
ParameterRequiredDescription
filetypeYesThe MIME type of the uploaded video (e.g., video/mp4)
titleYesThe title of the video
collectionNoThe GUID of the collection to upload to
thumbnailTimeNoTime 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:
SHA256(library_id + api_key + expiration_time + video_id)
The signature must be generated on your server to keep your API key secure. Never expose your API key in client-side code.
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");

Examples

Once you have the presigned credentials from your server, use a TUS client library to upload the file. The official tus-js-client is recommended for browser uploads. Install the TUS client:
npm install tus-js-client

Basic client-side upload

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.
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);
}

React with Express backend

Express server (server.js):
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:
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:
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
  },
});