Skip to main content

Confirm

Are you sure?

Ruby · Code recipe

Take a screenshot of a website in Ruby

Save a full-page PNG of any website, in Ruby 3.1+ with Net::HTTP from the standard library. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.

By · Last updated: September 2026

TL;DR

To take a screenshot of a website in Ruby, POST its URL to https://urlpipe.dev/screenshot with sync set to true, decode the Base64 body with String#unpack1("m") and write the bytes to a .png file. It is a full-page capture from real Chrome for 1 credit; leave sync out and the result goes to your webhook instead.

Free plan, no credit card. 1,000 credits a month.

One POST to /screenshot loads the page in real Chrome, scrolls it top to bottom so lazy images load, and captures the whole document — not just the fold. The image comes back Base64-encoded in a text/plain body, so the Ruby work is two lines: decode it with String#unpack1("m") and write the bytes.

The request below also sets page_options.block_cookie_banners, because a consent dialog over the page is the most common reason a screenshot is useless. Every other knob — viewport, device scale, JPEG or WebP, one element by selector, dark mode — goes in screenshot_options.

Setup

Before you start

Nothing to install: net/http and json are part of Ruby. Put your API key in the environment so it never lands in the file.

Terminal
ruby --version   # 3.1 or later
export URLPIPE_API_KEY="your_api_key"

The request

Save a screenshot as a PNG file

sync: true keeps the request open until the result is ready. use_ssl: has to be asked for — Net::HTTP does not infer it from the scheme — and abort prints to stderr and exits 1. The body is Base64 text; unpack1("m") decodes it without requiring the base64 gem, which is a bundled gem rather than a default one from Ruby 3.4.

screenshot.rb
require "json"
require "net/http"

uri = URI("https://urlpipe.dev/screenshot")
# read_timeout: a sync call can take up to 60 s, Net::HTTP's default.
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 90) do |http|
  http.post(uri.path, { url: "https://example.com", page_options: { block_cookie_banners: true }, sync: true }.to_json,
            "Authorization" => "Bearer #{ENV.fetch("URLPIPE_API_KEY")}",
            "Content-Type" => "application/json")
end
abort "URLpipe answered #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPOK)

png = res.body.unpack1("m") # Base64 decode, core Ruby
File.binwrite("screenshot.png", png)
puts "Saved screenshot.png (#{png.bytesize} bytes)"

Run it: ruby screenshot.rb

Async

The async variant: a token, a webhook and a poll

Leave out sync and the answer is a token, straight away. Net::HTTP reports the status as a string, so the poll compares res.code with "202", not the number.

screenshot_async.rb
require "json"
require "net/http"

API = URI("https://urlpipe.dev")

def send_request(request)
  request["Authorization"] = "Bearer #{ENV.fetch("URLPIPE_API_KEY")}"
  Net::HTTP.start(API.host, API.port, use_ssl: API.scheme == "https") { |http| http.request(request) }
end

# No sync: the request is accepted at once and the work carries on without you.
post = Net::HTTP::Post.new(URI.join(API, "/screenshot"), "Content-Type" => "application/json")
post.body = {
  url: "https://example.com",
  page_options: { block_cookie_banners: true },
  report_to: "https://your-app.com/webhooks/urlpipe",
  labels: { customer: "acme" }
}.to_json
res = send_request(post)
abort "URLpipe answered #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPOK)
token = JSON.parse(res.body).fetch("token")
puts "Accepted #{token}"

# The result is POSTed to report_to when it is ready. Polling by token is the
# other way to collect it: no endpoint needed, and a backup for the webhook.
60.times do
  res = send_request(Net::HTTP::Get.new(URI.join(API, "/result/#{token}")))
  break unless res.code == "202" # 202 means still processing

  sleep 2
end

case res.code
when "200" then nil
when "202" then abort "Still processing after two minutes; try the token again later."
when "422" then abort "The analysis failed: #{JSON.parse(res.body)["error"]}"
when "410" then abort "The result is past the 30-day window; send the request again."
else abort "URLpipe answered #{res.code}: #{res.body}"
end

