> ## 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](/docs/stream/tus-resumable-uploads), and your API key stays in the server's environment. Everything here runs on Django and the standard library.

> **[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/<str:video_id>", 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 %}<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="csrf-token" content="{{ csrf_token }}">
    <title>Upload to Bunny Stream</title>
    <script type="module" src="{% static 'uploads/video-uploader.js' %}"></script>
  </head>
  <body>
    <input type="file" id="video-file" accept="video/*">
    <div id="video-output"></div>
  </body>
</html>
```

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](/docs/stream/tus-resumable-uploads#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](/docs/stream/webhooks) 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

> **/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.