> ## 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. # Upload videos to Bunny Stream from Django > Upload video from the browser straight to Bunny Stream over TUS, with a Django backend that creates each video and signs the upload. Django creates the video in Bunny Stream and signs an upload. The browser sends the file to us over [TUS](https://bunny.net/docs/stream/tus-resumable-uploads.md), and your API key stays in the server's environment. Everything here runs on Django and the standard library. > [card] [Django example on GitHub](https://github.com/BunnyWay/examples/tree/main/stream/upload-tus-django) > > A Django 6 app with no database. ## Quickstart **1. Create the uploads app** These paths assume a project made with `django-admin startproject config .`. Create an app for the upload code and register it, so Django finds its templates and static files. ```bash theme={null} python manage.py startapp uploads ``` ```python config/settings.py theme={null} INSTALLED_APPS = [ # ... "uploads", ] ``` **2. Add your library credentials** ```bash .env theme={null} BUNNY_STREAM_LIBRARY_ID= BUNNY_STREAM_API_KEY= ``` Copy both from your library's **API** page. Django won't read `.env` on its own, and `uv run --env-file .env manage.py runserver` loads it for you. **3. Create the Bunny Stream module** `urllib` covers the two API calls. The upload signature is a SHA-256 of the library ID, API key, expiry, and video ID. ```python uploads/bunny_stream.py theme={null} import hashlib import json import os import time from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import quote from urllib.request import Request, urlopen # The status of a video still waiting for its file. STATUS_CREATED = 0 # Bunny checks the expiry on every TUS request, so leave room for slow uploads. SIGNATURE_TTL_SECONDS = 24 * 60 * 60 class BunnyStreamError(Exception): def __init__(self, message: str, status: int = 502): super().__init__(message) # 404 when Bunny Stream has no such video, 502 for anything else. self.status = status def _config() -> tuple[str, str]: return os.environ["BUNNY_STREAM_LIBRARY_ID"], os.environ["BUNNY_STREAM_API_KEY"] def _stream(path: str, *, method: str = "GET", body: dict[str, Any] | None = None) -> dict[str, Any]: library_id, api_key = _config() request = Request( f"https://video.bunnycdn.com/library/{library_id}/videos{path}", method=method, data=json.dumps(body).encode() if body is not None else None, headers={"AccessKey": api_key, "Accept": "application/json", "Content-Type": "application/json"}, ) try: with urlopen(request, timeout=30) as response: return json.load(response) except HTTPError as error: raise BunnyStreamError( f"Bunny Stream returned {error.code}: {error.read().decode()}", 404 if error.code == 404 else 502, ) from error except URLError as error: raise BunnyStreamError(f"Could not reach Bunny Stream: {error.reason}") from error def get_video(video_id: str) -> dict[str, Any]: library_id, _ = _config() video = _stream(f"/{quote(video_id, safe='')}") return { "status": video["status"], "encodeProgress": video["encodeProgress"], "embedUrl": f"https://player.mediadelivery.net/embed/{library_id}/{video_id}", } def create_video(title: str) -> str: return _stream("", method="POST", body={"title": title})["guid"] def sign_upload(video_id: str) -> dict[str, Any]: library_id, api_key = _config() expiration_time = int(time.time()) + SIGNATURE_TTL_SECONDS signature = hashlib.sha256(f"{library_id}{api_key}{expiration_time}{video_id}".encode()).hexdigest() return { "videoId": video_id, "libraryId": library_id, "expirationTime": expiration_time, "signature": signature, } ``` **4. Add the views** > [!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. `index` renders the page. `create_upload` re-signs an unfinished video when the browser sends its ID, and creates a new one otherwise. ```python uploads/views.py theme={null} import json from django.http import HttpRequest, HttpResponse, JsonResponse from django.shortcuts import render from django.views.decorators.http import require_GET, require_POST from . import bunny_stream @require_GET def index(request: HttpRequest) -> HttpResponse: return render(request, "uploads/index.html") @require_POST def create_upload(request: HttpRequest) -> JsonResponse: # Require a signed-in user here. This route is public and creates videos in your library. # Before re-signing a videoId, check that the user owns it. try: payload = json.loads(request.body) except json.JSONDecodeError: payload = None if not isinstance(payload, dict): return JsonResponse({"error": "Send a JSON object"}, status=400) title = payload.get("title") if not isinstance(title, str) or not title.strip(): return JsonResponse({"error": "title is required"}, status=400) try: video_id = payload.get("videoId") if not (isinstance(video_id, str) and _can_resume(video_id)): video_id = bunny_stream.create_video(title) return JsonResponse(bunny_stream.sign_upload(video_id)) except bunny_stream.BunnyStreamError as error: return JsonResponse({"error": str(error)}, status=502) @require_GET def video_status(request: HttpRequest, video_id: str) -> JsonResponse: # Require a signed-in user here, and check that they own this video ID. The route is public. try: return JsonResponse(bunny_stream.get_video(video_id)) except bunny_stream.BunnyStreamError as error: return JsonResponse({"error": str(error)}, status=error.status) def _can_resume(video_id: str) -> bool: try: return bunny_stream.get_video(video_id)["status"] == bunny_stream.STATUS_CREATED except bunny_stream.BunnyStreamError: return False ``` Add the three routes next to the admin route that `startproject` created. ```python config/urls.py theme={null} from django.contrib import admin from django.urls import path from uploads import views urlpatterns = [ path("admin/", admin.site.urls), path("", views.index), path("api/uploads", views.create_upload), path("api/videos/", views.video_status), ] ``` **5. Upload from the browser** Render the CSRF token into the page. The CSRF middleware rejects the `POST` without it. ```html uploads/templates/uploads/index.html theme={null} {% load static %} Upload to Bunny Stream
``` There's no build step. tus-js-client comes from jsDelivr, and the token travels as `X-CSRFToken`. ```js uploads/static/uploads/video-uploader.js theme={null} import * as tus from "https://cdn.jsdelivr.net/npm/tus-js-client@4.3.1/+esm"; const csrfToken = document.querySelector('meta[name="csrf-token"]').content; async function requestUpload(title, videoId) { const response = await fetch("/api/uploads", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken }, body: JSON.stringify({ title, videoId }), }); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? "Could not create the upload"); return body; } ``` tus-js-client sends the credentials as headers with every request. The video ID goes into `localStorage` against the file, which is how a reload finds its way back to the same upload. Add this to the same file. ```js theme={null} // Remembers which Bunny video a file was going into, so a reload can resume it. const videoKey = (file) => `bunny-video:${file.name}:${file.size}:${file.lastModified}`; // Aborting `signal` cancels the upload, even while it still waits on your server. export async function uploadVideo(file, { signal, onProgress, onSuccess, onError }) { const key = videoKey(file); const savedVideoId = localStorage.getItem(key); const credentials = await requestUpload(file.name, savedVideoId); if (signal?.aborted) return null; localStorage.setItem(key, credentials.videoId); const upload = new tus.Upload(file, { endpoint: "https://video.bunnycdn.com/tusupload", retryDelays: [0, 3000, 5000, 10000, 20000, 60000], removeFingerprintOnSuccess: true, headers: { AuthorizationSignature: credentials.signature, AuthorizationExpire: String(credentials.expirationTime), VideoId: credentials.videoId, LibraryId: credentials.libraryId, }, metadata: { filetype: file.type, title: file.name }, onProgress: (sent, total) => onProgress(Math.floor((sent / total) * 100)), onSuccess: () => { localStorage.removeItem(key); onSuccess(credentials.videoId); }, onError, }); // A stored upload URL belongs to one video, so only resume into the same one. const [previous] = await upload.findPreviousUploads(); if (signal?.aborted) return null; if (previous && credentials.videoId === savedVideoId) { upload.resumeFromPreviousUpload(previous); } signal?.addEventListener("abort", () => upload.abort()); upload.start(); return upload; } ``` `abort()` pauses. `start()` picks up from the last chunk we acknowledged. A 401 from the TUS endpoint means the signature doesn't match the headers. Check that the library ID and API key belong to the same library. A 400 means the expiry has already passed. Re-signing keeps the upload's original expiry, as the [TUS FAQ](https://bunny.net/docs/stream/tus-resumable-uploads.md#resumable-tus-upload-faq) explains. ## Play it once it's encoded We start encoding when the last chunk arrives. Poll your status route until `status` reaches `4` (finished), `5` or `6` (failed), then embed `embedUrl`. This goes in the same file too. ```js theme={null} const FINISHED = 4; const FAILED = [5, 6]; // Returns a function that stops polling. export function watchVideo(videoId, { onChange, onError }) { let timer; let active = true; async function poll() { const response = await fetch(`/api/videos/${videoId}`); const video = await response.json(); if (!active) return; if (!response.ok) return onError(new Error(video.error ?? "Could not read the video status")); onChange(video); if (video.status !== FINISHED && !FAILED.includes(video.status)) { timer = setTimeout(() => poll().catch(onError), 3000); } } poll().catch(onError); return () => { active = false; clearTimeout(timer); }; } ``` `encodeProgress` gives you a percentage to show in the meantime. A [webhook](https://bunny.net/docs/stream/webhooks.md) tells your server when encoding finishes. Then wire both to the page's `#video-file` input and `#video-output` element. Picking another file cancels the current upload. ```js theme={null} const input = document.querySelector("#video-file"); const output = document.querySelector("#video-output"); let cancel = () => {}; function show(video) { if (FAILED.includes(video.status)) { output.textContent = "Bunny Stream could not encode the video."; } else if (video.status !== FINISHED) { output.textContent = `Encoding… ${video.encodeProgress}%`; } else { const player = Object.assign(document.createElement("iframe"), { src: video.embedUrl, allow: "autoplay; encrypted-media; picture-in-picture; fullscreen", allowFullscreen: true, }); output.replaceChildren(player); } } input.addEventListener("change", async () => { const file = input.files[0]; if (!file) return; cancel(); const controller = new AbortController(); let stopWatching = () => {}; cancel = () => { controller.abort(); stopWatching(); }; const onError = (error) => (output.textContent = error.message); output.textContent = "Uploading… 0%"; try { await uploadVideo(file, { signal: controller.signal, onProgress: (percent) => (output.textContent = `Uploading… ${percent}%`), onSuccess: (videoId) => { stopWatching = watchVideo(videoId, { onChange: show, onError }); }, onError, }); } catch (error) { if (!controller.signal.aborted) onError(error); } }); ``` Start the server with `uv run --env-file .env manage.py runserver`, open [http://localhost:8000](http://localhost:8000), and choose a video. ## Before you deploy Wrap both views in `login_required` and record who owns each video ID. The example's `settings.py` is for development, and production needs `DJANGO_SECRET_KEY`, `DEBUG = False`, and `ALLOWED_HOSTS`. ## Troubleshooting > [accordion] /api/uploads returns 403 with CSRF verification failed > > The template needs the `csrf-token` meta tag, and the `fetch` needs the `X-CSRFToken` header. This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.