Ruby Screenshot API — Capture Websites with One HTTP Call
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:
| Parameter | Type | Default | Description |
|---|---|---|---|
url | string | — | The webpage to capture |
format | string | png | Output: png, jpeg, webp, avif, pdf |
width | integer | 1280 | Viewport width in pixels |
height | integer | 720 | Viewport height in pixels |
full_page | boolean | false | Capture the entire scrollable page |
device | string | desktop | Device type: desktop, mobile, tablet |
retina | boolean | false | Render at 2x pixel density |
dark_mode | boolean | false | Force dark color scheme |
selector | string | — | CSS selector to capture a specific element |
delay | integer | 0 | Wait N milliseconds before capturing |
block_cookies | boolean | false | Remove cookie consent banners |
block_ads | boolean | false | Remove advertisements |
block_chats | boolean | false | Remove live chat widgets |
css | string | — | Inject custom CSS before capture |
js | string | — | Execute 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 | |
|---|---|---|
| Setup | Chrome + ChromeDriver + Heroku buildpacks | One HTTP GET request |
| Gem dependencies | ferrum or selenium-webdriver + webdrivers | None (stdlib net/http) |
| Memory per capture | Chrome eats a few hundred MB per instance | Zero browser memory on your server |
| Heroku deployment | 2-3 buildpacks, version sync issues, occasional hangs | One ENV var for the API key |
| Docker image | +400 MB for Chrome, --disable-dev-shm-usage, font packages | Standard Ruby image |
| Full-page capture | Window resize hack (Selenium) or full: true (Ferrum) | One parameter: full_page=true |
| Cookie/ad blocking | Manual JS injection or browser extension setup | One parameter: block_cookies=true |
| Cost | Server RAM + engineering hours + buildpack maintenance | Free 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
Vitalii Holben