Skip to main content
Technology & EngineeringVideo Services256 lines

Bunny Stream

"Bunny.net Stream: video hosting, HLS delivery, direct uploads, video collections, thumbnail generation, token authentication, webhook events"

Quick Summary25 lines
Bunny.net Stream is a cost-effective video hosting and delivery platform built on Bunny.net's global CDN. It handles transcoding, adaptive bitrate streaming, and delivery through 100+ PoPs worldwide. The mental model is simple: you create a Video Library, upload videos to it, and Bunny handles everything else — transcoding into multiple resolutions, generating HLS manifests, creating thumbnails, and delivering via CDN. All interactions go through a REST API authenticated with a library-level API key. Use direct uploads via TUS protocol for client-side ingest, organize content with collections, and rely on webhook callbacks for processing status updates.

## Key Points

- Use TUS protocol for client-side uploads to get resumable, chunked uploads out of the box.
- Organize videos into collections for logical grouping and easier management.
- Enable token authentication on your video library for any content that requires access control.
- Store the video GUID in your database and construct CDN URLs dynamically using your hostname.
- Use the webhook endpoint to track transcoding completion rather than polling the status API.
- Set appropriate cache headers and leverage Bunny's CDN for thumbnail delivery.
- Do not poll the video status endpoint in a loop; configure a webhook URL in your library settings.
- Do not hardcode CDN hostnames; they can change if you reconfigure your library's CDN settings.
- Do not upload large files through your server; use TUS direct uploads from the client.
- Do not skip token authentication for paid or private content; unsigned URLs are publicly accessible.
- Do not forget to handle status code 5 (error) in webhooks; transcoding can fail for corrupt or unsupported inputs.

## Quick Example

```
BUNNY_API_KEY=your-library-api-key
BUNNY_LIBRARY_ID=your-library-id
BUNNY_CDN_HOSTNAME=vz-abcdef-123.b-cdn.net
```
skilldb get video-services-skills/Bunny StreamFull skill: 256 lines
Paste into your CLAUDE.md or agent config

Bunny Stream

Core Philosophy

Bunny.net Stream is a cost-effective video hosting and delivery platform built on Bunny.net's global CDN. It handles transcoding, adaptive bitrate streaming, and delivery through 100+ PoPs worldwide. The mental model is simple: you create a Video Library, upload videos to it, and Bunny handles everything else — transcoding into multiple resolutions, generating HLS manifests, creating thumbnails, and delivering via CDN. All interactions go through a REST API authenticated with a library-level API key. Use direct uploads via TUS protocol for client-side ingest, organize content with collections, and rely on webhook callbacks for processing status updates.

Setup

Bunny Stream uses a straightforward REST API. There is no official SDK — use fetch or any HTTP client:

const BUNNY_API_KEY = process.env.BUNNY_API_KEY!;
const BUNNY_LIBRARY_ID = process.env.BUNNY_LIBRARY_ID!;

const bunnyApi = {
  baseUrl: `https://video.bunnycdn.com/library/${BUNNY_LIBRARY_ID}`,

  async request<T>(path: string, options: RequestInit = {}): Promise<T> {
    const res = await fetch(`${this.baseUrl}${path}`, {
      ...options,
      headers: {
        AccessKey: BUNNY_API_KEY,
        "Content-Type": "application/json",
        ...options.headers,
      },
    });
    if (!res.ok) throw new Error(`Bunny API error: ${res.status}`);
    return res.json();
  },
};

Environment variables required:

BUNNY_API_KEY=your-library-api-key
BUNNY_LIBRARY_ID=your-library-id
BUNNY_CDN_HOSTNAME=vz-abcdef-123.b-cdn.net

Key Techniques

Creating and Uploading Videos

interface BunnyVideo {
  guid: string;
  title: string;
  status: number; // 0=created, 1=uploaded, 2=processing, 3=transcoding, 4=finished, 5=error
  thumbnailFileName: string;
}

async function createAndUploadVideo(title: string, fileBuffer: Buffer): Promise<BunnyVideo> {
  // Step 1: Create the video object
  const video = await bunnyApi.request<BunnyVideo>("/videos", {
    method: "POST",
    body: JSON.stringify({ title }),
  });

  // Step 2: Upload the file content
  await fetch(
    `https://video.bunnycdn.com/library/${BUNNY_LIBRARY_ID}/videos/${video.guid}`,
    {
      method: "PUT",
      headers: { AccessKey: BUNNY_API_KEY },
      body: fileBuffer,
    }
  );

  return video;
}

TUS Direct Upload (Client-Side)

import * as tus from "tus-js-client";

