Some sites decide that the renderer is a bot and show a challenge page instead of their content. They are right: it is a browser run by software. You will make those captures fail so they cost nothing, and, for sites you own or run for a client, let the renderer through.
RenderScreenshot does not solve CAPTCHAs and does not disguise the browser. The site owner decides who gets in. A custom user agent does not change that.
Recognise a blocked capture
The image at the top of this guide is a real capture: the request succeeded, so it used a credit, but the image shows a block message. Signs to look for:
| You get | Meaning | Credit used |
|---|---|---|
| An image of "Access denied", "Verify you are human" or a blank challenge | The browser loaded the challenge page | Yes |
Error blocked (422) |
The site refused the request | No |
Error timeout (422) |
The challenge never finished | No |
Retrying does not help: the next attempt is a new browser with the same signals.
Make blocked captures fail for free
Turn the first row into an error. A failed request uses no credits and leaves no useless image in your storage.
Check the text. List the phrases of the block pages you get. The request fails when the page contains one of them:
{ "url": "https://www.reddit.com", "options": { "fail_if_contains": ["blocked by network security", "Access denied", "Verify you are human"] } }
The real response for the page above:
{ "error": { "type": "target_error", "code": "content_check_failed", "message": "Forbidden text found: 'blocked by network security'", "retryable": true }, "request_id": "req_a53b5f66a8d44ca2" }
Matching is case-sensitive, so copy the phrases exactly. fail_if_missing does the opposite: the request fails when the page does not contain a phrase, for example ["Add to cart"] on a product page.
Or wait for real content. Wait for an element that only the real page has. A challenge page never shows it, so the request fails after the timeout:
{ "url": "https://shop.example.com/product/42", "wait": { "for_selector": "[data-product-id]", "timeout": 20 } }
In your code, treat content_check_failed and blocked as "do not retry", even though retryable is true. One retry at most.
Let the renderer through on your own site
If the site is yours, or you capture it for its owner, pick one of these. They go from the most specific to the broadest.
Option A: send your own HTML. For images of your own content (cards, reports, invoices), render the HTML in your app and send it with html. No request reaches your bot protection:
{ "html": "<html><body><h1>Invoice #1042</h1>...</body></html>", "viewport": { "width": 1200, "height": 630 } }
Option B: a secret header. Send a long random token in a header, and skip your bot check when it matches:
{ "url": "https://www.example.com/pricing", "network": { "headers": { "X-Screenshot-Token": "<32+ random characters>" } } }
On your server or edge function, compare it in constant time:
import { timingSafeEqual } from 'node:crypto'; const expected = Buffer.from(process.env.SCREENSHOT_BYPASS_TOKEN ?? ''); export function isTrustedScreenshot(req) { if (expected.length < 32) return false; const got = Buffer.from(String(req.headers['x-screenshot-token'] ?? '')); return got.length === expected.length && timingSafeEqual(got, expected); }
Give the token no power except skipping the challenge, and rotate it.
Option C: a rule in your bot or firewall product. Most products can skip challenges for traffic that matches a rule. For sites behind Cloudflare, the Cloudflare guide shows the exact rule for each plan. Do not allowlist IP addresses: we do not publish a fixed range.
Next steps
- Fix blank or incomplete screenshots for pages that are slow, not blocked.
- Prepare for production: which errors to retry.
- Content checks has more patterns.