Disclosure: RenderScreenshot is our product, and Screenshotlayer (an APILayer product) is a competitor. Screenshotlayer facts on this page come from its documentation and pricing page, both checked on 2026-10-15.
Screenshotlayer is one of the older screenshot APIs, and it shows in a few places that matter during a migration. Delays are in seconds. The documentation examples use http://. S3 export puts your AWS key and secret in the request URL. Errors come back as JSON on an endpoint that normally returns an image. None of this blocks you; each item just needs a deliberate change when you move.
This page lists those changes, maps every Screenshotlayer parameter, and gives you a rollout function in Node.js, Python and Ruby that sends a share of traffic to RenderScreenshot and falls back automatically. For other providers, see the screenshot API comparison.
TL;DR
| Stay with Screenshotlayer if | Move to RenderScreenshot if |
|---|---|
| You need GIF output | You want WebP and AVIF at any quality on every plan, plus PDF |
| You rely on its placeholder image while a capture runs | You want ad, tracker, chat-widget and cookie-banner blocking |
| Your volume sits near one of its plan sizes and its price per snapshot is lower | You do not want storage credentials inside request URLs |
| You need FTP export | You need wait conditions (networkidle, a selector) instead of fixed delays |
Five things to change during the move
- Use HTTPS everywhere. Screenshotlayer's getting-started page says all customers can use HTTPS, but most of its examples use
http://api.screenshotlayer.com. Copied code often still sends youraccess_keyin clear text. Fix the scheme while you migrate, and call our API athttps://api.renderscreenshot.com. - Move S3 credentials out of URLs. Screenshotlayer exports with
export=s3://ACCESS_KEY:SECRET@bucket/path. That URL ends up in logs, proxies and browser history. With RenderScreenshot you add the bucket once in the dashboard and sendstorage.enabled: trueand astorage.pathtemplate. See storage. - Treat JSON as failure. A failed Screenshotlayer call returns
{"success": false, "error": {"code": ..., "type": ...}}(for exampleinvalid_access_key,rate_limit_reached,delay_time_too_long). Code that writes the body straight tosnapshot.pngstores the error text as an image. Check the content type. Our errors use HTTP status codes and a typed JSON body withretryable. See error codes. - Convert seconds to milliseconds. Screenshotlayer's
delayis seconds (max 20). Ours iswait.delayin milliseconds. Better: replace fixed delays withwait.until: "networkidle"orwait.for_selector. See wait. - Send a real User-Agent. Screenshotlayer's docs say it sends no
User-Agentheader unless you passuser_agent. Some sites serve a different page or block requests without one. Our renderer is a Chromium browser; if a site needs a specific user agent, setbrowser.user_agent.
Parameter mapping
| Screenshotlayer | RenderScreenshot | Notes |
|---|---|---|
access_key |
Authorization: Bearer rs_live_... |
Or a signed URL for public <img> use |
secret_key=md5(url+keyword) |
key_id, expires, signature |
HMAC-SHA256 over all parameters, with expiry |
url |
url |
|
viewport=1440x900 (default) |
viewport.width, viewport.height |
Our default is 1280 x 800 |
fullpage=1 |
capture.mode: "full_page" |
Add capture.scroll.simulate: true for lazy images |
width=350 (thumbnail) |
output.resize.width |
|
scale=2 |
viewport.scale (1 to 3) |
|
format (PNG, JPG, GIF, WEBP) |
output.format (png, jpeg, webp, avif, pdf) |
No GIF |
quality (1 to 100, WebP capped at 70 on free) |
output.quality (1 to 100) |
Same range on every plan |
delay (seconds, max 20) |
wait.delay (ms) |
|
css_url |
page.styles: [{ "url": "..." }] |
Inline CSS strings also work |
user_agent |
browser.user_agent |
|
accept_lang |
browser.locale or network.headers |
|
ttl (300 s to 30 days, default 30 days) |
cache.ttl (1 hour to 30 days, default 24 hours) |
Note the different default |
force=1 |
cache.refresh: true |
|
placeholder=1 |
No equivalent | Our requests return when the image is ready |
export=s3://... / ftp://... |
storage.enabled, storage.path |
S3-compatible buckets; no FTP |
Our parameter names come from the POST endpoint reference.
Roll out with a fallback
The function below hides both providers behind one call. ROLLOUT_PERCENT sets the share of requests that try RenderScreenshot first. Whichever provider goes first, a failure falls back to the other one, so the rollout cannot make things worse than today. Start at 10, watch your logs for a few days, then raise it.
Node.js (Node 22+, no dependencies)
// Gradual move from Screenshotlayer to RenderScreenshot behind one function. // ROLLOUT_PERCENT decides how much traffic goes to the new provider; failures fall back. import { createHash } from 'node:crypto'; const ROLLOUT = Number(process.env.ROLLOUT_PERCENT ?? 10); // Screenshotlayer option names -> RenderScreenshot POST body export function toRenderScreenshot(o) { const [vw, vh] = (o.viewport ?? '1440x900').split('x').map(Number); // Screenshotlayer default const body = { url: o.url, viewport: { width: vw, height: vh } }; if (o.fullpage) body.capture = { mode: 'full_page', scroll: { simulate: true } }; if (o.scale) body.viewport.scale = Math.min(Math.max(Number(o.scale), 1), 3); const fmt = (o.format ?? 'png').toLowerCase(); body.output = { format: fmt === 'jpg' ? 'jpeg' : fmt === 'gif' ? 'png' : fmt }; if (o.quality) body.output.quality = Number(o.quality); if (o.width) body.output.resize = { width: Number(o.width) }; // thumbnail width if (o.delay) body.wait = { delay: Math.min(Number(o.delay) * 1000, 10_000) }; // seconds -> ms if (o.css_url) body.page = { styles: [{ url: o.css_url }] }; if (o.user_agent || o.accept_lang) { body.browser = {}; if (o.user_agent) body.browser.user_agent = o.user_agent; if (o.accept_lang) body.browser.locale = o.accept_lang.split(',')[0]; } if (o.force) body.cache = { refresh: true }; else if (o.ttl) body.cache = { ttl: Math.min(Math.max(Number(o.ttl), 3600), 2_592_000) }; // `export=s3://KEY:SECRET@bucket/path` has no equivalent on purpose: configure the bucket // once in the dashboard and send { storage: { enabled: true, path } } instead. return body; } async function renderscreenshot(o) { const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(toRenderScreenshot(o)), signal: AbortSignal.timeout(60_000), }); if (!res.ok) throw new Error(`renderscreenshot ${res.status}`); return Buffer.from(await res.arrayBuffer()); } async function screenshotlayer(o) { const q = new URLSearchParams({ access_key: process.env.SCREENSHOTLAYER_KEY, ...o }); if (process.env.SCREENSHOTLAYER_SECRET) { q.set('secret_key', createHash('md5').update(o.url + process.env.SCREENSHOTLAYER_SECRET).digest('hex')); } const res = await fetch(`https://api.screenshotlayer.com/api/capture?${q}`, { signal: AbortSignal.timeout(60_000) }); // Errors come back as JSON ({ success: false, error: {...} }), so check the content type too. if (!res.ok || (res.headers.get('content-type') ?? '').includes('json')) { throw new Error(`screenshotlayer: ${await res.text()}`); } return Buffer.from(await res.arrayBuffer()); } export async function capture(o) { const useNew = Math.random() * 100 < ROLLOUT; const [first, second] = useNew ? [renderscreenshot, screenshotlayer] : [screenshotlayer, renderscreenshot]; try { return { provider: first.name, image: await first(o) }; } catch (err) { console.warn(`${first.name} failed, falling back:`, err.message); return { provider: second.name, image: await second(o) }; } } // Usage: options use Screenshotlayer's names, so existing call sites do not change. const { provider, image } = await capture({ url: 'https://example.com', viewport: '1280x800', fullpage: '1', delay: '2' });
Python
"""Gradual move from Screenshotlayer to RenderScreenshot behind one function.""" import hashlib import os import random import requests ROLLOUT = float(os.environ.get("ROLLOUT_PERCENT", 10)) def to_renderscreenshot(o: dict) -> dict: vw, vh = (int(x) for x in o.get("viewport", "1440x900").split("x")) body = {"url": o["url"], "viewport": {"width": vw, "height": vh}} if o.get("fullpage"): body["capture"] = {"mode": "full_page", "scroll": {"simulate": True}} if o.get("scale"): body["viewport"]["scale"] = min(max(float(o["scale"]), 1), 3) fmt = o.get("format", "png").lower() body["output"] = {"format": {"jpg": "jpeg", "gif": "png"}.get(fmt, fmt)} if o.get("quality"): body["output"]["quality"] = int(o["quality"]) if o.get("width"): body["output"]["resize"] = {"width": int(o["width"])} if o.get("delay"): body["wait"] = {"delay": min(int(o["delay"]) * 1000, 10_000)} # seconds -> ms if o.get("css_url"): body["page"] = {"styles": [{"url": o["css_url"]}]} browser = {} if o.get("user_agent"): browser["user_agent"] = o["user_agent"] if o.get("accept_lang"): browser["locale"] = o["accept_lang"].split(",")[0] if browser: body["browser"] = browser if o.get("force"): body["cache"] = {"refresh": True} elif o.get("ttl"): body["cache"] = {"ttl": min(max(int(o["ttl"]), 3600), 2_592_000)} return body def renderscreenshot(o): r = requests.post("https://api.renderscreenshot.com/v1/screenshot", json=to_renderscreenshot(o), headers={"Authorization": f"Bearer {os.environ['RS_API_KEY']}"}, timeout=60) r.raise_for_status() return r.content def screenshotlayer(o): params = {"access_key": os.environ["SCREENSHOTLAYER_KEY"], **o} if os.environ.get("SCREENSHOTLAYER_SECRET"): params["secret_key"] = hashlib.md5((o["url"] + os.environ["SCREENSHOTLAYER_SECRET"]).encode()).hexdigest() r = requests.get("https://api.screenshotlayer.com/api/capture", params=params, timeout=60) if not r.ok or "json" in r.headers.get("content-type", ""): raise RuntimeError(f"screenshotlayer: {r.text[:200]}") return r.content def capture(o): order = [renderscreenshot, screenshotlayer] if random.uniform(0, 100) >= ROLLOUT: order.reverse() try: return order[0].__name__, order[0](o) except (requests.RequestException, RuntimeError) as e: print(f"{order[0].__name__} failed, falling back: {e}") return order[1].__name__, order[1](o)
Ruby
# Gradual move from Screenshotlayer to RenderScreenshot behind one method. require 'net/http' require 'json' require 'digest' ROLLOUT = ENV.fetch('ROLLOUT_PERCENT', '10').to_f def to_renderscreenshot(o) vw, vh = o.fetch(:viewport, '1440x900').split('x').map(&:to_i) body = { url: o[:url], viewport: { width: vw, height: vh } } body[:capture] = { mode: 'full_page', scroll: { simulate: true } } if o[:fullpage] body[:viewport][:scale] = o[:scale].to_f.clamp(1, 3) if o[:scale] fmt = o.fetch(:format, 'png').downcase body[:output] = { format: { 'jpg' => 'jpeg', 'gif' => 'png' }.fetch(fmt, fmt) } body[:output][:quality] = o[:quality].to_i if o[:quality] body[:output][:resize] = { width: o[:width].to_i } if o[:width] body[:wait] = { delay: [o[:delay].to_i * 1000, 10_000].min } if o[:delay] # seconds -> ms body[:page] = { styles: [{ url: o[:css_url] }] } if o[:css_url] browser = {} browser[:user_agent] = o[:user_agent] if o[:user_agent] browser[:locale] = o[:accept_lang].split(',').first if o[:accept_lang] body[:browser] = browser unless browser.empty? if o[:force] body[:cache] = { refresh: true } elsif o[:ttl] body[:cache] = { ttl: o[:ttl].to_i.clamp(3600, 2_592_000) } end body end def renderscreenshot(o) uri = URI('https://api.renderscreenshot.com/v1/screenshot') res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 60) do |h| h.post(uri.path, to_renderscreenshot(o).to_json, 'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}", 'Content-Type' => 'application/json') end raise "renderscreenshot #{res.code}" unless res.is_a?(Net::HTTPSuccess) res.body end def screenshotlayer(o) params = { access_key: ENV.fetch('SCREENSHOTLAYER_KEY') }.merge(o) if ENV['SCREENSHOTLAYER_SECRET'] params[:secret_key] = Digest::MD5.hexdigest(o[:url] + ENV['SCREENSHOTLAYER_SECRET']) end uri = URI('https://api.screenshotlayer.com/api/capture') uri.query = URI.encode_www_form(params) res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 60) { |h| h.get(uri.request_uri) } raise "screenshotlayer: #{res.body[0, 200]}" if !res.is_a?(Net::HTTPSuccess) || res['content-type'].to_s.include?('json') res.body end def capture(o) order = %i[renderscreenshot screenshotlayer] order.reverse! if rand * 100 >= ROLLOUT [order.first, send(order.first, o)] rescue StandardError => e warn "#{order.first} failed, falling back: #{e.message}" [order.last, send(order.last, o)] end
Tested on 2026-10-15 with Node 22.18.0, Python 3.14 (requests 2.34.2) and Ruby 4.0.7 against a local mock of both APIs, at ROLLOUT_PERCENT=100, at 0, and with the new provider forced to return HTTP 500 to check the fallback. We did not call either live API.
What to measure during the rollout
Log one line per call: provider, status, latency, bytes, and whether a fallback happened. After a few days, compare per provider:
- Fallback rate. It should be close to zero for both. A high rate on one side points to URLs that provider cannot load.
- p50 and p95 latency, split by cache hit (our
X-Cache-Hitheader) and fresh render. Mixing the two hides the real render time. - Bytes per image at the same format and quality. Large gaps usually mean one side missed lazy images or a font.
We have not published our own head-to-head numbers against Screenshotlayer yet. When we do, we will use our shared test corpus with at least 3 runs per URL, cold and warm cache reported separately.
Price at three volumes
Prices as listed on Screenshotlayer's pricing page and our pricing page, checked on 2026-10-15, monthly billing, USD. Screenshotlayer's free plan is listed for non-commercial use, so the 1,000 row uses its first paid plan.
| Snapshots per month | Screenshotlayer | RenderScreenshot |
|---|---|---|
| 1,000 | Basic, $19.99 (10,000 included) | Starter, $10 (2,500 credits) |
| 10,000 | Basic, $19.99 (10,000 included) | Growth, $40 (12,000 credits) |
| 100,000 | Enterprise $149.99 (75,000) + 25,000 overage at about $0.008 = about $350 | More than Scale's 60,000 credits ($100); contact us |
Screenshotlayer is cheaper at exactly 10,000 a month. We are cheaper at low volume. At 100,000 a month, compare our custom quote with its overage cost. Our free plan includes 150 credits every month, with no credit card required.
When Screenshotlayer is the better choice
- You need GIF output or FTP export.
- You want a placeholder image to show while a capture is in progress.
- Your volume sits right at 10,000 a month and the Basic plan covers it.
Otherwise, the migration is mostly mechanical. Create a free key, put the rollout function in front of your existing calls, and set ROLLOUT_PERCENT=10.