Skip to content

DocsIntegrations

Ruby on Rails

A Ruby on Rails example that creates jobs from JSON, uploads from the browser, verifies webhooks and redirects downloads.

What you build#

An app that turns an uploaded file into an MP4, or into an MP4, a poster and a GIF preview in one job. The browser uploads straight to Videxo with a one-time URL. The server keeps the API key, creates and reads the job, verifies the webhook and redirects downloads to fresh signed links. Every call to Videxo sits in app/services/videxo.rb, with plain HTTP.

These files are additions to a fresh Rails 8 app, not a whole application skeleton.

The whole project: videxo-rails.zip, or browse it in the examples repository (opens in a new tab).

Before you start#

  • An API key from the dashboard's API keys page, in VIDEXO_API_KEY.
  • The signing secret from the dashboard's Webhooks page, in VIDEXO_WEBHOOK_SECRET.
  • Ruby 3.3 or newer, Bundler and a fresh Rails 8 app.

Both secrets stay in the server environment. These demo routes share one API key; add your app's authentication and per-user checks for uploads and jobs before exposing them to users.

Install#

With Rails 8 installed, run rails new ../videxo-rails. Inside it, run bundle add standardwebhooks --version '~> 1.0' (the Gemfile entry is gem 'standardwebhooks', '~> 1.0'). Copy app/, requests/ and public/index.html from the example into the app. Copy config/initializers/videxo_webhook_body_limit.rb into the app's initializers. Merge config/routes.rb into the app's routes block, replacing an existing root route. The signing library is standardwebhooks (opens in a new tab).

Create a job#

The JSON bodies are read from requests/transcode.json and requests/jobs.json at runtime. transcode makes one MP4; jobs makes an MP4, a poster and a GIF preview together. Only a completed upload's videxo://uploads/<id> is accepted. The key is transcode-<upload id> or jobs-<upload id>, so retrying the same upload and kind within 24 hours returns the same job:

examples/rails/app/services/videxo.rb · plain HTTP
def create_job(kind, input)
  upload = input.is_a?(String) && /\Avidexo:\/\/uploads\/(up_[a-f0-9]{24})\z/.match(input)
  unless upload
    raise Problem.new({
      "type" => "about:blank", "title" => "The request is not valid.", "status" => 400,
      "detail" => "input_url must be the input_url of an upload.", "code" => "invalid_request"
    })
  end
  kind = kind == "jobs" ? "jobs" : "transcode"
  body = JSON.parse(File.read(Rails.root.join("requests", "#{kind}.json")))
  if kind == "jobs"
    body["inputs"] = { "talk" => input }
  else
    body["input"] = input
  end
  url = ENV["VIDEXO_WEBHOOK_URL"]
  body["webhook_url"] = url unless url.nil? || url.empty?
  request("/v1/#{kind}", method: "POST", body: body, key: "#{kind}-#{upload[1]}")
end

The route returns the created Job with 202, so the page can follow it:

examples/rails/app/controllers/videxo_controller.rb · plain HTTP
def create
  kind = params[:kind] == "jobs" ? "jobs" : "transcode"
  input = params[:input_url].is_a?(String) ? params[:input_url] : ""
  # perform_now returns the created Job; perform_later is for your own background work.
  job = CreateVidexoJob.perform_now(kind, input)
  render json: job, status: :accepted
end

The page uses perform_now, which returns the Videxo Job. For your own background workflow use CreateVidexoJob.perform_later('transcode', input_url) (or jobs) and run your queue backend (bin/jobs start for Solid Queue). Save the returned Videxo id on your order in perform. perform_later returns an Active Job instead of the Videxo Job.

examples/rails/app/jobs/create_videxo_job.rb · plain HTTP
class CreateVidexoJob < ApplicationJob
  def perform(kind, input)
    # Reusing the upload and kind derives the same Idempotency-Key on a retry.
    job = Videxo.new.create_job(kind, input)
    # Your code: store this id on the order so your webhook can update it later.
    Rails.logger.info("Videxo job created: #{job.fetch('id')}")
    job
  end
end

Every API call uses one helper. API errors become an exception carrying the original problem details:

