Menu
Sign up

Screenshot API in Ruby: Net::HTTP, Rails jobs, webhooks, signed URLs

Take website screenshots from Ruby with Net::HTTP: full page, device presets, element capture, cookie banners, Rails jobs, cached signed URLs and webhooks.

Published November 4, 2026 · 5 min read

To take a website screenshot in Ruby, POST JSON like {"url": "https://example.com", "full_page": true} to https://shareglint.com/v1/screenshots?wait=true with Net::HTTP and an Authorization: Bearer sk_live_… header. The JSON response holds a url to the PNG, JPEG, WebP or PDF. In Rails, run the call in Active Job or take a webhook, and use a cached, signed /s/ URL for image_tag.

Only the standard library is needed: net/http, json and openssl. The code was run against the API on Ruby 3.4; the webhook check uses ActiveSupport 8.1, which every Rails app has.

One screenshot with Net::HTTP

require "net/http"
require "json"

API = URI("https://shareglint.com/v1/")

def api_request(req)
  req["Authorization"] = "Bearer #{ENV.fetch('SHOT_API_KEY')}"
  Net::HTTP.start(API.host, API.port, use_ssl: API.scheme == "https",
                  open_timeout: 5, read_timeout: 60) do |http|
    http.request(req)
  end
end

def screenshot(**options)
  req = Net::HTTP::Post.new(URI.join(API, "screenshots?wait=true"))
  req["Content-Type"] = "application/json"
  req.body = JSON.generate(options)
  res = api_request(req)
  body = JSON.parse(res.body)

  case res.code.to_i
  when 200 then body
  when 503 then wait_for(body.fetch("id")) # sync window ran out
  when 500 then raise "capture failed: #{body['error']}"
  else raise "#{res.code} #{body['error']} #{body['message']}"
  end
end

shot = screenshot(url: "https://example.com", selector: "p", scale: 2)
File.binwrite("element.png", Net::HTTP.get(URI(shot["url"])))

Three Ruby details. Net::HTTP does not raise on 4xx/5xx, so branch on res.code (a string). read_timeout defaults to 60 seconds (Net::HTTP docs), enough for our sync window, but set it explicitly so a later edit cannot lower it. And mind the trailing slash in API: URI.join drops /v1 from a base without one. The file download sends no key, which is right — it does not need one.

The default viewport is 1280×800; format: "jpeg", quality: 80, "webp" or "pdf" change the output.

# The whole page; lazy_load scrolls first so lazy images appear
screenshot(url: "https://example.com/blog", full_page: true, lazy_load: true)

# A phone preset: viewport, pixel ratio, touch, mobile user agent
screenshot(url: "https://example.com", device: "galaxy-s24")

# A clean dashboard
screenshot(
  url: "https://example.com/dashboard",
  block_cookie_banners: true,
  block_ads: true,
  hide_selectors: ["#intercom-container", ".promo-bar"],
  click_selector: "#tab-yearly",
  wait_for: ".chart svg",
  delay: 1,
  dark_mode: true
)

Presets include iphone-15, iphone-se, pixel-8, galaxy-s24, ipad, macbook and desktop-1920 (full list in the API docs). block_cookie_banners first clicks "accept" on OneTrust, Cookiebot, Didomi and similar dialogs, then hides what remains with the EasyList Cookie List. A selector or click_selector that is not on the page fails the capture with an error, and the quota comes back.

Polling from a job

Without ?wait=true you get a 202 and an id — note that the 202's url is the status endpoint, not the image:

def wait_for(id, tries: 30)
  tries.times do
    shot = JSON.parse(api_request(Net::HTTP::Get.new(URI.join(API, "screenshots/#{id}"))).body)
    return shot if shot["status"] == "completed"
    raise shot["error"] if shot["status"] == "failed"
    sleep 2
  end
  raise "screenshot #{id} still running"
end

In Rails, put the call in a job so no Puma thread sits waiting:

class CaptureScreenshotJob < ApplicationJob
  retry_on Net::ReadTimeout, wait: :polynomially_longer, attempts: 3

  def perform(page)
    shot = screenshot(url: page.url, viewport_width: 1200, viewport_height: 630, block_cookie_banners: true)
    page.update!(screenshot_url: shot["url"])
  end
end

Rails webhook controller