// Server: create video and return upload credentials
async function createUploadSession(title: string) {
  const video = await bunnyApi.request<BunnyVideo>("/videos", {
    method: "POST",
    body: JSON.stringify({ title }),
  });

  // Generate a SHA256 signature for TUS auth
  const expiration = Math.floor(Date.now() / 1000) + 3600;
  const signaturePayload = `${BUNNY_LIBRARY_ID}${BUNNY_API_KEY}${expiration}${video.guid}`;
  const signature = await sha256Hex(signaturePayload);

  return {
    videoId: video.guid,
    libraryId: BUNNY_LIBRARY_ID,
    expiration,
    signature,
  };
}

// Client: upload using TUS protocol
function uploadWithTus(
  file: File,
  params: { videoId: string; libraryId: string; expiration: number; signature: string }
) {
  const upload = new tus.Upload(file, {
    endpoint: "https://video.bunnycdn.com/tusupload",
    retryDelays: [0, 3000, 5000, 10000],
    metadata: {
      filetype: file.type,
      title: file.name,
    },
    headers: {
      AuthorizationSignature: params.signature,
      AuthorizationExpire: String(params.expiration),
      VideoId: params.videoId,
      LibraryId: params.libraryId,
    },
    onProgress(bytesUploaded, bytesTotal) {
      console.log(`${((bytesUploaded / bytesTotal) * 100).toFixed(1)}%`);
    },
    onSuccess() {
      console.log("Upload complete");
    },
  });
  upload.start();
}

Playback and Thumbnails

function getPlaybackUrls(videoId: string, cdnHostname: string) {
  return {
    hls: `https://${cdnHostname}/${videoId}/playlist.m3u8`,
    thumbnail: `https://${cdnHostname}/${videoId}/thumbnail.jpg`,
    preview: `https://${cdnHostname}/${videoId}/preview.webp`,
    // Thumbnail at specific time (seconds)
    thumbnailAt30s: `https://${cdnHostname}/${videoId}/thumbnail.jpg?v=30`,
  };
}

// Embed with iframe
function getEmbedUrl(videoId: string, libraryId: string): string {
  return `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`;
}

Token-Authenticated Playback

import crypto from "crypto";

function generateSignedUrl(
  videoId: string,
  cdnHostname: string,
  securityKey: string,
  expiresInSeconds = 3600
): string {
  const expiration = Math.floor(Date.now() / 1000) + expiresInSeconds;
  const path = `/${videoId}/playlist.m3u8`;
  const hashable = securityKey + path + expiration;
  const token = crypto
    .createHash("sha256")
    .update(hashable)
    .digest("hex")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");

  return `https://${cdnHostname}${path}?token=${token}&expires=${expiration}`;
}

Managing Collections

interface BunnyCollection {
  guid: string;
  name: string;
  videoCount: number;
}

async function createCollection(name: string): Promise<BunnyCollection> {
  return bunnyApi.request<BunnyCollection>("/collections", {
    method: "POST",
    body: JSON.stringify({ name }),
  });
}

async function addVideoToCollection(videoId: string, collectionId: string) {
  return bunnyApi.request(`/videos/${videoId}`, {
    method: "POST",
    body: JSON.stringify({ collectionId }),
  });
}

async function listCollectionVideos(collectionId: string): Promise<BunnyVideo[]> {
  const result = await bunnyApi.request<{ items: BunnyVideo[] }>(
    `/videos?collection=${collectionId}&page=1&itemsPerPage=100`
  );
  return result.items;
}

Webhook Handling

interface BunnyWebhookPayload {
  VideoGuid: string;
  VideoLibraryId: number;
  Status: number; // matches BunnyVideo status codes
}

async function handleBunnyWebhook(req: Request): Promise<Response> {
  const body: BunnyWebhookPayload = await req.json();

  switch (body.Status) {
    case 4: // Finished
      await db.video.update({
        where: { bunnyVideoId: body.VideoGuid },
        data: { status: "ready" },
      });
      break;
    case 5: // Error
      await db.video.update({
        where: { bunnyVideoId: body.VideoGuid },
        data: { status: "error" },
      });
      break;
  }

  return new Response("OK", { status: 200 });
}

Best Practices

  • Use TUS protocol for client-side uploads to get resumable, chunked uploads out of the box.
  • Organize videos into collections for logical grouping and easier management.
  • Enable token authentication on your video library for any content that requires access control.
  • Store the video GUID in your database and construct CDN URLs dynamically using your hostname.
  • Use the webhook endpoint to track transcoding completion rather than polling the status API.
  • Set appropriate cache headers and leverage Bunny's CDN for thumbnail delivery.

Anti-Patterns

  • Do not poll the video status endpoint in a loop; configure a webhook URL in your library settings.
  • Do not hardcode CDN hostnames; they can change if you reconfigure your library's CDN settings.
  • Do not upload large files through your server; use TUS direct uploads from the client.
  • Do not skip token authentication for paid or private content; unsigned URLs are publicly accessible.
  • Do not forget to handle status code 5 (error) in webhooks; transcoding can fail for corrupt or unsupported inputs.

Install this skill directly: skilldb add video-services-skills

Get CLI access →