examples/rails/app/services/videxo.rb · plain HTTP
def request(path, method: "GET", body: nil, key: nil)
  api_key = ENV.fetch("VIDEXO_API_KEY")
  raise "Set VIDEXO_API_KEY on the server." if api_key.empty?
  base = ENV["VIDEXO_API_URL"].to_s
  base = "https://api.videxo.io" if base.empty?
  uri = URI("#{base.sub(%r{/$}, '')}#{path}")
  call = method == "POST" ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
  call["Authorization"] = "Bearer #{api_key}"
  call["Accept"] = "application/json"
  call["Idempotency-Key"] = key if key
  if body
    call["Content-Type"] = "application/json"
    call.body = JSON.generate(body)
  end
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 30) do |http|
    http.request(call)
  end
  status = response.code.to_i
  if status >= 400
    problem = begin
      JSON.parse(response.body)
    rescue JSON::ParserError
      nil
    end
    unless problem.is_a?(Hash) && problem["code"].is_a?(String)
      problem = { "type" => "about:blank", "title" => "The API answered with an error.",
                  "status" => status, "detail" => "HTTP #{status}", "code" => "http_error" }
    end
    raise Problem.new(problem)
  end
  # Net::HTTP does not follow redirects. Give the fresh Location to the browser.
  if status >= 300
    return response["Location"] || raise("The API returned no download link.")
  end
  JSON.parse(response.body)
end

The error handler keeps the status and application/problem+json, including field errors, so the shared page can show them:

examples/rails/app/controllers/videxo_controller.rb · plain HTTP
def respond_with_problem
  yield
rescue Videxo::Problem => error
  render json: error.problem, status: error.problem.fetch("status"), content_type: "application/problem+json"
rescue StandardError => error
  Rails.logger.error("Videxo controller error: #{error.class}")
  render json: { title: "Something went wrong.", status: 500 }, status: 500, content_type: "application/problem+json"
end

Receive the webhook#

Videxo sends a signed POST when the job ends (Webhooks). Verify the raw body exactly as it arrived. The library checks the signature and rejects timestamps more than five minutes away:

examples/rails/app/services/videxo.rb · plain HTTP
def verify_webhook(raw, headers)
  secret = ENV.fetch("VIDEXO_WEBHOOK_SECRET")
  raise "Set VIDEXO_WEBHOOK_SECRET on the server." if secret.empty?
  # Gem 1.1.0 can raise NoMethodError for a signature entry without a comma.
  # Accept the space-separated signatures used during secret rotation too.
  signatures = headers["webhook-signature"]
  unless signatures.is_a?(String) && signatures.ascii_only? && %r{\Av[0-9]+,[A-Za-z0-9+/]+={0,2}(?: v[0-9]+,[A-Za-z0-9+/]+={0,2})*\z}.match?(signatures)
    raise StandardWebhooks::WebhookVerificationError, "Invalid signature header."
  end
  StandardWebhooks::Webhook.new(secret).verify(raw, headers)
end

Rails can parse request parameters for logging before the action, so the initializer bounds the input in a first Rack middleware:

examples/rails/config/initializers/videxo_webhook_body_limit.rb · plain HTTP
# Run before Rails' logging and parameter parsing, which can otherwise read the whole body.
class VidexoWebhookBodyLimit
  MAX = 2_097_152

  def initialize(app)
    @app = app
  end

  def call(env)
    path = Rack::Utils.unescape_path(env["PATH_INFO"].to_s)
    return @app.call(env) unless env["REQUEST_METHOD"] == "POST" && path.start_with?("/webhooks/videxo")
    return [413, { "content-length" => "0" }, []] if env["CONTENT_LENGTH"].to_i > MAX

    raw = env.fetch("rack.input").read(MAX + 1).to_s
    return [413, { "content-length" => "0" }, []] if raw.bytesize > MAX

    # Rails may parse/log the accepted body before the controller runs. It only sees bounded bytes.
    env["rack.input"] = StringIO.new(raw)
    env["RAW_POST_DATA"] = raw
    @app.call(env)
  end
end

