Bot protection and CAPTCHAs

Some captures come back showing "Verify you are human", "Access denied" or a blank challenge page instead of the site. The target site's bot protection decided that our browser is a bot, and it is right: it is. This guide explains our policy, what these cases look like in the API, how to stop paying for them, and how site owners can let screenshots through. It is part of the troubleshooting guides.

Our policy

  • We do not solve CAPTCHAs and do not send pages to CAPTCHA-solving services.
  • We do not disguise the browser. There is no stealth mode that hides automation, and setting browser.user_agent does not make a request look human.
  • The site owner decides. If a site blocks automated browsers, the supported route is that the owner, or someone acting for the owner, allows our requests through. The rest of this guide shows how.

We think this is the right line for a service that anyone can point at any URL. It also makes results predictable: a site that lets the renderer through does so on every request, which no evasion technique can promise.

Why the renderer is identified as a bot

Captures run in Cloudflare's browser rendering service, Browser Run. Cloudflare documents that it adds non-configurable headers to every request from that service, including cf-brapi-devtools and cf-biso-devtools, and that the requests are signed with Web Bot Auth (Signature, Signature-Input and Signature-agent headers). It also states that a custom user agent "does not bypass bot protection" and that requests from the service are always identified as a bot. Sources: Cloudflare: automatic request headers and Cloudflare: JSON endpoint, custom user agent, both checked on 2026-10-15.

On top of that, every capture starts a fresh browser session with no cookies and no history. A bot product that scores visitors by reputation sees a new visitor every time.

What it looks like in the API

What you see HTTP What happened Credit used
An image of a challenge, "Access denied" or an error page 200 The browser loaded the challenge page and captured it Yes
target_error with a timeout message 422 The challenge never finished, so the wait condition was never met No
target_error with code blocked 422 The site refused the request No
target_error with content_check_failed 422 Your content check found challenge text or missed the expected text No

The first row is the expensive one: the request "succeeds", you pay a credit and you store a useless image. The next section turns it into a failure.

Retrying does not help with bot protection. The second attempt is a new browser with the same signals, and it usually meets the same challenge. Cap these retries at one, as described in error handling and retries.

Make a challenge page fail instead of succeed

Two documented ways, which you can combine. Try them on a page you know is protected with a free account before you roll them out.

Wait for real content. Wait for an element that only the real page has. A challenge page never shows it, so the request fails when wait.timeout runs out, with no image and no credit:

{
  "url": "https://shop.example.com/product/42",
  "wait": {
    "for_selector": "[data-product-id]",
    "timeout": 20
  }
}

Check the text. options.fail_if_contains fails the request when the page contains any of the strings, and fail_if_missing when an expected string is absent:

{
  "url": "https://shop.example.com/product/42",
  "options": {
    "fail_if_contains": ["Verify you are human", "Access denied", "unusual traffic"],
    "fail_if_missing": ["Add to cart"]
  }
}

Matching is case-sensitive. Collect the exact phrases from the challenge pages you actually receive. The content checks guide has more patterns.

For site owners: let screenshots through

If the site is yours, or you capture it on the owner's behalf, use one of these approaches. They are ordered from the most specific to the broadest.

Option 1: render the content yourself and send HTML

If you only need your own content as an image (social cards, reports, invoices), skip the public page. Render the HTML in your application and send it with the html parameter, as shown in HTML to image. No request reaches your bot protection at all.

Option 2: a shared secret header

Send a header with a long random token, and skip your bot check when the token matches. In the API request:

{
  "url": "https://www.example.com/pricing",
  "network": {
    "headers": { "X-Screenshot-Token": "<32+ random characters from your secret store>" }
  }
}

On your server, or in an edge function in front of it, compare the token in constant time:

// trusted-screenshot.mjs: skip your own bot challenge for requests that carry your token.
import { timingSafeEqual } from 'node:crypto';

const expected = Buffer.from(process.env.SCREENSHOT_BYPASS_TOKEN ?? '');

export function isTrustedScreenshot(req) {
  if (expected.length < 32) return false; // refuse to run with a short or missing token
  const got = Buffer.from(String(req.headers['x-screenshot-token'] ?? ''));
  return got.length === expected.length && timingSafeEqual(got, expected);
}

