> ## Documentation Index > Fetch the complete documentation index at: https://bunny.net/docs/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](https://bunny.net/docs/api-reference/stream.md) 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](https://bunny.net/docs/api-reference/stream.md) 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] > > Put the upload routes behind your own authentication before you deploy. The create route makes a video in your library and hands back a signature that lets the caller upload into it. Left open, anyone who finds the URL can fill your library with uploads that you pay to store, encode, and deliver. > > Server Actions and API routes are public HTTP endpoints, even when nothing in your UI links to them. Check the user on every request, and check that they own a video ID before you re-sign it or return its status. > [!NOTE] > > A **401 Unauthorized** from the TUS endpoint means the `AuthorizationSignature` doesn't match the headers the request sends, or the `VideoId`, `AuthorizationSignature`, or `AuthorizationExpire` header is missing. The values your server signs must match the headers the client sends byte-for-byte: library ID, API key, expiration, and video ID. > > A **400 Bad Request** means `AuthorizationExpire` is already in the past, or `LibraryId` is missing or invalid. The response body says which. ```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} [card] [Laravel](https://bunny.net/docs/stream/upload/laravel.md) > [card] [Rails](https://bunny.net/docs/stream/upload/rails.md) > [card] [Django](https://bunny.net/docs/stream/upload/django.md) We have runnable apps for more stacks in [BunnyWay/examples](https://github.com/BunnyWay/examples/tree/main/stream). > [card] [Next.js](https://github.com/BunnyWay/examples/tree/main/stream/upload-tus-nextjs) > [card] [Nuxt](https://github.com/BunnyWay/examples/tree/main/stream/upload-tus-nuxt) > [card] [SvelteKit](https://github.com/BunnyWay/examples/tree/main/stream/upload-tus-sveltekit) > [card] [Uppy with React](https://github.com/BunnyWay/examples/tree/main/stream/upload-uppy-react) 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: String(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!"); }, }); // Each call signs a new video, so start fresh. To resume after a reload, see Resuming uploads. upload.start(); ``` #### Next.js App Router This example shows a complete implementation using Next.js with the App Router. ```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) { // Require a signed-in user here. This route is a public endpoint that creates // videos in your library and signs uploads into them. 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://player.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(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: String(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); }, }); // Each call signs a new video, so start fresh. To resume after a reload, see Resuming uploads. upload.start(); } function handleFileChange(event: React.ChangeEvent) { const file = event.target.files?.[0]; if (file) { handleUpload(file); } } return (
{uploading &&

Uploading: {progress}%

} {videoUrl && (