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

> Upload video from the browser straight to Bunny Stream over TUS, with a Rails backend that creates each video and signs the upload.

Rails never sees the video file. It creates the video in Bunny Stream and signs an upload, and the browser sends the file to us over [TUS](/docs/stream/tus-resumable-uploads). With import maps, none of it needs a JavaScript build.

<Card title="Rails example on GitHub" icon="https://mintcdn.com/bunnynet-cb9733c2/08vlxCwhRdpoHfuV/logo/frameworks/rubyonrails.svg?fit=max&auto=format&n=08vlxCwhRdpoHfuV&q=85&s=8680a61be3c3fe55dc70a55bc85de2ef" href="https://github.com/BunnyWay/examples/tree/main/stream/upload-tus-rails" horizontal width="24" height="24" data-path="logo/frameworks/rubyonrails.svg">
  A Rails 8 app using import maps.
</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. The example loads them with `dotenv-rails` in development.
  </Step>

  <Step title="Create the Bunny Stream module">
    `sign_upload` hashes the library ID, API key, expiry, and video ID with SHA-256. The key never leaves this module.

    ```ruby app/models/bunny_stream.rb theme={null}
    require "net/http"

    module BunnyStream
      extend self

      class Error < StandardError; end

      # The status of a video still waiting for its file.
      CREATED = 0

      # Bunny checks the expiry on every TUS request, so leave room for slow uploads.
      SIGNATURE_TTL = 24.hours

      def video(video_id)
        video = stream(Net::HTTP::Get, "/#{ERB::Util.url_encode(video_id)}")

        {
          status: video["status"],
          encodeProgress: video["encodeProgress"],
          embedUrl: "https://player.mediadelivery.net/embed/#{library_id}/#{video_id}"
        }
      end

      def create_video(title)
        stream(Net::HTTP::Post, "", { title: }).fetch("guid")
      end

      def sign_upload(video_id)
        expiration_time = SIGNATURE_TTL.from_now.to_i
        signature = Digest::SHA256.hexdigest("#{library_id}#{api_key}#{expiration_time}#{video_id}")

        { videoId: video_id, libraryId: library_id, expirationTime: expiration_time, signature: }
      end

      private

      def library_id = ENV.fetch("BUNNY_STREAM_LIBRARY_ID")
      def api_key = ENV.fetch("BUNNY_STREAM_API_KEY")

      def stream(verb, path, body = nil)
        uri = URI("https://video.bunnycdn.com/library/#{library_id}/videos#{path}")
        request = verb.new(uri, "AccessKey" => api_key, "Accept" => "application/json", "Content-Type" => "application/json")
        request.body = body.to_json if body

        response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
        raise Error, "Bunny Stream returned #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)

        JSON.parse(response.body)
      end
    end
    ```

    One `rescue_from` turns its errors into JSON for every controller.

    ```ruby app/controllers/application_controller.rb theme={null}
    class ApplicationController < ActionController::Base
      rescue_from BunnyStream::Error do |error|
        render json: { error: error.message }, status: :bad_gateway
      end
    end
    ```
  </Step>

  <Step title="Add the API routes">
    ```ruby config/routes.rb theme={null}
    namespace :api do
      resources :uploads, only: :create
      resources :videos, only: :show
    end
    ```

    `resumable?` checks that we're still waiting on the file before re-signing an existing video.

    ```ruby app/controllers/api/uploads_controller.rb theme={null}
    class Api::UploadsController < ApplicationController
      def create
        title = params.require(:title)
        video_id = params[:videoId]
        video_id = BunnyStream.create_video(title) unless resumable?(video_id)

        render json: BunnyStream.sign_upload(video_id)
      end

      private

      def resumable?(video_id)
        video_id.present? && BunnyStream.video(video_id)[:status] == BunnyStream::CREATED
      rescue BunnyStream::Error
        false
      end
    end
    ```

    ```ruby app/controllers/api/videos_controller.rb theme={null}
    class Api::VideosController < ApplicationController
      def show
        render json: BunnyStream.video(params[:id])
      end
    end
    ```
  </Step>

  <Step title="Upload from the browser">
    jsDelivr's `+esm` build bundles tus-js-client into one file, which an import map can pin.

    ```ruby config/importmap.rb theme={null}
    pin "tus-js-client", to: "https://cdn.jsdelivr.net/npm/tus-js-client@4.3.1/+esm"
    ```

    The `X-CSRF-Token` header carries the token from `csrf_meta_tags`. Rails rejects the `POST` without it.

    ```js app/javascript/components/video_uploader.js theme={null}
    import * as tus from "tus-js-client";

    async function requestUpload(title, videoId) {
      const response = await fetch("/api/uploads", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content,
        },
        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

Add a `before_action` that requires a signed-in user and records who owns each video ID. In production, set the two variables on the host.

## Troubleshooting

<AccordionGroup>
  <Accordion title="/api/uploads returns 422 with InvalidAuthenticityToken">
    The layout needs `<%= csrf_meta_tags %>`, and the `fetch` needs the `X-CSRF-Token` header.
  </Accordion>
</AccordionGroup>


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