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:
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]}")
endCopy failed. The text is selected; copy it with your keyboard.
The route returns the created Job with 202, so the page can follow it:
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
endCopy failed. The text is selected; copy it with your keyboard.
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.
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
endCopy failed. The text is selected; copy it with your keyboard.
Every API call uses one helper. API errors become an exception carrying the original problem details:
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)
endCopy failed. The text is selected; copy it with your keyboard.
The error handler keeps the status and application/problem+json, including field errors, so the
shared page can show them:
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"
endCopy failed. The text is selected; copy it with your keyboard.
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:
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)
endCopy failed. The text is selected; copy it with your keyboard.
Rails can parse request parameters for logging before the action, so the initializer bounds the input in a first Rack middleware:
# 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)Copy failed. The text is selected; copy it with your keyboard.
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
endCopy failed. The text is selected; copy it with your keyboard.
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:
def create_upload(file)
request("/v1/uploads", method: "POST", body: file)
endCopy failed. The text is selected; copy it with your keyboard.
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
endCopy failed. The text is selected; copy it with your keyboard.
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:
def output_url(id, alias_name)
request("/v1/jobs/#{segment(id)}/outputs/#{segment(alias_name)}")
endCopy failed. The text is selected; copy it with your keyboard.
def download
redirect_to Videxo.new.output_url(params[:id], params[:alias]), allow_other_host: true, status: :found
endCopy failed. The text is selected; copy it with your keyboard.
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.rbis the one file to change when replacing the HTTP client.