Disclosure: I build RenderScreenshot. ScreenshotOne is the most complete screenshot API on the market by option count, and this page lists the places where it is the better choice.
A ScreenshotOne alternative makes sense when you want use-case presets, a cache you can purge and share publicly by default, or lower prices at 10,000 shots a month. It does not make sense if you depend on video, animated screenshots, an MCP server for AI agents, or ScreenshotOne's long tail of options. This page maps ScreenshotOne's options to RenderScreenshot's, compares prices at three volumes, and gives you a shadow mode migration: keep serving ScreenshotOne while RenderScreenshot renders the same jobs in the background, then compare before you switch. For the wider market, see the best screenshot API comparison.
Last verified: October 15, 2026, against ScreenshotOne's option reference, caching docs and pricing page as published on October 14, 2026.
The short answer
| Choose ScreenshotOne if | Choose RenderScreenshot if |
|---|---|
| You need video, animated or scrolling screenshots, or GPU rendering | You want presets for social cards, Twitter cards, mobile and HD instead of picking sizes |
| You want an MCP server so AI agents can take screenshots | You embed cached images publicly and want a 24-hour default TTL, up to 30 days |
| You use options we do not have: full-page slices and algorithms, Markdown content extraction, OpenAI vision prompts, TIFF, GIF, HEIF, a device scale factor above 3 | You need to purge cached images by key, URL pattern or date |
| You need 100,000+ shots a month on a self-serve plan | You run about 10,000 shots a month and want the lower bill ($40 vs $79) |
| You want extra usage billed per screenshot instead of a hard stop | You want a nested JSON body for complex requests and typed errors with a retryable flag |
Option map
ScreenshotOne has well over 100 options, all flat (viewport_width, block_ads). RenderScreenshot groups them in nested JSON for POST (viewport.width, block.ads) and accepts flat names for the common ones on GET. The table covers the options most integrations use. A dash means there is no equivalent on our side today.
| ScreenshotOne | RenderScreenshot (POST body) |
Notes |
|---|---|---|
access_key |
Authorization: Bearer rs_live_... |
Or api_key on GET, or a signed URL |
signature (signed requests) |
key_id + expires + signature |
Ours must expire, 30 days at most |
url / html / markdown |
url / html / markdown |
|
viewport_width (default 1280) |
viewport.width (default 1280) |
|
viewport_height (default 1024) |
viewport.height (default 800) |
Set it explicitly |
device_scale_factor (1 to 5) |
viewport.scale (1 to 3) |
|
viewport_device |
viewport.device or a preset |
Our presets also set format and cache TTL |
viewport_mobile, viewport_has_touch, viewport_landscape |
viewport.mobile, viewport.touch, viewport.landscape |
|
format (default jpg) |
output.format (default png) |
Both: PNG, JPEG, WebP, AVIF, PDF, HTML. Only ScreenshotOne: GIF, JP2, TIFF, HEIF, Markdown |
image_quality (default 80) |
output.quality (default 80) |
|
image_width, image_height |
output.resize.width, output.resize.height |
Plus resize.fit |
omit_background |
output.transparent |
|
full_page |
capture.mode: "full_page" |
|
full_page_scroll, full_page_scroll_delay |
capture.scroll.simulate, capture.scroll.delay |
|
full_page_max_height |
capture.scroll.max_height (default 16,000) |
|
full_page_algorithm, full_page_slices |
- | |
selector |
capture.mode: "element" + capture.selector |
|
error_on_selector_not_found |
capture.selector_required |
|
clip_x, clip_y, clip_width, clip_height |
capture.clip |
|
capture_beyond_viewport |
capture.beyond_viewport |
|
wait_until (load, domcontentloaded, networkidle0, networkidle2) |
wait.until (load, domcontentloaded, networkidle) |
Our networkidle is 500 ms with no requests |
delay (seconds) |
wait.delay (milliseconds) |
Multiply by 1,000 |
wait_for_selector |
wait.for_selector |
|
timeout (default 60 s, max 90) |
wait.timeout (default 30 s, max 60) |
|
block_ads, block_trackers |
block.ads, block.trackers |
|
block_cookie_banners |
block.cookie_banners |
|
block_chats |
block.chat_widgets |
|
block_requests, block_resources |
block.requests, block.resources |
|
hide_selectors |
page.hide or page.remove |
ScreenshotOne sets display: none, which is our page.remove |
scripts, styles |
page.scripts, page.styles |
|
click |
page.click |
|
dark_mode, reduced_motion, media_type |
browser.dark_mode, browser.reduced_motion, browser.media |
|
user_agent, time_zone |
browser.user_agent, browser.timezone |
|
geolocation_latitude, _longitude, _accuracy |
browser.geolocation |
|
headers, cookies, authorization |
network.headers, network.cookies, network.auth |
Cookies are objects on our side, not name=value; Domain=... strings |
proxy, ip_country_code |
network.proxy, network.country |
|
bypass_csp |
network.bypass_csp |
|
cache, cache_ttl, cache_key |
cache.available, cache.ttl, cache.key |
Our TTL: 1 hour to 30 days, default 24 hours |
store, storage_* |
storage.enabled, storage.path, storage.acl |
Our bucket credentials live in the dashboard |
metadata_page_title, metadata_open_graph, metadata_fonts, metadata_http_response_status_code |
metadata.include: ["title", "og", "fonts", "status"] |
|
fail_if_content_contains, fail_if_content_missing |
options.fail_if_contains, options.fail_if_missing |
|
async + webhook_url |
webhook.url |
A webhook makes our request async |
response_type=json |
Accept: application/json |
|
openai_api_key, vision_prompt |
- |
The full RenderScreenshot list is in the parameter docs. The ScreenshotOne migration guide walks through a real request conversion step by step.
What we have that ScreenshotOne does not
Fair claims only, checked against ScreenshotOne's docs:
- Use-case presets. ScreenshotOne has device presets (
viewport_device). RenderScreenshot has ten use-case presets that bundle size, format and cache TTL:og_card(1200×630),twitter_card(1200×600),twitter_card_large(1200×628),link_preview,full_page,pdf_document,mobile,mobile_landscape,desktop_hdanddesktop_4k. - A cache meant for public links. Both return a direct cache link. ScreenshotOne's is opt-in, defaults to 4 hours, and its docs say it is "not intended to be used in CDN-like scenarios". Our
X-Cache-URLcomes on every response, defaults to 24 hours (up to 30 days), and is designed to go into public HTML. See caching. - Cache purge.
DELETE /v1/cache/:keyremoves one image;POST /v1/cache/purgeremoves by keys, URL pattern or date. ScreenshotOne's caching docs offer a newcache_keyor waiting for the TTL. See cache management. - Typed errors. Every RenderScreenshot error has a
type, acodeand aretryableboolean.
Price at three volumes
As listed on each pricing page on October 14, 2026, monthly billing:
| Monthly volume | ScreenshotOne | RenderScreenshot |
|---|---|---|
| Free | 100 screenshots | 150 credits, no credit card |
| 1,000 | $17 (Basic, 2,000) | $10 (Starter, 2,500 credits) |
| 10,000 | $79 (Growth, 10,000) | $40 (Growth, 12,000 credits) |
| 100,000 | $459 (Scale $259 for 50,000, plus 50,000 × $0.004 extra) | No self-serve plan; Scale is $100 for 60,000 |
ScreenshotOne does not count failed renders or cache hits. RenderScreenshot does not charge for cache hits requested with cache.available. Some ScreenshotOne features are plan-gated: IP location, scrolling screenshots and video start on Growth, GPU rendering on Scale. At 100,000 a month, ScreenshotOne has a self-serve answer and we do not. The pricing comparison has eight more vendors.
Head-to-head: what we measured
No numbers yet for either service. Both need paid keys, and I have not completed that run. I would rather show nothing than a guess. Every request on this page was tested against a local mock that checks paths, auth and parameter names for both APIs. To measure on your URLs, run the comparison script from the best screenshot API comparison:
node compare.mjs --providers screenshotone,renderscreenshot --urls urls.txt --runs 3 --out so-vs-rs.csv
The script sends cache=true to ScreenshotOne and cache.available to RenderScreenshot, so the "warm" rows measure each cache. The shadow mode below gives you a second, better data set: real production jobs, side by side.
Response and auth differences
- Auth. ScreenshotOne:
access_keyin the query, plus optional signed requests. RenderScreenshot: Bearer header for server calls. Signed URLs use a separate public key ID (rs_pub_...) and secret (rs_secret_...), and must carry anexpirestimestamp. - Response. Both return the image by default. ScreenshotOne's metadata comes in
x-screenshotone-*headers; ours inX-Screenshot-*headers, plusX-Cache-URL,X-Cache-Hit,X-Credits-UsedandX-Credits-Remaining. - Defaults. Format JPEG vs PNG, height 1024 vs 800, delay seconds vs milliseconds, timeout 60 vs 30 seconds. Set each one explicitly in the adapter so output does not change shape on the day you switch.
Migration: shadow mode first
A shadow test answers "will my pages look right?" with your own traffic, before any user sees a RenderScreenshot image. ScreenshotOne keeps serving every request. When RS_SHADOW=1, the same job is sent to RenderScreenshot in the background and one JSON line is logged with sizes and timings. Set RS_ROLLOUT_PERCENT=100 to switch over.
Shadow mode costs a RenderScreenshot credit per job, so run it on a sample (one day, or a fraction of traffic) rather than forever.
Node.js
// shadow.mjs: keep serving ScreenshotOne, render the same job on RenderScreenshot in the background, // and log one JSON line per comparison. Node 22+, npm i renderscreenshot. import { Client, TakeOptions } from 'renderscreenshot'; const rs = new Client(process.env.RS_API_KEY); const SHADOW = process.env.RS_SHADOW === '1'; const CUTOVER = process.env.RS_ROLLOUT_PERCENT === '100'; async function viaScreenshotOne(job) { const q = new URLSearchParams({ access_key: process.env.SCREENSHOTONE_ACCESS_KEY, url: job.url, viewport_width: String(job.width), viewport_height: String(job.height), format: job.format, full_page: String(job.fullPage), block_cookie_banners: String(job.blockCookieBanners), dark_mode: String(job.darkMode), }); const res = await fetch(`https://api.screenshotone.com/take?${q}`, { signal: AbortSignal.timeout(60_000) }); if (!res.ok) throw new Error(`ScreenshotOne ${res.status}: ${await res.text()}`); return Buffer.from(await res.arrayBuffer()); } function viaRenderScreenshot(job) { let o = TakeOptions.url(job.url).width(job.width).height(job.height).format(job.format); if (job.fullPage) o = o.fullPage(); if (job.blockCookieBanners) o = o.blockCookieBanners(); if (job.darkMode) o = o.darkMode(); return rs.take(o); } const timed = async (fn) => { const t0 = performance.now(); const buf = await fn(); return { buf, ms: Math.round(performance.now() - t0) }; }; export async function screenshot(input) { const job = { width: 1280, height: 800, format: 'png', fullPage: false, blockCookieBanners: false, darkMode: false, ...input }; if (CUTOVER) return viaRenderScreenshot(job); const primary = await timed(() => viaScreenshotOne(job)); if (SHADOW) { // Fire and forget: the user never waits for the shadow call. It does cost one credit per job. timed(() => viaRenderScreenshot(job)).then( (s) => console.log(JSON.stringify({ shadow: 'ok', url: job.url, so_ms: primary.ms, rs_ms: s.ms, so_bytes: primary.buf.length, rs_bytes: s.buf.length })), (err) => console.log(JSON.stringify({ shadow: 'error', url: job.url, code: err.code ?? null, message: err.message })), ); } return primary.buf; } if (import.meta.url === `file://${process.argv[1]}`) { const image = await screenshot({ url: process.argv[2] ?? 'https://example.com/', blockCookieBanners: true }); console.log(`served ${image.length} bytes`); }
Python
# shadow.py: serve ScreenshotOne, compare against RenderScreenshot in a worker thread. # Python 3.9+. pip install httpx git+https://github.com/Render-Screenshot/rs-python.git import json import os import sys import time from concurrent.futures import ThreadPoolExecutor import httpx from renderscreenshot import Client, TakeOptions rs = Client(os.environ['RS_API_KEY']) SHADOW = os.environ.get('RS_SHADOW') == '1' CUTOVER = os.environ.get('RS_ROLLOUT_PERCENT') == '100' pool = ThreadPoolExecutor(max_workers=4) def via_screenshotone(url, width, height, fmt, full_page): params = { 'access_key': os.environ['SCREENSHOTONE_ACCESS_KEY'], 'url': url, 'viewport_width': width, 'viewport_height': height, 'format': fmt, 'full_page': str(full_page).lower(), } res = httpx.get('https://api.screenshotone.com/take', params=params, timeout=60) res.raise_for_status() return res.content def via_renderscreenshot(url, width, height, fmt, full_page): options = TakeOptions.url(url).width(width).height(height).format(fmt) if full_page: options = options.full_page() return rs.take(options) def compare(url, args, primary): start = time.monotonic() try: image = via_renderscreenshot(url, *args) print(json.dumps({'shadow': 'ok', 'url': url, 'so_bytes': len(primary), 'rs_bytes': len(image), 'rs_ms': round((time.monotonic() - start) * 1000)})) except Exception as exc: # log every failure; never let the shadow path raise into the request print(json.dumps({'shadow': 'error', 'url': url, 'message': str(exc)})) def screenshot(url, width=1280, height=800, fmt='png', full_page=False): args = (width, height, fmt, full_page) if CUTOVER: return via_renderscreenshot(url, *args) primary = via_screenshotone(url, *args) if SHADOW: pool.submit(compare, url, args, primary) return primary if __name__ == '__main__': image = screenshot(sys.argv[1] if len(sys.argv) > 1 else 'https://example.com/') print(f'served {len(image)} bytes') pool.shutdown(wait=True)
Ruby
# shadow.rb: serve ScreenshotOne, compare against RenderScreenshot in a background thread. # Ruby 3.0+. gem install renderscreenshot require 'json' require 'net/http' require 'renderscreenshot' RS = RenderScreenshot.client(ENV.fetch('RS_API_KEY')) SHADOW = ENV['RS_SHADOW'] == '1' CUTOVER = ENV['RS_ROLLOUT_PERCENT'] == '100' def via_screenshotone(url, width:, height:, format:, full_page:) uri = URI('https://api.screenshotone.com/take') uri.query = URI.encode_www_form( access_key: ENV.fetch('SCREENSHOTONE_ACCESS_KEY'), url: url, viewport_width: width, viewport_height: height, format: format, full_page: full_page ) res = Net::HTTP.get_response(uri) raise "ScreenshotOne #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess) res.body end def via_renderscreenshot(url, width:, height:, format:, full_page:) options = RenderScreenshot::TakeOptions.url(url).width(width).height(height).format(format) options = options.full_page if full_page RS.take(options) end def screenshot(url, width: 1280, height: 800, format: 'png', full_page: false) args = { width: width, height: height, format: format, full_page: full_page } return via_renderscreenshot(url, **args) if CUTOVER primary = via_screenshotone(url, **args) if SHADOW Thread.new do image = via_renderscreenshot(url, **args) puts({ shadow: 'ok', url: url, so_bytes: primary.bytesize, rs_bytes: image.bytesize }.to_json) rescue StandardError => e puts({ shadow: 'error', url: url, message: e.message }.to_json) end end primary end if $PROGRAM_NAME == __FILE__ image = screenshot(ARGV[0] || 'https://example.com/') puts "served #{image.bytesize} bytes" sleep 1 if SHADOW # let the demo thread finish before the script exits end
Tested on October 15, 2026, with RS_SHADOW=1, at RS_ROLLOUT_PERCENT=0 and 100, against a local mock of both APIs: Node.js 22.18.0 with renderscreenshot 1.1.0, Python 3.14 with the rs-python SDK 1.0.0, Ruby 4.0.7 with the renderscreenshot gem 1.0.0. In each language the shadow call logged one {"shadow":"ok",...} line and the primary response was served first.
Reading the shadow log
- Group by URL. Look at every row with
"shadow":"error"first; thecodefield tells you whether it was a timeout, a blocked page or a bad parameter. - Compare
so_bytesandrs_bytes. A large drop on our side often means a blank or half-loaded page: addwait.for_selectorfor that site. - Compare
so_msandrs_msat p50 and p95 per site, not as one average. - Open a sample of images from both sides for your hardest pages.
When to stay with ScreenshotOne
- You use video, animated screenshots, scrolling screenshots or GPU rendering. We have none of those.
- Your AI agents use the ScreenshotOne MCP server. We do not have one.
- You rely on options from the dash rows above, such as full-page slices, Markdown extraction or OpenAI vision prompts.
- You are above 60,000 shots a month and want a self-serve plan with per-screenshot overage.
- Your integration works and the bill is fine. Switching costs engineering time; do it for a feature or a saving you can name.
Run the shadow test
Create a free RenderScreenshot key (150 credits a month, no credit card), set RS_API_KEY and RS_SHADOW=1 in staging, and read the log after a day of real jobs. The free credits cover a shadow sample of about a hundred jobs.
Related
- Best screenshot API comparison: ten services on one checklist.
- Urlbox alternative: the other option-rich API, with render links.
- Migrate from ScreenshotOne: request-by-request conversion.
- Cache management: get, delete and purge cached screenshots.