Disclosure: I build RenderScreenshot. Restpack's Screenshot API is priced well and its v7 parameter set is clean. Below are the cases where it is the better deal, and the price table shows one of them.
Restpack's Screenshot API v7 and RenderScreenshot are close in shape: both accept GET or POST, both return the image or JSON with a CDN link, both block ads and cookie notices and wait for a selector. So a Restpack alternative should be judged on the details: which defaults change your output, which features sit behind which plan, and what you get that Restpack does not offer (signed public URLs, presets, cache purge, PDF and AVIF output, webhooks). This page answers those, then gives you a percentage rollout so you can move traffic without a big-bang switch. It belongs to our best screenshot API comparison.
Last verified: October 15, 2026, against the Restpack Screenshot API v7 reference and Restpack screenshot pricing.
Should you switch?
| Stay with Restpack if | Move to RenderScreenshot if |
|---|---|
| You render around 100,000 a month or more; Restpack's overage rates are low | You need public, expiring screenshot URLs for <img> or og:image tags |
You use privacy=true so nothing is cached or stored |
You want presets such as og_card (1200×630) or twitter_card_large |
You capture with a grayscale filter |
You need to purge cached images by key, URL pattern or date |
| Restpack's 2,000 px maximum viewport width is enough | You need AVIF or WebP output, PDFs with headers and footers, or webhooks |
| You want a free monthly allowance rather than a 7-day trial |
Defaults that change when you switch
Restpack and RenderScreenshot disagree on four defaults. Port the behaviour you rely on, not just the parameters you send:
| Behaviour | Restpack v7 default | RenderScreenshot default | Set this |
|---|---|---|---|
| Capture area | mode=fullpage |
capture.mode: viewport |
capture.mode: full_page |
| Delay after load | delay=500 ms |
wait.delay: 0 |
wait.delay: 500 if you depended on it |
| Viewport | 1280 × 1024 | 1280 × 800 | viewport.height: 1024 |
| Error pages | non-2xx target responses fail unless allow_failed=true |
not documented as failing on target status | metadata.include: ["status"] and check it yourself |
The first row breaks layouts. Restpack users often never set mode, so they get full-page images. Our default is the viewport. If you swap the endpoint and keep the parameters, your gallery of tall page previews becomes a gallery of 800 px screenshots.
The last row matters for monitoring. Restpack treats a 404 or 500 from the target site as an error by default. To get the same behaviour, ask for the target status in the metadata and fail in your code, or use options.fail_if_contains with text your error pages show.
Parameter map
| Restpack v7 | RenderScreenshot | Notes |
|---|---|---|
access_token (query) or X-Access-Token header |
Authorization: Bearer rs_live_... |
api_key query parameter for quick tests |
url / html |
url / html (+ html_base_url) |
Also markdown |
json=true |
Accept: application/json |
Response shape below |
mode = fullpage, viewport, element |
capture.mode = full_page, viewport, element |
|
element_selector |
capture.selector |
|
format = png, jpg, pdf, html |
output.format = png, jpeg, webp, avif, pdf, html |
jpg becomes jpeg |
width (320 to 2000), height (min 160) |
viewport.width (1 to 7680), viewport.height (1 to 4320) |
|
| full page limited to 16,000 px | capture.scroll.max_height (default 16,000) |
|
thumbnail_width, thumbnail_height |
output.resize.width, output.resize.height, output.resize.fit |
|
css, js |
page.styles, page.scripts |
Arrays; strings or {url} objects |
delay (ms, default 500, max 20,000) |
wait.delay (ms) |
|
wait = load, network, dom |
wait.until = load, networkidle, domcontentloaded |
|
shutter (selector, 10 s timeout) |
wait.for_selector (+ wait.for_selector_mode) |
Overall wait.timeout up to 60 s |
user_agent |
browser.user_agent |
|
headers (newline-separated string) |
network.headers (JSON object) |
|
omit_background |
output.transparent |
PNG and WebP |
retina |
viewport.scale: 2 |
Up to 3 |
emulate_media |
browser.media = screen, print |
|
block_ads |
block.ads |
|
block_cookie_warnings |
block.cookie_banners |
|
cache_ttl (max 1 week) |
cache.ttl (1 hour to 30 days) |
|
filename |
output.filename |
|
allow_failed |
no direct equivalent | See the defaults table |
privacy |
cache.enabled: false |
Our render is still billed |
grayscale |
no equivalent | Inject CSS: html { filter: grayscale(1) } |
Full references: capture, wait, blocking, output.
Response shape in JSON mode
Both services can answer with JSON that points to a hosted image, which is the right mode if you display the image rather than process it. Restpack's v7 JSON has image, cached, width, height, remote_status, run_time and url. Ours nests the same facts: image.url, image.width, image.height, image.size, image.format, plus cache.hit, cache.url, cache.expires_at and usage.credits. Restpack's documentation says its CDN URL does not count against your limits; on our side, the X-Cache-URL CDN link needs no key and cache hits are not billed as renders.
If your code reads Restpack's fields directly, normalise both shapes in one function during the migration, as the Python example below does. Then the rest of your code does not care which provider answered.
Pricing at three volumes
List prices, monthly billing, checked on October 15, 2026.
| Monthly screenshots | Restpack | RenderScreenshot |
|---|---|---|
| 1,000 | $9.95 (Developer, 1,000) | $10 (Starter, 2,500 credits) |
| 10,000 | $39.95 (Startup, 10,000) | $40 (Growth, 12,000 credits) |
| 100,000 | about $250 (Business, $99.95 for 40,000 + 60,000 × $0.0025) | above our largest listed plan (Scale, $100 for 60,000 credits) |
| Free | 7-day trial | 150 credits every month, no card |
The 100,000 figure is our arithmetic from Restpack's listed overage price; their Business Plus plan is $499.95 for 300,000. At that volume Restpack is the cheaper published option, and this page will not pretend otherwise.
Plan gating matters as much as price. Restpack's pricing table excludes shutter, element capture, custom headers, ad blocking and cookie-warning blocking from the Developer plan, and retina from Developer and Startup. On RenderScreenshot, plans differ mainly in credits, rate limits and concurrency (rate limits); test the parameters you need on the free plan before you buy.
Measuring before you move
We have not benchmarked Restpack yet, so there are no latency figures or side-by-side images here. The rollout script below is also a measuring tool: send a small share of URLs to us, log provider and the elapsed time for each request in your own metrics, and compare the two distributions after a few days of real traffic. That beats any benchmark we could publish, because it uses your pages and your network.
Migration code
Node: percentage rollout by URL
// rollout.mjs: move a share of Restpack traffic to RenderScreenshot, keyed by URL. // Node 22+, npm i renderscreenshot // RS_ROLLOUT=25 sends about 25% of distinct URLs to RenderScreenshot; the rest stay on Restpack. import { Client, TakeOptions } from 'renderscreenshot'; import { createHash } from 'node:crypto'; import { writeFile } from 'node:fs/promises'; const RESTPACK = process.env.RESTPACK_BASE ?? 'https://restpack.io'; const ROLLOUT = Number(process.env.RS_ROLLOUT ?? 0); const rs = new Client(process.env.RS_API_KEY, process.env.RS_BASE_URL ? { baseUrl: process.env.RS_BASE_URL } : {}); // The same URL always lands in the same bucket, so caches stay warm on both sides. const bucket = (url) => createHash('sha256').update(url).digest().readUInt16BE(0) % 100; async function restpack(url) { // Restpack v7 defaults to mode=fullpage and a 500 ms delay. const qs = new URLSearchParams({ url, mode: 'viewport', width: '1280', height: '800', format: 'png' }); const res = await fetch(`${RESTPACK}/api/screenshot/v7/capture?${qs}`, { headers: { 'X-Access-Token': process.env.RESTPACK_TOKEN }, signal: AbortSignal.timeout(60_000), }); if (!res.ok) throw new Error(`restpack ${res.status}: ${await res.text()}`); return Buffer.from(await res.arrayBuffer()); } function renderscreenshot(url) { // RenderScreenshot defaults to the viewport, so no mode switch is needed. return rs.take(TakeOptions.url(url).width(1280).height(800).format('png')); } export async function screenshot(url) { const provider = bucket(url) < ROLLOUT ? 'renderscreenshot' : 'restpack'; const image = provider === 'renderscreenshot' ? await renderscreenshot(url) : await restpack(url); return { provider, image }; } try { for (const url of process.argv.slice(2)) { const { provider, image } = await screenshot(url); await writeFile(`${bucket(url)}.png`, image); console.log(`${url} -> ${provider} (${image.length} bytes)`); } } catch (err) { console.error(err.message); process.exitCode = 1; }
Hashing the URL, instead of picking a provider at random per request, keeps each URL on one provider. Both caches stay useful, and when a user reports a bad image you know which service made it.
Python: one shape for both JSON responses
"""json_mode.py: Restpack's json=true response vs RenderScreenshot's JSON response. Both return a hosted image URL instead of bytes. The field names differ, so normalise them in one place. Python 3.10+, standard library only. Usage: RS_API_KEY=rs_live_... python3 json_mode.py https://example.com """ import json import os import sys import urllib.error import urllib.request RS = os.environ.get("RS_BASE_URL", "https://api.renderscreenshot.com") def normalise_restpack(payload: dict) -> dict: # Restpack: {"image": "...", "cached": true, "width": 1280, "height": 768, ...} return {"url": payload["image"], "cached": payload.get("cached"), "width": payload.get("width"), "height": payload.get("height")} def normalise_renderscreenshot(payload: dict) -> dict: # RenderScreenshot: {"image": {"url", "width", "height"}, "cache": {"hit", "url"}, ...} image = payload["image"] return {"url": image["url"], "cached": payload.get("cache", {}).get("hit"), "width": image.get("width"), "height": image.get("height")} def renderscreenshot_json(url: str) -> dict: req = urllib.request.Request( f"{RS}/v1/screenshot", data=json.dumps({"url": url, "viewport": {"width": 1280, "height": 800}}).encode(), method="POST", headers={ "Authorization": f"Bearer {os.environ['RS_API_KEY']}", "Content-Type": "application/json", "Accept": "application/json", # ask for JSON instead of image bytes }, ) with urllib.request.urlopen(req, timeout=60) as res: return normalise_renderscreenshot(json.load(res)) if __name__ == "__main__": try: print(renderscreenshot_json(sys.argv[1] if len(sys.argv) > 1 else "https://example.com")) except urllib.error.HTTPError as err: print(f"HTTP {err.code}: {err.read()[:200]!r}", file=sys.stderr) sys.exit(1) # The Restpack shape, taken from their v7 docs, for comparison: print(normalise_restpack({"cached": True, "height": 768, "width": 1280, "image": "https://cdn.restpack.io/a/cache/...", "url": "https://google.com"}))
Ruby: a job with bounded retries
# capture_job.rb: a background-job style wrapper that replaces a Restpack call. # gem install renderscreenshot require 'renderscreenshot' class CaptureJob def initialize(client = RenderScreenshot.client(ENV.fetch('RS_API_KEY'), base_url: ENV['RS_BASE_URL'])) @client = client end # Restpack options this job used: mode=fullpage, block_ads, block_cookie_warnings, retina. def perform(url, attempts: 3) options = RenderScreenshot::TakeOptions.url(url) .full_page .block_ads .block_cookie_banners .scale(2) .format('png') @client.take_json(options) rescue RenderScreenshot::Error => e attempts -= 1 raise if attempts.zero? || !e.retryable? sleep(e.is_a?(RenderScreenshot::RateLimitError) && e.retry_after ? e.retry_after : 2) retry end end if $PROGRAM_NAME == __FILE__ result = CaptureJob.new.perform(ARGV.fetch(0, 'https://example.com')) puts result.dig('image', 'url') end
The job retries only errors the SDK marks as retryable (rate limits, timeouts, render failures), waits as long as a rate-limit response asks, and gives up after three attempts. A bad URL fails at once instead of burning retries.
All three tested on October 15, 2026 against a local mock that checks Restpack's X-Access-Token header and records RenderScreenshot request bodies: Node 22.18.0 with [email protected], Python 3.14.8, Ruby 4.0.7 with the renderscreenshot gem 1.0.0. Not run against production APIs.
Rollout plan
- Start at 5%.
RS_ROLLOUT=5in production, with the provider name in your logs and metrics. - Compare for a few days: error rate, p95 time and a sample of images from each side.
- Fix default drift. If our images are shorter, a call site relied on Restpack's full-page default. If they are emptier, it relied on the 500 ms delay; prefer
wait.for_selectorto restoring the delay. - Raise to 50%, then 100%, and remove the Restpack branch after a clean week at 100%.
When Restpack is the better choice
Restpack is the better choice at high volume on price alone, if you need privacy mode (no stored copy at all), or if you use its grayscale filter and do not want to maintain CSS for it. It is also a sound choice if you never put screenshot URLs in public pages, because then our signed URLs do not matter to you.
If you embed screenshots publicly, need social-card presets, or want to purge and refresh cached images on demand, create a free key and start the rollout at 5%.