ScreenshotAPI.net Alternative: Convert Your Request URLs

Paste an existing ScreenshotAPI.net request URL and get the RenderScreenshot equivalent. Full parameter map, unit traps, pricing checked October 2026, and code.

By Simon Chiu · Updated

Disclosure: RenderScreenshot is our product. ScreenshotAPI.net is a competitor. ScreenshotAPI.net facts on this page come from its documentation and pricing page, checked on 2026-10-15.

Most ScreenshotAPI.net integrations are a single GET URL: https://shot.screenshotapi.net/v3/screenshot?token=...&url=... plus options, often built by string concatenation in several places across a codebase. The fastest migration path is not to rewrite each call by hand. It is to convert those URLs mechanically, log anything that does not map, and compare the output before you switch.

This page gives you that converter in Node.js, Python and Ruby, the full parameter map behind it, and the three unit and default traps that cause most migration bugs. The screenshot API comparison covers the rest of the market.

TL;DR

Stay with ScreenshotAPI.net if Move to RenderScreenshot if
Lowest list price at your volume is the deciding factor You want signed URLs for public <img> and og:image use, so no token sits in your HTML
You need scrolling videos (MP4, WebM, GIF) or scheduled screenshots You want nested JSON options, presets such as og_card, and Markdown input
You use its text and HTML extraction You want element capture that fails fast when the selector is missing (capture.selector_required)
You submit very large bulk jobs (its Business plan lists no bulk URL limit) You want a public CDN link (X-Cache-URL) returned with every render

Three traps to know before you convert

  1. Default output is JSON there, bytes here. ScreenshotAPI.net's docs list output with a default of JSON. Our API returns the image unless you send Accept: application/json. A URL with no output parameter therefore means "JSON" and must get the Accept header after migration.
  2. timeout units differ. ScreenshotAPI.net's timeout is in milliseconds. Our wait.timeout is in seconds, with a maximum of 60. timeout=45000 becomes wait.timeout: 45.
  3. The token moves out of the URL. Server-side calls send Authorization: Bearer rs_live_.... Public embeds use signed URLs, which carry a public key ID and a signature, never the secret.

delay (milliseconds) and ttl (seconds) use the same units in both APIs.

Parameter mapping

Our names are from the POST endpoint reference and the parameter pages it links to.

ScreenshotAPI.net RenderScreenshot Notes
token Authorization: Bearer header Or a signed URL for public use
url url
custom_html html Also markdown with markdown_theme
width, height viewport.width, viewport.height
retina=true viewport.scale: 2 Scale goes up to 3
full_page=true capture.mode: "full_page"
lazy_load=true capture.scroll.simulate: true
scroll_delay (ms) capture.scroll.delay (ms)
selector capture.mode: "element", capture.selector Add capture.selector_required to fail when it is missing
clip[x], clip[y], ... capture.clip {x, y, width, height}
file_type (png, jpg, webp, pdf) output.format jpg becomes jpeg; we add avif
image_quality output.quality
omit_background=true output.transparent: true
thumbnail_width output.resize.width Resized in the same request
wait_for_event wait.until load, domcontentloaded, networkidle
delay (ms) wait.delay (ms)
timeout (ms) wait.timeout (s, max 60) Convert units
fresh=true cache.refresh: true
enable_caching cache.enabled Our cache is on by default
ttl (s) cache.ttl (s, 3,600 to 2,592,000)
block_ads block.ads
block_tracking block.trackers
no_cookie_banners block.cookie_banners
block_chat_widgets block.chat_widgets
block_js, block_images, block_font, ... block.resources: ["script", "image", "font", ...]
block_specific_requests block.requests (glob patterns)
css page.styles: ["..."]
remove_selector page.remove
blur_selector page.styles with filter: blur() No dedicated option
selector_to_click page.click
cookies network.cookies Array of cookie objects
timezone browser.timezone
latitude, longitude browser.geolocation
output=json Accept: application/json

The converters below cover the common subset. Anything they do not recognize goes into an unmapped list instead of being dropped, so you see every gap on the first run.

The converter

Node.js (Node 22+, no dependencies)

// Convert an existing ScreenshotAPI.net request URL into a RenderScreenshot POST body.
// Unknown parameters are returned in `unmapped` so nothing is dropped silently.
const truthy = (v) => v === 'true' || v === '1';