Instead of polling, add webhook_url: "https://your.app/webhooks/screenshots" and we call you with screenshot.completed or screenshot.failed. Verify each call: X-Webhook-Signature is the hex HMAC-SHA256 of X-Webhook-Timestamp + "." + raw body, keyed with the webhook secret from the dashboard or GET /v1/account.

class ScreenshotWebhooksController < ActionController::API # no CSRF in API controllers
  def create
    raw = request.raw_post
    timestamp = request.headers["X-Webhook-Timestamp"].to_s
    expected = OpenSSL::HMAC.hexdigest("SHA256", Rails.application.credentials.screenshot_webhook_secret, "#{timestamp}.#{raw}")

    unless ActiveSupport::SecurityUtils.secure_compare(expected, request.headers["X-Webhook-Signature"].to_s) &&
           (Time.now.to_i - timestamp.to_i).abs <= 300 # replay window
      return head :bad_request
    end

    event = JSON.parse(raw)
    StoreScreenshotJob.perform_later(event["id"], event["url"]) if event["event"] == "screenshot.completed"
    head :no_content
  end
end

Hash request.raw_post, not params.to_json: Rails merges route and query parameters into params and re-encodes them. secure_compare compares in constant time. In a controller inheriting from ActionController::Base, add skip_forgery_protection. We retry failed deliveries five times with backoff, so return 204 quickly.

Signed /s/ URLs with Rails.cache

For image_tag or og:image, sign a URL with your signing secret and account id — no API call per page view:

require "openssl"
require "erb"

def signed_screenshot_url(page_url, ext: "png", **options)
  params = { "url" => page_url }
  options.each do |name, value|
    params[name.to_s] = value.is_a?(Array) ? value.join(",") : value.to_s
  end
  path = "/s/#{ENV.fetch('SHOT_ACCOUNT_ID')}.#{ext}"
  query = params.sort.map { |k, v| "#{ERB::Util.url_encode(k)}=#{ERB::Util.url_encode(v)}" }.join("&")
  s = OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("SHOT_SIGNING_SECRET"), "#{path}?#{query}")[0, 32]
  "https://shareglint.com#{path}?#{query}&s=#{s}"
end
<%= image_tag signed_screenshot_url(@site.url, device: "mobile", block_cookie_banners: true), alt: @site.name %>

The canonical string is the path, ?, then every parameter except s, sorted by name, each part RFC 3986-encoded; s is the first 32 hex characters of the HMAC. ERB::Util.url_encode encodes a space as %20; CGI.escape and URI.encode_www_form write + and the server answers 403. true.to_s is "true", which is accepted. We ran this function against the server's verifier: valid URLs redirect, a changed character is a 403.

The first request captures (and charges); repeats within 24 hours redirect to the cached file. If you store result URLs from the POST API instead, Rails.cache.fetch(["shot", url], expires_in: 24.hours) { screenshot(url: url)["url"] } matches our cache window.

Errors and quota

  • 402 quota_exceeded: renders used up; nothing charged.
  • 422: invalid option or non-public url; message and errors in the body. Not retryable.
  • 429: rate limit; wait for Retry-After.
  • 500 with ?wait=true: the page failed (HTTP error, timeout, missing element); error says why, quota returned.
  • 503 sync_overloaded: poll the id.

A viewport or element capture uses 1 render, a full page 2. Give a retried POST the same Idempotency-Key header and it returns the first response instead of capturing again — useful with retry_on. Plans are on the pricing page; to compare with running Chromium yourself, see the Playwright guide.

FAQ

How do I take a website screenshot in Ruby?

Without running a browser, POST JSON such as {"url": "https://example.com"} to /v1/screenshots?wait=true with Net::HTTP from the standard library and an Authorization: Bearer header. The JSON response has a url to the finished image; Net::HTTP.get(URI(url)) downloads it.

Which URL encoding do I use to sign a screenshot URL in Ruby?

ERB::Util.url_encode or CGI.escapeURIComponent (Ruby 3.2+), which follow RFC 3986 and write a space as %20. CGI.escape and URI.encode_www_form_component write + for a space, and the signature then fails with 403.

Should Rails take screenshots inside a controller action?

No. A capture takes seconds, which ties up a Puma thread. Queue it in Active Job, or send webhook_url and let the API call a controller when the screenshot is done.

What does a screenshot cost?

A viewport or element screenshot uses 1 render of the monthly quota, a full-page one 2. The same URL with the same options within 24 hours is served from cache for free; failed captures give the render back.