png = res.body.unpack1("m") # Base64 decode, core Ruby
File.binwrite("screenshot.png", png)
puts "Saved screenshot.png (#{png.bytesize} bytes)"

Run it: ruby screenshot_async.rb

Errors

Handle errors and retries

Pattern matching on [status, code] keeps the retry rules in one place. A 401 answers in plain text, so it is checked before JSON.parse, and a body that still is not JSON becomes an empty hash.

screenshot_errors.rb
require "json"
require "net/http"

class URLpipeError < StandardError; end

# POST a sync request and return the response, or raise URLpipeError.
def urlpipe(path, payload, attempts: 5)
  uri = URI("https://urlpipe.dev#{path}")
  attempts.times do |attempt|
    res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 90) do |http|
      http.post(uri.path, payload.merge(sync: true).to_json,
                "Authorization" => "Bearer #{ENV.fetch("URLPIPE_API_KEY")}",
                "Content-Type" => "application/json")
    end
    return res if res.code == "200"
    raise URLpipeError, "401: the API key is missing or wrong. Check URLPIPE_API_KEY." if res.code == "401"

    body = begin
      JSON.parse(res.body)
    rescue JSON::ParserError
      {}
    end
    code = body["error"].to_s
    detail = body["message"] ? "#{code}: #{body["message"]}" : code

    case [ res.code, code ]
    in [ "429", "rate_limited" ]
      # Sending too fast: Retry-After says how long the window has left.
      sleep Integer(res["Retry-After"] || 1)
    in [ "429", "concurrency_limit" ]
      # Every parallel slot on your plan is busy with your own requests.
      sleep 2**attempt
    in [ "504", _ ]
      # Still running on our side; the token collects it from GET /result/:token.
      raise URLpipeError, "504 processing_timeout: collect it later with token #{body["token"]}"
    else
      # 403 email_unverified, 422 (a bad parameter, or a page that would not load),
      # 429 quota_exceeded: sending the same request again gets the same answer.
      raise URLpipeError, "#{res.code}: #{detail}"
    end
  end
  raise URLpipeError, "429: still refused after #{attempts} attempts"
end

begin
  res = urlpipe("/screenshot", { url: "https://example.com", page_options: { block_cookie_banners: true } })
rescue URLpipeError => e
  abort "URLpipe: #{e.message}"
end

png = res.body.unpack1("m") # Base64 decode, core Ruby
File.binwrite("screenshot.png", png)
puts "Saved screenshot.png (#{png.bytesize} bytes)"

Run it: ruby screenshot_errors.rb

Details

What to know about /screenshot

  • The default viewport is 1350 × 797 and the capture follows the page down to 16,384 px; a taller page is cut there.
  • Every response carries an X-Result-Url header: a link to the same image that needs no API key and stays valid for 30 days. Often you can store that link instead of the file.
  • PNG is the default; "format": "jpeg" or "webp" in screenshot_options makes a long page far smaller.
  • It captures images, not PDFs, and it does not record video.
  • A screenshot of the same URL with the same options is served from the cache for 7 days by default, free. Set max_age to refresh sooner.

Other languages

Take a screenshot of a website in another language

More Ruby: every Ruby recipe

FAQ

Frequently asked questions

Why is the screenshot response Base64 and not an image?
So the same body works in JSON, in a webhook payload and in an <img> data URI. Decode it with String#unpack1("m"), or skip decoding and use the X-Result-Url header, a direct link to the image.
How do I take a mobile screenshot?
Set screenshot_options.viewport_width to a phone width such as 390 and device_scale_factor to 2 or 3. The viewport can be anything from 320 to 1920 pixels wide.
Can I screenshot one element instead of the whole page?
Yes: screenshot_options takes a CSS selector and captures just that element. A selector that matches nothing is a 422, and costs nothing.
Do I need an SDK to call URLpipe from Ruby?
No gem: Net::HTTP and json are in the standard library, and the API is one POST per job.

Get a key and run it.

Free plan, no card. Paste your key into URLPIPE_API_KEY and every program on this page runs as it is.