To migrate from Urlbox, convert the options object you send to /v1/render/sync (or put in render links) into a RenderScreenshot request body, fix the two unit changes (timeout and wait_until), and drop the second request that downloads renderUrl: RenderScreenshot returns the image in the first response. This guide gives you a converter in Node.js, Python and Ruby that accepts Urlbox options in camelCase or snake_case and lists everything it could not carry over. For the decision itself (features, prices, when Urlbox is the better tool) see the Urlbox alternative page and the alternatives hub.

Tested on October 15, 2026: the converters with Node.js 22.18.0, Python 3.14.8 and Ruby 4.0.7 against fixed test vectors, and the Ruby send path and SDK examples against a local mock of the RenderScreenshot API. Urlbox option names, units and defaults are as listed in Urlbox's options reference on October 14, 2026.

Which Urlbox integration do you have?

You use What changes Where
Render links in <img> / og:image The link builder function Urlbox alternative, "swap the link builder"
POST /v1/render/sync from a server Options object, response handling This guide
POST /v1/render/async + webhook_url Options, plus a batch job or dashboard webhook This guide, then async screenshots and webhooks

The same request, before and after

Urlbox sync render. The default response is JSON with a temporary renderUrl, so a second request downloads the file:

curl -X POST https://api.urlbox.com/v1/render/sync \
  -H "Authorization: Bearer $URLBOX_SECRET" -H "Content-Type: application/json" \
  -d '{ "url": "example.com", "full_page": true, "retina": true, "timeout": 45000,
        "wait_until": "requestsfinished", "hide_cookie_banners": true, "block_images": true }'

RenderScreenshot POST. One request, image bytes in the body:

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "viewport": { "height": 1024, "scale": 2 },
    "capture": { "mode": "full_page" },
    "wait": { "until": "networkidle", "timeout": 45 },
    "block": { "cookie_banners": true, "resources": ["image"] }
  }' --output page.png

The GET form covers the flat parameters; scale, full_page, timeout and block_cookies are among them:

curl "https://api.renderscreenshot.com/v1/screenshot?url=https%3A%2F%2Fexample.com&height=1024&scale=2&full_page=true&timeout=45&block_cookies=true" \
  -H "Authorization: Bearer $RS_API_KEY" --output page.png

If your code expects JSON, add -H "Accept: application/json". The response then carries image.url (a CDN link) and cache.url, which replaces Urlbox's renderUrl.

Values that change

Urlbox RenderScreenshot What changes
timeout in ms (5,000 to 100,000, default 30,000) wait.timeout in seconds (max 60, default 30) Divide by 1,000 and round up; the converter clamps values above 60
delay in ms wait.delay in ms Same unit; the API caps it at 10,000 ms
wait_until: domloaded, loaded, requestsfinished, mostrequestsfinished wait.until: domcontentloaded, load, networkidle mostrequestsfinished (2 open connections allowed) becomes the stricter networkidle
height default 1024 viewport.height default 800 Send 1024 if you relied on the default
retina: true viewport.scale: 2 Scale goes up to 3
thumb_width, thumb_height, img_fit output.resize.width, .height, .fit Same fit names: cover, contain, fill, inside, outside
block_images, block_fonts, block_medias, block_scripts, block_styles block.resources: [...] One array instead of five booleans
hide_cookie_banners, click_accept block.cookie_banners One option does both: clicks consent buttons and hides banners
hide_selector: ".a, .b" (comma string) page.hide: [".a", ".b"] Both use visibility: hidden
js, css (one string each) page.scripts, page.styles (arrays) Wrap in an array
click (can repeat) page.click (one selector) For several clicks, use page.scripts
header: ["Name=Value"] network.headers: {"Name": "Value"} Object
cookie: ["name=v;Domain=.d.com"] network.cookies: [{name, value, domain}] Objects, domain required
authorization: "Basic ..." network.auth Basic is decoded into username and password
ttl default 30 days cache.ttl default 24 hours (1 h to 30 days) Set cache.ttl: 2592000 to keep long caching
force: true cache.refresh: true
pdf_page_size: "A4", pdf_orientation, pdf_background pdf.paper: "a4", pdf.landscape, pdf.print_background Lowercase paper names. See PDF options

Options with no equivalent today, among them use_stealth, solve_captchas, gpu, video_*, llm_*, and SVG, MP4, WebM or Markdown output, come back as warnings from the converter.

The converter

Node.js

