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_agentdoes 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
- Screenshots of sites behind Cloudflare.
- Network parameters for custom headers and cookies.
- Content checks and wait strategies.