export function convert(sapiUrl) {
  const q = new URL(sapiUrl).searchParams;
  const body = {}, unmapped = [];
  const set = (path, value) => {
    const keys = path.split('.'); let o = body;
    for (const k of keys.slice(0, -1)) o = o[k] ??= {};
    o[keys.at(-1)] = value;
  };
  for (const [k, v] of q) {
    switch (k) {
      case 'token': break; // auth moves to the Authorization header
      case 'url': set('url', v); break;
      case 'custom_html': set('html', v); break;
      case 'width': set('viewport.width', Number(v)); break;
      case 'height': set('viewport.height', Number(v)); break;
      case 'retina': if (truthy(v)) set('viewport.scale', 2); break;
      case 'full_page': if (truthy(v)) set('capture.mode', 'full_page'); break;
      case 'lazy_load': if (truthy(v)) set('capture.scroll.simulate', true); break;
      case 'scroll_delay': set('capture.scroll.delay', Number(v)); break;
      case 'selector': set('capture.mode', 'element'); set('capture.selector', v); break;
      case 'file_type': set('output.format', v === 'jpg' ? 'jpeg' : v); break;
      case 'image_quality': set('output.quality', Number(v)); break;
      case 'omit_background': if (truthy(v)) set('output.transparent', true); break;
      case 'thumbnail_width': set('output.resize.width', Number(v)); break;
      case 'wait_for_event': set('wait.until', v); break; // load | domcontentloaded | networkidle
      case 'delay': set('wait.delay', Math.min(Number(v), 10_000)); break; // both in ms
      case 'timeout': set('wait.timeout', Math.min(Math.ceil(Number(v) / 1000), 60)); break; // ms -> s
      case 'fresh': if (truthy(v)) set('cache.refresh', true); break;
      case 'enable_caching': set('cache.enabled', truthy(v)); break;
      case 'ttl': set('cache.ttl', Math.min(Math.max(Number(v), 3600), 2_592_000)); break; // both in s
      case 'block_ads': set('block.ads', truthy(v)); break;
      case 'block_tracking': set('block.trackers', truthy(v)); break;
      case 'no_cookie_banners': set('block.cookie_banners', truthy(v)); break;
      case 'block_chat_widgets': set('block.chat_widgets', truthy(v)); break;
      case 'css': (body.page ??= {}).styles = [...(body.page?.styles ?? []), v]; break;
      case 'remove_selector': set('page.remove', v.split(',').map((s) => s.trim())); break;
      case 'blur_selector': // no direct option: blur with injected CSS
        (body.page ??= {}).styles = [...(body.page?.styles ?? []), `${v} { filter: blur(8px) !important; }`]; break;
      case 'timezone': set('browser.timezone', v); break;
      case 'output': break; // handled by the caller: image bytes or JSON
      default: unmapped.push(k);
    }
  }
  return { body, wantsJson: (q.get('output') ?? 'json') === 'json', unmapped };
}