// urlbox-to-rs.mjs: convert a Urlbox options object (camelCase or snake_case) into a RenderScreenshot POST body.
// Node 22+, no dependencies. Returns { body, warnings }.
const snake = (k) => k.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
const list = (v) => (Array.isArray(v) ? v : String(v).split(',').map((s) => s.trim()).filter(Boolean));

const WAIT_UNTIL = { domloaded: 'domcontentloaded', loaded: 'load', requestsfinished: 'networkidle', mostrequestsfinished: 'networkidle' };
const RESOURCE = { block_images: 'image', block_fonts: 'font', block_medias: 'media', block_scripts: 'script', block_styles: 'stylesheet' };
const DIRECT = {
  width: 'viewport.width', height: 'viewport.height',
  quality: 'output.quality', transparent: 'output.transparent',
  thumb_width: 'output.resize.width', thumb_height: 'output.resize.height', img_fit: 'output.resize.fit',
  max_height: 'capture.scroll.max_height',
  wait_for: 'wait.for_selector',
  block_ads: 'block.ads',
  dark_mode: 'browser.dark_mode', reduced_motion: 'browser.reduced_motion', media: 'browser.media',
  user_agent: 'browser.user_agent', tz: 'browser.timezone', accept_lang: 'browser.locale',
  ttl: 'cache.ttl', force: 'cache.refresh',
  pdf_landscape: 'pdf.landscape', pdf_background: 'pdf.print_background', pdf_margin: 'pdf.margin',
};

function put(body, path, value) {
  const keys = path.split('.');
  const leaf = keys.pop();
  keys.reduce((node, k) => (node[k] ??= {}), body)[leaf] = value;
}

function cookieFromString(raw, host) {
  // Urlbox format: "name=value;Domain=.example.com"
  const [pair, ...attrs] = raw.split(';').map((s) => s.trim());
  const i = pair.indexOf('=');
  const cookie = { name: pair.slice(0, i), value: pair.slice(i + 1), domain: host };
  for (const a of attrs) if (/^domain=/i.test(a)) cookie.domain = a.slice(7).replace(/^\./, '');
  return cookie;
}

export function fromUrlbox(input) {
  const opts = Object.fromEntries(Object.entries(input).map(([k, v]) => [snake(k), v]));
  const body = {};
  const warnings = [];
  const host = opts.url ? new URL(/^https?:/.test(opts.url) ? opts.url : `https://${opts.url}`).hostname : undefined;

  for (const [key, v] of Object.entries(opts)) {
    if (DIRECT[key]) { put(body, DIRECT[key], v); continue; }
    if (RESOURCE[key]) { if (v) (body.block ??= {}).resources = [...(body.block?.resources ?? []), RESOURCE[key]]; continue; }
    switch (key) {
      case 'url': body.url = /^https?:/.test(v) ? v : `https://${v}`; break; // Urlbox accepts bare hosts
      case 'html': body.html = v; break;
      case 'format': {
        const f = v === 'jpg' ? 'jpeg' : v;
        if (['png', 'jpeg', 'webp', 'avif', 'pdf', 'html'].includes(f)) put(body, 'output.format', f);
        else warnings.push(`format ${v} has no equivalent (svg, mp4, webm and md are Urlbox-only)`);
        break;
      }
      case 'full_page': if (v) put(body, 'capture.mode', 'full_page'); break;
      case 'selector': put(body, 'capture.mode', 'element'); put(body, 'capture.selector', v); break;
      case 'fail_if_selector_missing':
        if (opts.selector) put(body, 'capture.selector_required', Boolean(v));
        else warnings.push('fail_if_selector_missing only carries over for selector captures');
        break;
      case 'retina': if (v) put(body, 'viewport.scale', 2); break;
      case 'delay': put(body, 'wait.delay', Math.min(Number(v), 10_000)); if (v > 10_000) warnings.push('delay capped at 10,000 ms'); break;
      case 'timeout': put(body, 'wait.timeout', Math.min(Math.ceil(Number(v) / 1000), 60)); break; // ms -> s
      case 'wait_until':
        put(body, 'wait.until', WAIT_UNTIL[v] ?? 'load');
        if (v === 'mostrequestsfinished') warnings.push('mostrequestsfinished mapped to networkidle (stricter: 0 connections)');
        break;
      case 'hide_cookie_banners': case 'click_accept': if (v) put(body, 'block.cookie_banners', true); break;
      case 'block_urls': put(body, 'block.requests', list(v)); break;
      case 'hide_selector': put(body, 'page.hide', list(v)); break; // both use visibility: hidden
      case 'css': put(body, 'page.styles', [v]); break;
      case 'js': put(body, 'page.scripts', [v]); break;
      case 'click': {
        const clicks = list(v);
        put(body, 'page.click', clicks[0]);
        if (clicks.length > 1) warnings.push(`only the first click carries over (${clicks.length} given)`);
        break;
      }
      case 'header':
        put(body, 'network.headers', Object.fromEntries(list(v).map((h) => [h.slice(0, h.indexOf('=')), h.slice(h.indexOf('=') + 1)])));
        break;
      case 'cookie': put(body, 'network.cookies', [].concat(v).map((c) => cookieFromString(c, host))); break;
      case 'authorization': {
        const [scheme, token] = String(v).split(' ');
        if (/^bearer$/i.test(scheme)) put(body, 'network.auth', { type: 'bearer', token });
        else if (/^basic$/i.test(scheme)) {
          const [username, ...rest] = Buffer.from(token, 'base64').toString('utf8').split(':');
          put(body, 'network.auth', { type: 'basic', username, password: rest.join(':') });
        }
        break;
      }
      case 'latitude': case 'longitude': case 'accuracy': put(body, `browser.geolocation.${key}`, Number(v)); break;
      case 'pdf_page_size': put(body, 'pdf.paper', String(v).toLowerCase()); break;
      case 'pdf_orientation': put(body, 'pdf.landscape', v === 'landscape'); break;
      case 'webhook_url': warnings.push('webhook_url: set the webhook in the dashboard, or pass webhook.url on POST /v1/batch'); break;
      case 'response_type': if (v === 'json') warnings.push('response_type=json: send Accept: application/json'); break;
      default: warnings.push(`${key} has no RenderScreenshot equivalent; dropped`);
    }
  }
  return { body, warnings };
}

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(JSON.stringify(fromUrlbox({ url: 'example.com', fullPage: true, retina: true, timeout: 45000, waitUntil: 'requestsfinished', hideCookieBanners: true, blockImages: true, blockFonts: true, useStealth: true }), null, 2));
}

