> ## 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.

<Card title="Django example on GitHub" icon="https://mintcdn.com/bunnynet-cb9733c2/08vlxCwhRdpoHfuV/logo/frameworks/django.svg?fit=max&auto=format&n=08vlxCwhRdpoHfuV&q=85&s=be7d91674999a75f04fd2318fa9795b5" 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="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):
        pass


    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()}") 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">
    `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, JsonResponse
    from django.views.decorators.http import require_GET, require_POST

    from . import bunny_stream


    @require_POST
    def create_upload(request: HttpRequest) -> JsonResponse:
        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:
        try:
            return JsonResponse(bunny_stream.get_video(video_id))
        except bunny_stream.BunnyStreamError as error:
            return JsonResponse({"error": str(error)}, status=502)


    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
    ```

    ```python config/urls.py theme={null}
    from django.urls import path

    from uploads import views

    urlpatterns = [
        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}
    <meta name="csrf-token" content="{{ csrf_token }}">
    <script type="module" src="{% static 'uploads/video-uploader.js' %}"></script>
    ```

    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.

    ```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}`;

    export async function uploadVideo(file, { onProgress, onSuccess, onError }) {
      const key = videoKey(file);
      const savedVideoId = localStorage.getItem(key);
      const credentials = await requestUpload(file.name, savedVideoId);
      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 (previous && credentials.videoId === savedVideoId) {
        upload.resumeFromPreviousUpload(previous);
      }
      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. Re-signing keeps the upload's original expiry, as the [TUS FAQ](/docs/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`.

```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.

## 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.