> ## Documentation Index
> Fetch the complete documentation index at: https://bunnynet-cb9733c2-nathan-draft-sep-8.mintlify.site/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](/stream/tus-resumable-uploads), and your API key stays in the server's environment. Everything here runs on Django and the standard library.

<Card title="Django example on GitHub" icon="https://mintcdn.com/bunnynet-cb9733c2-nathan-draft-sep-8/EMGBBzipJnaVqwed/logo/frameworks/django.svg?fit=max&auto=format&n=EMGBBzipJnaVqwed&q=85&s=1e5eb62f00796a8dc30c064f8db330f6" href="https://github.com/BunnyWay/examples/tree/main/stream/upload-tus-django" horizontal width="24" height="24" data-path="logo/frameworks/django.svg">
  A Django 6 app with no database.
</Card>

## Quickstart

<Steps>
  <Step title="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",
    ]
    ```
  </Step>

  <Step title="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.
  </Step>

  <Step title="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,
        }
    ```
  </Step>

  <Step title="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.
    </Warning>

    `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),
    ]
    ```
  </Step>

  <Step title="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](/stream/tus-resumable-uploads#resumable-tus-upload-faq) explains.
  </Step>
</Steps>

## 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](/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

<AccordionGroup>
  <Accordion title="/api/uploads returns 403 with CSRF verification failed">
    The template needs the `csrf-token` meta tag, and the `fetch` needs the `X-CSRFToken` header.
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.