The test file feeds it camelCase input, thumbnails, selectors, cookies, Basic auth and PDF options, and checks the exact output. All three tests passed.

Python

# urlbox_to_rs.py: convert Urlbox options (camelCase or snake_case) into a RenderScreenshot POST body.
# Python 3.9+, standard library only.
import base64
import re
from urllib.parse import urlparse

WAIT_UNTIL = {'domloaded': 'domcontentloaded', 'loaded': 'load',
              'requestsfinished': 'networkidle', 'mostrequestsfinished': 'networkidle'}
RESOURCE = {'block_images': 'image', 'block_fonts': 'font', 'block_medias': 'media',
            'block_scripts': 'script', 'block_styles': 'stylesheet'}
DIRECT = {
    'width': 'viewport.width', 'height': 'viewport.height', 'quality': 'output.quality',
    'transparent': 'output.transparent', 'thumb_width': 'output.resize.width',
    'thumb_height': 'output.resize.height', 'img_fit': 'output.resize.fit',
    'max_height': 'capture.scroll.max_height', 'wait_for': 'wait.for_selector', 'block_ads': 'block.ads',
    'dark_mode': 'browser.dark_mode', 'reduced_motion': 'browser.reduced_motion', 'media': 'browser.media',
    'user_agent': 'browser.user_agent', 'tz': 'browser.timezone', 'accept_lang': 'browser.locale',
    'ttl': 'cache.ttl', 'force': 'cache.refresh', 'pdf_background': 'pdf.print_background', 'pdf_margin': 'pdf.margin',
}
FORMATS = {'png', 'jpeg', 'webp', 'avif', 'pdf', 'html'}


def snake(key):
    return re.sub(r'[A-Z]', lambda m: '_' + m.group(0).lower(), key)


def as_list(value):
    return value if isinstance(value, list) else [s.strip() for s in str(value).split(',') if s.strip()]


def put(body, path, value):
    *parents, leaf = path.split('.')
    node = body
    for p in parents:
        node = node.setdefault(p, {})
    node[leaf] = value


