This page shows how to take website screenshots from Ruby with the renderscreenshot gem, and how to wire it into a Rails app the way we would in our own: the capture runs in an ActiveJob, the bytes go to ActiveStorage, the og:image tag comes from an ERB helper that signs a URL, and Minitest covers all of it without network calls. RenderScreenshot itself is a Rails 8.1 app, so these are the patterns we use every day. For other languages, see the screenshot API code examples hub.
Last tested: 2026-10-18 with Ruby 4.0.7, Rails 8.1.4,
renderscreenshot1.0.1, Faraday 2.15.0 and WebMock 3.34.0. The job, helper and tests ran in a freshrails new --minimalapp. Live calls went to a local stub of our API that returns the documented image, batch and error bodies, including a 429 withRetry-After. We did not call the production API for this page.
Install
Add the gem and run Bundler:
# Gemfile gem 'renderscreenshot', '~> 1.0'
bundle install
The gemspec of 1.0.1 requires Ruby 3.2 or later. The gem depends on Faraday and uses its default Net::HTTP adapter, so it adds no native extensions. You need an rs_live_ API key from the dashboard. For signed URLs you also need a signing key pair (rs_pub_ and rs_secret_); see API keys.
Quick start
require 'renderscreenshot' client = RenderScreenshot.client(ENV.fetch('RS_API_KEY')) image = client.take( RenderScreenshot::TakeOptions.url('https://example.com').preset('og_card').format('webp') ) File.binwrite('example.webp', image)
take sends POST /v1/screenshot with a nested JSON body and returns the image as a binary String. TakeOptions is immutable: each call such as .preset or .format returns a new object, so you can keep a base set of options in a constant and extend it per call. Use take_json instead when you only need the CDN URL and dimensions.
One client per process
Build the client once, in an initializer, and read it from Rails.configuration:
# config/initializers/renderscreenshot.rb Rails.application.config.to_prepare do Rails.application.config.x.screenshots = RenderScreenshot::Client.new( ENV.fetch('RS_API_KEY'), # rs_live_... signing_key: ENV.fetch('RS_SECRET_KEY', nil), # rs_secret_..., for signed URLs public_key_id: ENV.fetch('RS_PUBLIC_KEY_ID', nil), # rs_pub_... base_url: ENV.fetch('RS_BASE_URL', nil), timeout: 90 # longer than the 60 s maximum page wait ) end
Two settings matter. The gem's default timeout is 30 seconds, but a capture may wait up to 60 seconds for a slow page (wait.timeout), so a 30-second client can give up on a render that would have worked. And the client keeps one Faraday connection behind a mutex, so one instance is safe to share between Puma threads and job threads. base_url is there so that tests and staging can point at a stub; leave the variable unset in production.
ActiveJob plus ActiveStorage
Screenshots belong in a job. A controller that calls the API holds a Puma thread while the target page loads. A job also gives you retries for free, if you tell it which errors are worth a retry.
# app/models/page.rb class Page < ApplicationRecord has_one_attached :preview end
# app/jobs/capture_preview_job.rb class CapturePreviewJob < ApplicationJob queue_as :screenshots MAX_ATTEMPTS = 5 rescue_from RenderScreenshot::Error do |error| # 429 (rate limit), 408/5xx and network errors say retryable? == true. # A 422 from the target page (timeout, navigation_failed) arrives as a # ValidationError, so we let it through once more by status. worth_retrying = error.retryable? || error.http_status == 422 raise error unless worth_retrying && executions < MAX_ATTEMPTS retry_job wait: (error.retry_after || (15 * (2**executions))).seconds end def perform(page) image = client.take( RenderScreenshot::TakeOptions.url(page.url).preset('og_card').format('webp') ) page.preview.attach( io: StringIO.new(image), filename: "page-#{page.id}.webp", content_type: 'image/webp' ) end private def client = Rails.configuration.x.screenshots end
Enqueue it with CapturePreviewJob.perform_later(page) after the record is saved. Some notes from running it:
retry_afterfirst. The rate limit is per account and shared by every key and every worker. When the API returns 429, the gem fillsretry_afterfrom theRetry-Afterheader. Waiting exactly that long is better than backoff: the window resets at the next full minute, so an earlier retry only burns another request. See rate limits.- Retry a 422 once, not forever. A page that timed out may load on the next try. A page that is blocked will not. If your queue has many 422s for the same host, look at the message, not the retry count.
StringIO, not a tempfile. Anog_cardrender is small enough to keep in memory. Forfull_pagecaptures of long pages, write to aTempfileand attach that instead.- Concurrency. Set the
screenshotsqueue's thread count (Solid Queue, Sidekiq or GoodJob) at or below the number of renders your plan allows per minute divided by the seconds a render takes. Five threads that each hit 429 waste more time than three that do not.
Signed og:image URLs in ERB
A crawler fetches og:image without your API key. A signed URL solves that: you sign the GET request on the server, and the page carries only the public key ID, the expiry and an HMAC.
# app/helpers/og_image_helper.rb module OgImageHelper # A signed GET URL a crawler can fetch without your API key. # Cached so the same page gets the same URL (and the API's cache) for 6 days. def og_image_url(page_url) Rails.cache.fetch(['og-image', page_url], expires_in: 6.days) do Rails.configuration.x.screenshots.generate_url( RenderScreenshot::TakeOptions.url(page_url).preset('og_card').cache_available, expires_at: 7.days.from_now ) end end end
<%# app/views/pages/_og_meta.html.erb %> <% image = og_image_url(url) %> <meta property="og:image" content="<%= image %>"> <meta property="og:image:width" content="1200"> <meta property="og:image:height" content="630"> <meta name="twitter:card" content="summary_large_image">
Render it in your layout's <head> with render 'pages/og_meta', url: request.original_url. The cache is the part people skip. Without it, every page view signs a new URL with a new expires, so every crawler fetch is a new URL and a new render. With it, one URL lives for six days. cache_available tells the API to reuse an identical render inside its cache TTL, which costs 0 credits. Keep the expiry at or under 30 days; the API rejects anything later with invalid_expiration. ERB escapes the & in the URL as &, which is correct HTML; browsers and crawlers decode it before the request.
Signed URLs carry only the flat GET parameters. The gem leaves out options that GET cannot read, such as wait_for_selector or block_trackers. If you need those, render in a job and serve the stored file. For an ERB card route and the meta-tags gem, see the Rails OG image guide.
Errors by class
Every exception inherits from RenderScreenshot::Error, which exposes http_status, code, request_id, retry_after and retryable?. The subclass comes from the HTTP status, and code comes from the API body, so branch on the class for control flow and log the code. The full list of codes is on the error codes page.
| Class | Status | retryable? |
What we do |
|---|---|---|---|
ValidationError |
400, 422 | false | Fix the request on 400. Retry a 422 once. |
AuthenticationError |
401 | false | Check the key or signature. Alert. |
AuthorizationError |
403 | false | Credits or plan. Stop the queue and alert. |
NotFoundError |
404 | false | Unknown batch or cache key. |
TimeoutError |
408, client timeout | true | Retry with backoff. |
RateLimitError |
429 | true | Sleep retry_after, then retry. |
RenderFailedError |
422 with render_failed |
true | Retry once. |
ServerError |
5xx | true | Retry with backoff. |
ConnectionError |
no response | true | Retry with backoff. |
We checked the mapping against gem 1.0.1: a 422 with code timeout raises ValidationError with retryable? false, and a 429 with code too_many_requests raises RateLimitError with retry_after set. The gem can also retry by itself: RenderScreenshot::Client.new(key, max_retries: 2) retries the retryable? classes and honours retry_after. Inside a job, leave it at the default of 0 and let the queue retry, or a worker thread sleeps for up to a minute.
Batch
For a list of URLs with the same options, one batch request replaces many single calls and counts as one request against the per-minute limit:
result = client.batch( %w[https://example.com/a https://example.com/b], options: RenderScreenshot::TakeOptions.url('').preset('og_card') ) result['results'].each { |r| puts "#{r['url']} #{r['status']} #{r.dig('image', 'image_url')}" }
The URL in TakeOptions.url('') is a placeholder that the batch ignores. Up to 10 URLs without a webhook return in the same response. Larger batches return an ID to poll with client.get_batch(id). Batch size limits depend on your plan; see the batch endpoint.
Testing with Minitest
The gem talks HTTP through Net::HTTP, so WebMock intercepts it. Stub the endpoint, run the job, and check the attachment. These three tests pass in the app described above:
# test/jobs/capture_preview_job_test.rb require 'test_helper' require 'webmock/minitest' class CapturePreviewJobTest < ActiveJob::TestCase ENDPOINT = "#{ENV.fetch('RS_BASE_URL', 'https://api.renderscreenshot.com')}/v1/screenshot" WEBP = 'RIFF....WEBPVP8 '.b setup { @page = Page.create!(url: 'https://example.com/pricing') } test 'attaches the rendered image' do stub_request(:post, ENDPOINT) .with(body: hash_including('url' => 'https://example.com/pricing', 'preset' => 'og_card')) .to_return(status: 200, body: WEBP, headers: { 'Content-Type' => 'image/webp' }) CapturePreviewJob.perform_now(@page) assert @page.reload.preview.attached? assert_equal 'image/webp', @page.preview.content_type end test 'a 429 is retried after Retry-After seconds' do stub_request(:post, ENDPOINT).to_return( status: 429, headers: { 'Content-Type' => 'application/json', 'Retry-After' => '20' }, body: { error: { type: 'rate_limit_error', code: 'too_many_requests', message: 'Rate limit exceeded' } }.to_json ) assert_enqueued_with(job: CapturePreviewJob, args: [@page]) do CapturePreviewJob.perform_now(@page) end assert_not @page.reload.preview.attached? end test 'a 400 is not retried' do stub_request(:post, ENDPOINT).to_return( status: 400, headers: { 'Content-Type' => 'application/json' }, body: { error: { type: 'validation_error', code: 'invalid_url', message: 'bad url' } }.to_json ) assert_raises(RenderScreenshot::ValidationError) { CapturePreviewJob.perform_now(@page) } assert_no_enqueued_jobs end end
The helper test needs no stub, because signing is local. Set RS_PUBLIC_KEY_ID=rs_pub_test and RS_SECRET_KEY=rs_secret_test in the test environment and assert that the URL contains key_id=rs_pub_test, ends in a 64-character hex signature, and contains neither rs_live_ nor rs_secret_. For fakes that replace the client entirely, see testing code that calls the API.
Raw HTTP without the gem
If you cannot add a dependency, such as in a Rake task in a locked-down image, the standard library is enough:
# raw.rb - Ruby standard library only (net/http, json) require 'net/http' require 'json' uri = URI("#{ENV.fetch('RS_BASE_URL', 'https://api.renderscreenshot.com')}/v1/screenshot") request = Net::HTTP::Post.new(uri, 'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}", 'Content-Type' => 'application/json') request.body = { url: 'https://example.com', preset: 'og_card', output: { format: 'webp' } }.to_json response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 90) do |http| http.request(request) end if response.is_a?(Net::HTTPSuccess) File.binwrite('card.webp', response.body) puts "CDN copy: #{response['X-Cache-URL']}" else error = JSON.parse(response.body).fetch('error') abort "#{response.code} #{error['code']}: #{error['message']}" end
Note the nesting: in a POST body, the format is output.format, not a top-level format. The raw HTTP guide covers the GET form and signing by hand.
When to drive a browser yourself
If every page you capture is your own and you already run Chrome in production, Ferrum or Grover may be enough; we compare them in Ferrum vs Grover. The API is the better fit when you capture third-party pages, need cookie banners and ads removed, or do not want Chrome and its fonts in your Rails image.
Source and changelog
- Gem source: github.com/Render-Screenshot/rs-ruby, with release notes in its CHANGELOG.
- Every method and option: the Ruby SDK reference.
- Queue patterns for Sidekiq and others: screenshots in background jobs.
Get a free API key to run these listings against the real API. The free plan includes 150 credits every month.