Features Full Page Screenshot Wait for Selector & Delay Block Cookie Banners Custom Viewport & Device Website to PDF HTML to Image Dark Mode Image Format & Quality MCP Server Pricing Docs Blog Log In Sign Up

Ruby Screenshot API — Capture Websites with One HTTP Call

Vitalii Holben Vitalii Holben

Ruby's screenshot tooling has had a rough decade. PhantomJS died, the webshot gem went down with it, and capybara-screenshot still trips over its own headless Chrome detection. The two survivors are Ferrum and Selenium, both of which need a Chrome binary on your server. Fine on your laptop. On Heroku, you're suddenly juggling deprecated buildpacks and crossing your fingers that ChromeDriver stays in sync with whatever Chrome version shipped this week.

ScreenshotRun's Ruby screenshot API sidesteps all of that. One GET request through net/http, Faraday, or HTTParty, and the response comes back as PNG, JPEG, WebP, AVIF, or PDF. No Chrome process burning memory on your Dyno. No buildpack configuration. No zombie process cleanup.

Your first Ruby screenshot API call in 30 seconds

Create an account at the dashboard to grab an API key (free plan, 200 captures per month, no credit card). Then run this in IRB or a script:

require 'net/http'
require 'uri'

api_key = 'YOUR_API_KEY'
target  = URI.encode_www_form_component('https://github.com')

uri = URI("https://api.screenshotrun.com/v1/screenshots/capture?url=#{target}&response_type=image")

request = Net::HTTP::Get.new(uri)
request['Authorization'] = "Bearer #{api_key}"

response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(request)
end

File.binwrite('github.png', response.body)
puts "Saved: github.png"

That's it. No gem install, no Chrome binary, no Gemfile changes. Ruby's standard library handles the entire thing. Our servers launch Chrome, render the page fully, and send you the finished image as raw bytes.

Production-ready version with error handling

A real deployment needs timeouts, status code checks, and the API key pulled from environment variables instead of hardcoded into your source:

require 'net/http'
require 'uri'
require 'json'

class ScreenshotClient
  API_BASE = 'https://api.screenshotrun.com/v1/screenshots/capture'

  def initialize(api_key = ENV['SCREENSHOTRUN_KEY'])
    @api_key = api_key
  end

  def capture(target_url, **options)
    params = {
      url:           target_url,
      format:        options.fetch(:format, 'png'),
      width:         options.fetch(:width, 1280),
      height:        options.fetch(:height, 720),
      full_page:     options.fetch(:full_page, false),
      response_type: 'image'
    }.merge(options)

    query = URI.encode_www_form(params)
    uri   = URI("#{API_BASE}?#{query}")

    request = Net::HTTP::Get.new(uri)
    request['Authorization'] = "Bearer #{@api_key}"

    response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 60) do |http|
      http.request(request)
    end

    unless response.is_a?(Net::HTTPSuccess)
      raise "Screenshot API returned #{response.code}: #{response.body}"
    end

    response.body
  end
end

client = ScreenshotClient.new
image  = client.capture('https://github.com', format: 'webp', width: 1920, full_page: true)

File.binwrite('github-full.webp', image)
puts "Captured full-page WebP screenshot"

Store the API key as an environment variable (SCREENSHOTRUN_KEY) so credentials stay out of version control. Rotating keys later takes one ENV change, zero code edits.

Why use Faraday instead of net/http?

Honestly, most Rails apps already have Faraday in their Gemfile for other API integrations. Adding a second HTTP library just for screenshots feels wasteful. Here's the same capture logic using Faraday's connection object:

require 'faraday'

class ScreenshotService
  API_BASE = 'https://api.screenshotrun.com/v1/screenshots/capture'

  def initialize(api_key = ENV['SCREENSHOTRUN_KEY'])
    @conn = Faraday.new do |f|
      f.request :url_encoded
      f.adapter Faraday.default_adapter
    end
    @api_key = api_key
  end

  def capture(target_url, **options)
    response = @conn.get(API_BASE) do |req|
      req.headers['Authorization'] = "Bearer #{@api_key}"
      req.options.timeout = 60
      req.params = {
        url:           target_url,
        format:        options.fetch(:format, 'png'),
        width:         options.fetch(:width, 1280),
        height:        options.fetch(:height, 720),
        full_page:     options.fetch(:full_page, false),
        response_type: 'image'
      }.merge(options)
    end

    raise "API error #{response.status}: #{response.body}" unless response.success?

    response.body
  end
end

service = ScreenshotService.new
image   = service.capture('https://stripe.com', format: 'jpeg', width: 1440)
File.binwrite('stripe.jpeg', image)

The real win is Faraday's middleware stack. Need request logging? Add f.response :logger. Want automatic retries? Drop in faraday-retry. The capture code stays untouched while you layer on instrumentation and resilience around it.

Available parameters

All parameters are query string arguments on the GET request. The table below lists what Ruby developers reach for most often:

ParameterTypeDefaultDescription
urlstringThe webpage to capture
formatstringpngOutput: png, jpeg, webp, avif, pdf
widthinteger1280Viewport width in pixels
heightinteger720Viewport height in pixels
full_pagebooleanfalseCapture the entire scrollable page
devicestringdesktopDevice type: desktop, mobile, tablet
retinabooleanfalseRender at 2x pixel density
dark_modebooleanfalseForce dark color scheme
selectorstringCSS selector to capture a specific element
delayinteger0Wait N milliseconds before capturing
block_cookiesbooleanfalseRemove cookie consent banners
block_adsbooleanfalseRemove advertisements
block_chatsbooleanfalseRemove live chat widgets
cssstringInject custom CSS before capture
jsstringExecute JavaScript before capture