Rails.application.config.middleware.insert_before(0, VidexoWebhookBodyLimit)
examples/rails/app/controllers/webhooks_controller.rb · plain HTTP
def videxo
  max = 2_097_152
  # content_length itself reads raw_post on chunked requests; use it only with the header.
  if request.headers["Content-Length"] && request.content_length > max
    return head :payload_too_large
  end
  raw = request.body.read(max + 1).to_s
  return head :payload_too_large if raw.bytesize > max
  headers = {
    "webhook-id" => request.headers["webhook-id"],
    "webhook-timestamp" => request.headers["webhook-timestamp"],
    "webhook-signature" => request.headers["webhook-signature"]
  }
  event = Videxo.new.verify_webhook(raw, headers)
  # A locked file for this single-server demo, independent of Rails' development NullStore.
  # In your app use a unique webhook-id column in the business update's transaction.
  File.open(Rails.root.join("tmp", "videxo-webhooks.json"), File::RDWR | File::CREAT, 0600) do |file|
    raise "Could not lock webhook storage." unless file.flock(File::LOCK_EX)
    saved = file.read
    handled = saved.empty? ? {} : JSON.parse(saved)
    id = headers.fetch("webhook-id")
    unless handled[id]
      # Your code: mark the order done or enqueue slow work. Log only ids, never signed URLs.
      Rails.logger.info("#{event.fetch('type')} #{event.fetch('data').fetch('id')}")
      handled[id] = true
      file.rewind
      file.write(JSON.generate(handled))
      file.truncate(file.pos)
      file.flush
    end
  end
  head :no_content
rescue StandardWebhooks::WebhookVerificationError, JSON::ParserError
  head :bad_request
end

The Ruby gem 1.1.0 can throw for a malformed signature header, so the helper checks the signature-list format first and responds with 400, including for v1 or garbage.

A delivery can arrive more than once with the same webhook-id. The single-server demo remembers handled ids under a lock and responds with 204. In your app, use a unique column in the same transaction as the business update. Keep slow work in your own queue so the response arrives within seconds.

The handler reads the raw request stream and skips CSRF for the webhook. The two browser POST actions also skip it for the shared static demo page, which has no session token. In a signed-in app use authentication and CSRF on those actions. The locked file lives in tmp/, independent of Rails' development cache.

The route rejects bodies over 2 MiB (2,097,152 bytes) with 413, checking Content-Length first and otherwise reading at most 2 MiB plus one byte before verification. Keep any reverse proxy limit at least 2 MiB.

Upload a file#

The server creates a one-time upload URL and gives the browser only that URL and the input URL:

examples/rails/app/services/videxo.rb · plain HTTP
def create_upload(file)
  request("/v1/uploads", method: "POST", body: file)
end
examples/rails/app/controllers/videxo_controller.rb · plain HTTP
def upload
  file = { filename: params[:filename], size_bytes: params[:size_bytes], content_type: params[:content_type] }
  upload = Videxo.new.create_upload(file)
  render json: upload.slice("input_url", "upload_url"), status: :created
end

The browser then sends the file with PUT to upload_url, with no API key, and creates the job with input_url. See Files for limits and expiry.

Download the outputs#

For each click, ask GET /v1/jobs/{id}/outputs/{alias} for a fresh link without following the redirect in the HTTP client. Then redirect the browser to its Location:

examples/rails/app/services/videxo.rb · plain HTTP
def output_url(id, alias_name)
  request("/v1/jobs/#{segment(id)}/outputs/#{segment(alias_name)}")
end
examples/rails/app/controllers/videxo_controller.rb · plain HTTP
def download
  redirect_to Videxo.new.output_url(params[:id], params[:alias]), allow_other_host: true, status: :found
end

Net::HTTP does not follow redirects. Rails uses allow_other_host: true because the signed link points to Videxo, outside your app's host.

A job's outputs[].download_url is also a signed link. It expires at download_url_expires_at, within an hour.

Run it#

Copy .env.example to .env, fill in the secrets and export its shell assignments with set -a; . ./.env; set +a. Rails does not load this file by itself. Run bin/rails db:prepare, set PORT to a free local port, and run bin/rails server --binding=127.0.0.1 --port="$PORT". Open http://127.0.0.1:$PORT. No frontend build or queue worker is needed for the demo page; the README gives the exact copy and merge steps.

Open the page, pick a file and choose the job. The page follows the job every 2 seconds and lists its outputs as download links. VIDEXO_API_URL defaults to https://api.videxo.io; set it to your local API's origin when using the local stack.

Webhooks need a public https URL. Tunnel this app's port, set VIDEXO_WEBHOOK_URL to the tunnel address plus /webhooks/videxo, and restart the app. Without it the page still follows the job by polling. In Rails development, add the tunnel's exact hostname to config.hosts in config/environments/development.rb and restart Rails so Host Authorization permits it. Keep the existing allowed hosts. Or set your account's default endpoint on the Webhooks page.

Next steps#

  • Operations and Presets: more JSON job options.
  • Webhooks: events, retries and delivery logs.
  • Errors: problem codes and field errors.
  • app/services/videxo.rb is the one file to change when replacing the HTTP client.