def from_urlbox(options):
    opts = {snake(k): v for k, v in options.items()}
    body, warnings = {}, []
    if 'url' in opts and not opts['url'].startswith(('http://', 'https://')):
        opts['url'] = 'https://' + opts['url']  # Urlbox accepts bare hosts
    host = urlparse(opts['url']).hostname if 'url' in opts else None

    for key, v in opts.items():
        if key in DIRECT:
            put(body, DIRECT[key], v)
        elif key in RESOURCE:
            if v: body.setdefault('block', {}).setdefault('resources', []).append(RESOURCE[key])
        elif key in ('url', 'html'):
            body[key] = v
        elif key == 'format':
            f = 'jpeg' if v == 'jpg' else v
            if f in FORMATS: put(body, 'output.format', f)
            else: warnings.append(f'format {v} has no equivalent')
        elif key == 'full_page':
            if v: put(body, 'capture.mode', 'full_page')
        elif key == 'selector':
            put(body, 'capture.mode', 'element'); put(body, 'capture.selector', v)
        elif key == 'fail_if_selector_missing' and 'selector' in opts:
            put(body, 'capture.selector_required', bool(v))
        elif key == 'retina':
            if v: put(body, 'viewport.scale', 2)
        elif key == 'delay':
            put(body, 'wait.delay', min(int(v), 10_000))
        elif key == 'timeout':
            put(body, 'wait.timeout', min(-(-int(v) // 1000), 60))  # ms -> s, rounded up
        elif key == 'wait_until':
            put(body, 'wait.until', WAIT_UNTIL.get(v, 'load'))
        elif key in ('hide_cookie_banners', 'click_accept'):
            if v: put(body, 'block.cookie_banners', True)
        elif key == 'block_urls':
            put(body, 'block.requests', as_list(v))
        elif key == 'hide_selector':
            put(body, 'page.hide', as_list(v))
        elif key in ('css', 'js'):
            put(body, 'page.styles' if key == 'css' else 'page.scripts', [v])
        elif key == 'click':
            clicks = as_list(v)
            put(body, 'page.click', clicks[0])
            if len(clicks) > 1: warnings.append('only the first click carries over')
        elif key == 'header':
            put(body, 'network.headers', dict(h.split('=', 1) for h in as_list(v)))
        elif key == 'cookie':
            cookies = []
            for raw in (v if isinstance(v, list) else [v]):
                pair, *attrs = [s.strip() for s in raw.split(';')]
                name, _, value = pair.partition('=')
                domain = next((a[7:].lstrip('.') for a in attrs if a.lower().startswith('domain=')), host)
                cookies.append({'name': name, 'value': value, 'domain': domain})
            put(body, 'network.cookies', cookies)
        elif key == 'authorization':
            scheme, _, token = str(v).partition(' ')
            if scheme.lower() == 'bearer':
                put(body, 'network.auth', {'type': 'bearer', 'token': token})
            elif scheme.lower() == 'basic':
                user, _, password = base64.b64decode(token).decode().partition(':')
                put(body, 'network.auth', {'type': 'basic', 'username': user, 'password': password})
        elif key in ('latitude', 'longitude', 'accuracy'):
            put(body, f'browser.geolocation.{key}', float(v))
        elif key == 'pdf_page_size':
            put(body, 'pdf.paper', str(v).lower())
        elif key == 'pdf_orientation':
            put(body, 'pdf.landscape', v == 'landscape')
        else:
            warnings.append(f'{key} has no RenderScreenshot equivalent; dropped')
    return body, warnings


if __name__ == '__main__':
    import json
    body, warnings = from_urlbox({'url': 'example.com', 'fullPage': True, 'retina': True, 'timeout': 45000,
                                  'waitUntil': 'requestsfinished', 'hideCookieBanners': True,
                                  'blockImages': True, 'useStealth': True})
    print(json.dumps(body, indent=2)); print(warnings)
    assert body == {'url': 'https://example.com', 'capture': {'mode': 'full_page'}, 'viewport': {'scale': 2},
                    'wait': {'timeout': 45, 'until': 'networkidle'},
                    'block': {'cookie_banners': True, 'resources': ['image']}}
    b2, _ = from_urlbox({'url': 'https://app.example.com', 'hide_selector': '.a, #b', 'header': ['X-Preview=1'],
                         'cookie': 'session=abc;Domain=.example.com', 'thumbWidth': 400, 'imgFit': 'cover',
                         'pdfPageSize': 'A4', 'pdfOrientation': 'landscape'})
    assert b2['page'] == {'hide': ['.a', '#b']}
    assert b2['network'] == {'headers': {'X-Preview': '1'}, 'cookies': [{'name': 'session', 'value': 'abc', 'domain': 'example.com'}]}
    assert b2['output'] == {'resize': {'width': 400, 'fit': 'cover'}}
    assert b2['pdf'] == {'paper': 'a4', 'landscape': True}
    print('ok')

Ruby

This version covers the options most server-side integrations use and includes the send step. Anything else becomes a warning.

# urlbox_to_rs.rb: convert Urlbox options (camelCase or snake_case) into a RenderScreenshot POST body,
# then send it. Ruby 3.0+, standard library only. Covers the common options; others become warnings.
require 'json'
require 'net/http'

module UrlboxToRs
  WAIT_UNTIL = { 'domloaded' => 'domcontentloaded', 'loaded' => 'load',
                 'requestsfinished' => 'networkidle', 'mostrequestsfinished' => 'networkidle' }.freeze
  RESOURCE = { 'block_images' => 'image', 'block_fonts' => 'font', 'block_medias' => 'media',
               'block_scripts' => 'script', 'block_styles' => 'stylesheet' }.freeze
  DIRECT = { 'width' => 'viewport.width', 'height' => 'viewport.height', 'quality' => 'output.quality',
             'thumb_width' => 'output.resize.width', 'thumb_height' => 'output.resize.height',
             'img_fit' => 'output.resize.fit', 'max_height' => 'capture.scroll.max_height',
             'wait_for' => 'wait.for_selector', 'block_ads' => 'block.ads', 'dark_mode' => 'browser.dark_mode',
             'user_agent' => 'browser.user_agent', 'tz' => 'browser.timezone', 'ttl' => 'cache.ttl',
             'force' => 'cache.refresh' }.freeze

  module_function

  def put(body, path, value)
    *parents, leaf = path.split('.')
    parents.reduce(body) { |node, key| node[key] ||= {} }[leaf] = value
  end

  def convert(options)
    opts = options.to_h { |k, v| [k.to_s.gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" }, v] }
    body = {}
    warnings = []
    opts.each do |key, v|
      if DIRECT.key?(key) then put(body, DIRECT[key], v)
      elsif RESOURCE.key?(key) then ((body['block'] ||= {})['resources'] ||= []) << RESOURCE[key] if v
      else
        case key
        when 'url' then body['url'] = v.match?(%r{\Ahttps?://}) ? v : "https://#{v}"
        when 'format' then put(body, 'output.format', v == 'jpg' ? 'jpeg' : v)
        when 'full_page' then put(body, 'capture.mode', 'full_page') if v
        when 'retina' then put(body, 'viewport.scale', 2) if v
        when 'delay' then put(body, 'wait.delay', [v.to_i, 10_000].min)
        when 'timeout' then put(body, 'wait.timeout', [(v.to_i / 1000.0).ceil, 60].min) # ms -> s
        when 'wait_until' then put(body, 'wait.until', WAIT_UNTIL.fetch(v, 'load'))
        when 'hide_cookie_banners', 'click_accept' then put(body, 'block.cookie_banners', true) if v
        when 'block_urls' then put(body, 'block.requests', Array(v))
        when 'hide_selector' then put(body, 'page.hide', v.split(',').map(&:strip))
        when 'css' then put(body, 'page.styles', [v])
        when 'js' then put(body, 'page.scripts', [v])
        when 'header' then put(body, 'network.headers', Array(v).to_h { |h| h.split('=', 2) })
        else warnings << "#{key} has no RenderScreenshot equivalent; dropped"
        end
      end
    end
    [body, warnings]
  end

  def render(options, api_key: ENV.fetch('RS_API_KEY'), base: ENV.fetch('RS_API_BASE', 'https://api.renderscreenshot.com'))
    body, warnings = convert(options)
    warnings.each { |w| warn "warning: #{w}" }
    uri = URI("#{base}/v1/screenshot")
    res = Net::HTTP.post(uri, body.to_json, 'Authorization' => "Bearer #{api_key}", 'Content-Type' => 'application/json')
    raise "RenderScreenshot #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)

    res.body
  end
end

if $PROGRAM_NAME == __FILE__
  body, warnings = UrlboxToRs.convert(url: 'example.com', fullPage: true, retina: true, timeout: 45_000,
                                      waitUntil: 'requestsfinished', hideCookieBanners: true, blockImages: true, useStealth: true)
  expected = { 'url' => 'https://example.com', 'capture' => { 'mode' => 'full_page' }, 'viewport' => { 'scale' => 2 },
               'wait' => { 'timeout' => 45, 'until' => 'networkidle' },
               'block' => { 'cookie_banners' => true, 'resources' => ['image'] } }
  raise "mismatch: #{body}" unless body == expected && warnings.size == 1

  image = UrlboxToRs.render({ url: 'https://example.com', width: 1200, height: 630, format: 'png' })
  puts "ok, #{image.bytesize} bytes"
end

ruby urlbox_to_rs.rb checks the conversion, then renders one page. Against the mock API it printed ok, 70 bytes.

Replace the download step

With Urlbox you read renderUrl from the JSON and download it before it expires. With RenderScreenshot, keep the bytes from the first response, or keep the URL:

  • Need the file now (save to disk, attach to an email): use the response body.
  • Need a URL (store in a database, send to a browser): read X-Cache-URL, a public CDN link valid for the cache TTL. For a link that never depends on our cache, enable your own storage.
  • Need a link in HTML before any render exists: build a signed URL. It always has an expiry, 30 days at most.

SDK examples

The converted example request in each official SDK. Options without a builder method (page scripts, PDF margins, resize) go in the POST body.

Node.js

import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);
const image = await client.take(
  TakeOptions.url('https://example.com').width(1280).height(1024).scale(2)
    .fullPage().waitFor('networkidle').blockCookieBanners().blockResources(['image', 'font'])
);

Python

client = Client(os.environ['RS_API_KEY'])
image = client.take(
    TakeOptions.url('https://example.com').width(1280).height(1024).scale(2)
    .full_page().wait_for('networkidle').block_cookie_banners().block_resources(['image', 'font'])
)

Ruby

client = RenderScreenshot.client(ENV.fetch('RS_API_KEY'))
image = client.take(
  RenderScreenshot::TakeOptions.url('https://example.com').width(1280).height(1024).scale(2)
    .full_page.wait_for('networkidle').block_cookie_banners.block_resources(%w[image font])
)

PHP

$client = new RenderScreenshot\Client(getenv('RS_API_KEY'));
$image = $client->take(
    RenderScreenshot\TakeOptions::url('https://example.com')->width(1280)->height(1024)->scale(2)
        ->fullPage()->waitFor('networkidle')->blockCookieBanners()->blockResources(['image', 'font'])
);

Java

Client client = new Client(System.getenv("RS_API_KEY"));
byte[] image = client.take(
    TakeOptions.url("https://example.com").width(1280).height(1024).scale(2.0)
        .fullPage().waitFor("networkidle").blockCookieBanners()
        .blockResources(Arrays.asList("image", "font"))
);

Go

image, err := client.Take(ctx, rs.URL("https://example.com").Width(1280).Height(1024).Scale(2).
    FullPage().WaitFor(rs.WaitNetworkIdle).BlockCookieBanners().BlockResources([]string{"image", "font"}))

Node.js, Python, Ruby, PHP and Go ran against the mock API. Java follows the Java SDK reference and was not run.

Async renders and webhooks

Urlbox's async endpoint returns at once and posts the result to webhook_url. RenderScreenshot has two ways to do the same job:

  1. Many URLs: send them to POST /v1/batch with webhook.url in the body. You get a batch.completed event with a results_url. See bulk screenshots.
  2. Every screenshot: set one webhook URL in Dashboard, Webhooks. Each render then also sends screenshot.completed or screenshot.failed. Single requests still answer synchronously.

Both are signed with HMAC-SHA256 in the X-Webhook-Signature header. The verification code is in async screenshots and webhooks.

Troubleshooting

Symptom Likely cause Fix
Renders fail with a timeout error long after you expected Urlbox milliseconds copied into wait.timeout, which is seconds Divide by 1,000; 60 at most
Captures happen earlier than on Urlbox wait_until value not recognized and fell back to load Map requestsfinished to networkidle
invalid_url for example.com Urlbox accepts bare hosts; we need a scheme Prefix https://
Cookie banner still visible Urlbox click_accept worked on a banner ours misses Add page.click with the accept button selector. See block ads and cookie banners
Only the first of several clicks happens page.click takes one selector Do the extra clicks in page.scripts
Cached images expire after a day Urlbox default TTL is 30 days, ours 24 hours Set cache.ttl up to 2,592,000
JSON parsing fails on the response Code expects Urlbox's JSON default Send Accept: application/json, or treat the body as image bytes
Thumbnails are cropped differently img_fit not passed Set output.resize.fit to the same value

Related in this topic

Was this page helpful?