# 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 2026-11-04 · https://shareglint.com/en/screenshot-api/ruby

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

```ruby
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](https://docs.ruby-lang.org/en/master/Net/HTTP.html)), 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.

## Full page, devices, cookie banners, waiting

```ruby
# 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](https://shareglint.com/en/docs#screenshots)). `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:

```ruby
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:

```ruby
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`.

```ruby
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`](https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-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:

```ruby
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
```

```erb
<%= 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](https://shareglint.com/en/pricing); to compare with running Chromium yourself, see the [Playwright guide](https://shareglint.com/en/guides/playwright-screenshot).

## 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.
