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.
Full page, devices, cookie banners, waiting
# 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;messageanderrorsin the body. Not retryable. - 429: rate limit; wait for
Retry-After. - 500 with
?wait=true: the page failed (HTTP error, timeout, missing element);errorsays why, quota returned. - 503
sync_overloaded: poll theid.
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.