Check the API documentation for the complete parameter list. What's in the table handles the vast majority of real-world Ruby integrations.

Capture in dark mode, target elements, inject CSS

Three features that show up in almost every production integration. They all work through query parameters on the same GET request, so there's nothing extra to install or configure.

Dark mode is a single parameter. Add dark_mode: true and the page loads with prefers-color-scheme: dark applied. Any site honoring that media query will show its dark theme:

image = client.capture('https://github.com', dark_mode: true)

Targeting a specific element works the same way. Pass a CSS selector and the API crops the output to that element's bounding box instead of capturing the full viewport:

image = client.capture('https://github.com/anthropics', selector: '.js-repo-list')

CSS injection lets you hide banners, swap fonts, or tweak styles before the capture happens. I've used this on a client project to strip promotional overlays from competitor pages during monitoring runs:

image = client.capture('https://example.com',
  css: '.promo-banner { display: none !important; } body { font-family: Inter, sans-serif; }'
)

All three combine freely in one request. Dark mode, cropped to a single element, with custom styles applied. One call.

Add retry logic before going to production

Networks drop packets. Target sites go down. Rate limits kick in when you're batching hundreds of URLs. A production Ruby service needs retries that back off instead of hammering the endpoint until it gives up:

def capture_with_retry(target_url, max_retries: 3, **options)
  attempts = 0

  begin
    capture(target_url, **options)
  rescue => e
    attempts += 1

    if e.message.include?('429')
      wait = 5
      sleep(wait)
      retry if attempts <= max_retries
    end

    if e.message.match?(/5\d{2}/) && attempts <= max_retries
      sleep(attempts * 2)
      retry
    end

    raise
  end
end

Rate-limit responses (429) get a flat 5-second pause. Server errors use progressive waits that grow with each attempt. Client errors like bad parameters or expired keys fail right away because retrying those changes nothing.

Rails controller for proxying screenshots

The setup I've seen most often in Rails projects: your frontend needs screenshots, and you proxy requests through your app to keep the API key server-side. Nothing fancy, just a controller that forwards the call and streams the image back:

class ScreenshotsController < ApplicationController
  def capture
    target_url = params.require(:url)
    fmt        = params.fetch(:format, 'png')
    width      = params.fetch(:width, 1280).to_i

    query = URI.encode_www_form(
      url:           target_url,
      format:        fmt,
      width:         width,
      response_type: 'image'
    )

    uri = URI("https://api.screenshotrun.com/v1/screenshots/capture?#{query}")
    request = Net::HTTP::Get.new(uri)
    request['Authorization'] = "Bearer #{Rails.application.credentials.screenshotrun_api_key}"

    response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 60) do |http|
      http.request(request)
    end

    unless response.is_a?(Net::HTTPSuccess)
      return head response.code.to_i
    end

    content_type = fmt == 'jpeg' ? 'image/jpeg' : 'image/png'
    expires_in 1.hour, public: true
    send_data response.body,
              type:        content_type,
              disposition: 'inline',
              filename:    "screenshot.#{fmt}"
  end
end

Route it in config/routes.rb:

get '/api/screenshot', to: 'screenshots#capture'

Your frontend sends a URL, the controller hits ScreenshotRun with credentials from Rails.application.credentials, and pipes the image back. The API key never touches the browser. The expires_in call tells browsers and CDNs to cache the result for an hour, which saves you from redundant captures when users reload the page.

Do you actually need to self-host Chrome?

Ferrum and Selenium are solid tools. If you're already running Capybara tests with headless Chrome and screenshots are a small side feature, keep what you have. But when the primary task is converting URLs to images across hundreds or thousands of pages, the operational weight adds up fast.

Self-hosted (Ferrum/Selenium)ScreenshotRun API
SetupChrome + ChromeDriver + Heroku buildpacksOne HTTP GET request
Gem dependenciesferrum or selenium-webdriver + webdriversNone (stdlib net/http)
Memory per captureChrome eats a few hundred MB per instanceZero browser memory on your server
Heroku deployment2-3 buildpacks, version sync issues, occasional hangsOne ENV var for the API key
Docker image+400 MB for Chrome, --disable-dev-shm-usage, font packagesStandard Ruby image
Full-page captureWindow resize hack (Selenium) or full: true (Ferrum)One parameter: full_page=true
Cookie/ad blockingManual JS injection or browser extension setupOne parameter: block_cookies=true
CostServer RAM + engineering hours + buildpack maintenanceFree tier (200/month), then usage-based

Ran into this trade-off on a project last year. The team had Ferrum running in Docker and spent more time debugging Chrome crashes than building actual features. Switched to the API, deleted the Chrome layer from the Dockerfile, and the deploy pipeline got noticeably simpler. Not saying self-hosting is always wrong, but for screenshot-focused workloads the API removes a category of problems you'd rather not deal with.

Start capturing screenshots from Ruby

Grab your API key, paste it into any example above, and you're done. 200 free captures every month, no credit card needed.

Get Your Free API Key