export async function render(sapiUrl) {
  const { body, wantsJson, unmapped } = convert(sapiUrl);
  if (unmapped.length) console.warn('not mapped:', unmapped.join(', '));
  const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.RS_API_KEY}`,
      'Content-Type': 'application/json',
      ...(wantsJson ? { Accept: 'application/json' } : {}),
    },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(90_000),
  });
  if (!res.ok) throw new Error(`RenderScreenshot ${res.status}: ${await res.text()}`);
  return wantsJson ? res.json() : Buffer.from(await res.arrayBuffer());
}

Example: this legacy URL

https://shot.screenshotapi.net/v3/screenshot?token=TOKEN&url=https%3A%2F%2Fexample.com%2F&output=image
  &file_type=webp&full_page=true&lazy_load=true&wait_for_event=networkidle&delay=1500
  &no_cookie_banners=true&block_ads=true&blur_selector=.email&timeout=45000&fresh=true&latitude=1

becomes this body (the converter reports latitude as unmapped, because it needs a longitude to form browser.geolocation):

{
  "url": "https://example.com/",
  "output": { "format": "webp" },
  "capture": { "mode": "full_page", "scroll": { "simulate": true } },
  "wait": { "until": "networkidle", "delay": 1500, "timeout": 45 },
  "block": { "cookie_banners": true, "ads": true },
  "page": { "styles": [".email { filter: blur(8px) !important; }"] },
  "cache": { "refresh": true }
}

Python

"""Convert an existing ScreenshotAPI.net request URL into a RenderScreenshot POST body."""
import os
from urllib.parse import parse_qsl, urlsplit

import requests


def _truthy(v): return v in ("true", "1")


def _set(body, path, value):
    *parents, last = path.split(".")
    for k in parents:
        body = body.setdefault(k, {})
    body[last] = value


def convert(sapi_url: str):
    body, unmapped, output = {}, [], "json"
    styles = []
    for k, v in parse_qsl(urlsplit(sapi_url).query, keep_blank_values=True):
        if k == "token": continue
        elif k == "url": _set(body, "url", v)
        elif k == "custom_html": _set(body, "html", v)
        elif k == "width": _set(body, "viewport.width", int(v))
        elif k == "height": _set(body, "viewport.height", int(v))
        elif k == "retina" and _truthy(v): _set(body, "viewport.scale", 2)
        elif k == "full_page" and _truthy(v): _set(body, "capture.mode", "full_page")
        elif k == "lazy_load" and _truthy(v): _set(body, "capture.scroll.simulate", True)
        elif k == "scroll_delay": _set(body, "capture.scroll.delay", int(v))
        elif k == "selector":
            _set(body, "capture.mode", "element"); _set(body, "capture.selector", v)
        elif k == "file_type": _set(body, "output.format", "jpeg" if v == "jpg" else v)
        elif k == "image_quality": _set(body, "output.quality", int(v))
        elif k == "omit_background" and _truthy(v): _set(body, "output.transparent", True)
        elif k == "thumbnail_width": _set(body, "output.resize.width", int(v))
        elif k == "wait_for_event": _set(body, "wait.until", v)
        elif k == "delay": _set(body, "wait.delay", min(int(v), 10_000))
        elif k == "timeout": _set(body, "wait.timeout", min(-(-int(v) // 1000), 60))  # ms -> s
        elif k == "fresh" and _truthy(v): _set(body, "cache.refresh", True)
        elif k == "enable_caching": _set(body, "cache.enabled", _truthy(v))
        elif k == "ttl": _set(body, "cache.ttl", min(max(int(v), 3600), 2_592_000))
        elif k == "block_ads": _set(body, "block.ads", _truthy(v))
        elif k == "block_tracking": _set(body, "block.trackers", _truthy(v))
        elif k == "no_cookie_banners": _set(body, "block.cookie_banners", _truthy(v))
        elif k == "block_chat_widgets": _set(body, "block.chat_widgets", _truthy(v))
        elif k == "css": styles.append(v)
        elif k == "remove_selector": _set(body, "page.remove", [s.strip() for s in v.split(",")])
        elif k == "blur_selector": styles.append(f"{v} {{ filter: blur(8px) !important; }}")
        elif k == "timezone": _set(body, "browser.timezone", v)
        elif k == "output": output = v
        elif k not in ("retina", "full_page", "lazy_load", "omit_background", "fresh"): unmapped.append(k)
    if styles:
        _set(body, "page.styles", styles)
    return body, output == "json", unmapped


def render(sapi_url: str):
    body, wants_json, unmapped = convert(sapi_url)
    if unmapped:
        print("not mapped:", ", ".join(unmapped))
    headers = {"Authorization": f"Bearer {os.environ['RS_API_KEY']}"}
    if wants_json:
        headers["Accept"] = "application/json"
    r = requests.post("https://api.renderscreenshot.com/v1/screenshot", json=body, headers=headers, timeout=90)
    r.raise_for_status()
    return r.json() if wants_json else r.content

The last elif skips boolean flags that were set to false, so they do not show up as unmapped.

Ruby

# Convert an existing ScreenshotAPI.net request URL into a RenderScreenshot POST body.
require 'net/http'
require 'json'
require 'uri'

def convert(sapi_url)
  body = {}
  unmapped = []
  styles = []
  output = 'json'
  set = lambda do |path, value|
    *parents, last = path.split('.')
    parents.reduce(body) { |o, k| o[k] ||= {} }[last] = value
  end
  truthy = ->(v) { %w[true 1].include?(v) }

  URI.decode_www_form(URI(sapi_url).query).each do |k, v|
    case k
    when 'token' then next
    when 'url' then set.('url', v)
    when 'custom_html' then set.('html', v)
    when 'width' then set.('viewport.width', v.to_i)
    when 'height' then set.('viewport.height', v.to_i)
    when 'retina' then set.('viewport.scale', 2) if truthy.(v)
    when 'full_page' then set.('capture.mode', 'full_page') if truthy.(v)
    when 'lazy_load' then set.('capture.scroll.simulate', true) if truthy.(v)
    when 'scroll_delay' then set.('capture.scroll.delay', v.to_i)
    when 'selector' then set.('capture.mode', 'element'); set.('capture.selector', v)
    when 'file_type' then set.('output.format', v == 'jpg' ? 'jpeg' : v)
    when 'image_quality' then set.('output.quality', v.to_i)
    when 'omit_background' then set.('output.transparent', true) if truthy.(v)
    when 'thumbnail_width' then set.('output.resize.width', v.to_i)
    when 'wait_for_event' then set.('wait.until', v)
    when 'delay' then set.('wait.delay', [v.to_i, 10_000].min)
    when 'timeout' then set.('wait.timeout', [(v.to_i / 1000.0).ceil, 60].min) # ms -> s
    when 'fresh' then set.('cache.refresh', true) if truthy.(v)
    when 'enable_caching' then set.('cache.enabled', truthy.(v))
    when 'ttl' then set.('cache.ttl', v.to_i.clamp(3600, 2_592_000))
    when 'block_ads' then set.('block.ads', truthy.(v))
    when 'block_tracking' then set.('block.trackers', truthy.(v))
    when 'no_cookie_banners' then set.('block.cookie_banners', truthy.(v))
    when 'block_chat_widgets' then set.('block.chat_widgets', truthy.(v))
    when 'css' then styles << v
    when 'remove_selector' then set.('page.remove', v.split(',').map(&:strip))
    when 'blur_selector' then styles << "#{v} { filter: blur(8px) !important; }"
    when 'timezone' then set.('browser.timezone', v)
    when 'output' then output = v
    else unmapped << k
    end
  end
  set.('page.styles', styles) unless styles.empty?
  [body, output == 'json', unmapped]
end

def render(sapi_url)
  body, wants_json, unmapped = convert(sapi_url)
  warn "not mapped: #{unmapped.join(', ')}" unless unmapped.empty?
  uri = URI('https://api.renderscreenshot.com/v1/screenshot')
  headers = { 'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}", 'Content-Type' => 'application/json' }
  headers['Accept'] = 'application/json' if wants_json
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 90) { |h| h.post(uri.path, body.to_json, headers) }
  raise "RenderScreenshot #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)

  wants_json ? JSON.parse(res.body) : res.body
end

Tested on 2026-10-15 with Node 22.18.0, Python 3.14 (requests 2.34.2) and Ruby 4.0.7. Each converter ran on a sample legacy URL, and the resulting request went to a local mock of our API. We checked the logged JSON against our parameter docs. We did not call either live API.

A safe cutover

  1. Inventory. Search your code and logs for shot.screenshotapi.net. Run every distinct URL shape through convert() and fix each unmapped key.
  2. Shadow mode. For one week, keep serving ScreenshotAPI.net images, but also call render() in the background for a sample (5 to 10 percent) of requests. Log status, latency and bytes for both.
  3. Look at the images. Pick the 20 pages where the byte sizes differ most and compare them by eye. Size gaps usually mean a cookie banner, a lazy image or a font that one side missed.
  4. Flip per use case. Move the least risky output first (internal thumbnails), then public ones. Keep the old token valid until the last image URL that points at ScreenshotAPI.net has aged out of your caches.

Price at three volumes

Prices as listed on ScreenshotAPI.net's pricing page and our pricing page, checked on 2026-10-15, monthly billing, USD.

Screenshots per month ScreenshotAPI.net RenderScreenshot
1,000 Essential, $9 (1,000) Starter, $10 (2,500 credits)
10,000 Startup, $29 (10,000) Growth, $40 (12,000 credits)
100,000 Business, $175 (100,000) More than Scale's 60,000 credits ($100); contact us

ScreenshotAPI.net is cheaper on list price at all three points. Its trial gives 100 screenshots over 7 days; our free plan gives 150 credits every month, with no credit card and no end date. Its Essential plan leaves out proxy support, scrolling screenshots and video, as its feature table shows.

When ScreenshotAPI.net is the better choice

  • You need video output (scrolling screenshots as MP4, WebM or GIF) or scheduled captures.
  • You extract page text or HTML along with the image.
  • Cost per screenshot at high volume is your main constraint.

If you need signed URLs for public images, presets for social cards, or nested request options that are easy to review in code, create a free key and run the converter on your top five URL shapes.

Frequently asked questions

Does ScreenshotAPI.net return JSON or an image by default?
Its documentation lists JSON as the default for the output parameter. RenderScreenshot returns image bytes by default and JSON when you send Accept application/json. The converter on this page keeps whichever behavior your old URL asked for.
Is ScreenshotAPI.net cheaper?
On list price in October 2026, yes, at all three volumes we checked (1,000, 10,000 and 100,000 a month). Compare the options you need, not only the price per screenshot.
What is the RenderScreenshot equivalent of blur_selector?
There is no dedicated option. Inject CSS with page.styles, for example ".email { filter: blur(8px) !important; }". The converter does this for you.
Can I keep using a token in the URL?
You can pass api_key as a query parameter for quick tests, but for anything public use a signed URL. It never exposes your secret and it expires.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.