Tested on 2026-10-15 with Node 22.18: the correct 32-character token returned true; a longer token, a changed character, a missing header and a short configured token all returned false.

Know the trade-off before you use it. Custom headers can be sent with every request the page makes, including requests to third-party hosts such as analytics or font servers. Treat the token as low-value: give it no power except skipping the challenge, rotate it regularly, and do not reuse it anywhere else.

Option 3: a rule in your bot or WAF product

Most bot products let you skip challenges for traffic that matches a rule. You can match on the headers that Cloudflare adds to Browser Run requests, or verify their Web Bot Auth signature. This is the broadest option: those headers mark all Browser Run traffic, not only RenderScreenshot, so combine the rule with a path or host condition. For sites behind Cloudflare, the Cloudflare guide shows the exact rule.

Do not allowlist by IP address. We do not publish a fixed address range for captures, and the headers or the signature identify the traffic more reliably.

SDK examples: send the token and require real content

Node.js

import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);

const image = await client.take(
  TakeOptions.url('https://www.example.com/pricing')
    .headers({ 'X-Screenshot-Token': process.env.SCREENSHOT_BYPASS_TOKEN })
    .waitForSelector('main [data-plan]')
    .waitForTimeout(20000)
);

Python

import os
from renderscreenshot import Client, TakeOptions

client = Client(os.environ['RS_API_KEY'])

image = client.take(
    TakeOptions.url('https://www.example.com/pricing')
    .headers({'X-Screenshot-Token': os.environ['SCREENSHOT_BYPASS_TOKEN']})
    .wait_for_selector('main [data-plan]')
    .wait_for_timeout(20000)
)

Ruby

require 'renderscreenshot'

client = RenderScreenshot.client(ENV.fetch('RS_API_KEY'))

image = client.take(
  RenderScreenshot::TakeOptions
    .url('https://www.example.com/pricing')
    .headers({ 'X-Screenshot-Token' => ENV.fetch('SCREENSHOT_BYPASS_TOKEN') })
    .wait_for_selector('main [data-plan]')
    .wait_for_timeout(20_000)
)

PHP

use RenderScreenshot\Client;
use RenderScreenshot\TakeOptions;

$client = new Client(getenv('RS_API_KEY'));

$image = $client->take(
    TakeOptions::url('https://www.example.com/pricing')
        ->headers(['X-Screenshot-Token' => getenv('SCREENSHOT_BYPASS_TOKEN')])
        ->waitForSelector('main [data-plan]')
        ->waitForTimeout(20000)
);

Java

Map<String, String> headers = Map.of("X-Screenshot-Token", System.getenv("SCREENSHOT_BYPASS_TOKEN"));

byte[] image = client.take(
    TakeOptions.url("https://www.example.com/pricing")
        .headers(headers)
        .waitForSelector("main [data-plan]")
        .waitForTimeout(20000)
);

Go

image, err := client.Take(ctx,
    rs.URL("https://www.example.com/pricing").
        Headers(map[string]string{"X-Screenshot-Token": os.Getenv("SCREENSHOT_BYPASS_TOKEN")}).
        WaitForSelector("main [data-plan]").
        WaitForTimeout(20000),
)

Troubleshooting

Symptom Likely cause Fix
Image shows "Verify you are human" or a spinner on a blank page Bot challenge Site owners: options above. Others: the page cannot be captured without the owner's permission
Image shows "Access denied" or a 403 page The site blocks automated browsers outright Same as above; add fail_if_contains so it stops using credits
Captures work sometimes and fail other times The site scores each request, and some pass Do not rely on it; make challenge pages fail and ask the owner for access
Your own site challenges the renderer after a security change A new bot rule or a higher sensitivity Add the token or WAF rule, then test with a capture
The token header works on your page but a widget breaks The widget's host rejects the unknown header Skip the header for that host, or use the WAF rule instead
Many 422 timeouts on one domain Challenge pages never reach the wait condition Expected when you wait for real content; stop retrying that domain

Related in this topic

Was this page helpful?