Screenshotlayer Alternative: Migrate a Legacy Screenshot API

Moving off Screenshotlayer? Map its parameters, replace S3 credentials in URLs, handle JSON errors on image calls, and roll out with an automatic fallback.

By Simon Chiu · Updated

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

  1. 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 your access_key in clear text. Fix the scheme while you migrate, and call our API at https://api.renderscreenshot.com.
  2. 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 send storage.enabled: true and a storage.path template. See storage.
  3. Treat JSON as failure. A failed Screenshotlayer call returns {"success": false, "error": {"code": ..., "type": ...}} (for example invalid_access_key, rate_limit_reached, delay_time_too_long). Code that writes the body straight to snapshot.png stores the error text as an image. Check the content type. Our errors use HTTP status codes and a typed JSON body with retryable. See error codes.
  4. Convert seconds to milliseconds. Screenshotlayer's delay is seconds (max 20). Ours is wait.delay in milliseconds. Better: replace fixed delays with wait.until: "networkidle" or wait.for_selector. See wait.
  5. Send a real User-Agent. Screenshotlayer's docs say it sends no User-Agent header unless you pass user_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, set browser.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-Hit header) 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.

Frequently asked questions

Does Screenshotlayer support HTTPS?
Yes. Its getting-started page says all customers can connect over 256-bit HTTPS by using https in the base URL. Many of its examples still show http, so check your own integration and switch the scheme.
Can I use Screenshotlayer's free plan in a product?
Its pricing page lists the free plan (100 snapshots a month) as non-commercial use. Paid plans are listed for commercial use.
What replaces Screenshotlayer's export=s3:// parameter?
Configure your bucket once in the RenderScreenshot dashboard and send storage.enabled with a path template. Your bucket credentials then never appear in a request URL or a log line.
How do I know a Screenshotlayer call failed?
Failed calls return a JSON body with success false and an error code, instead of an image. Check the response Content-Type before you save the body as an image.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.