To customize a website before the screenshot, send a page group in a POST /v1/screenshot request: styles to inject CSS, scripts to run JavaScript, click to press one element, hide to make elements invisible and remove to delete them. The one thing the reference does not tell you is the order, and it decides whether a recipe works: the renderer loads the page, waits, clicks, runs your scripts, adds your styles, hides, removes, and captures straight away. This guide explains that order and gives recipes that were checked in a browser. It is part of the guides; the option reference is page modifications.
Tested on October 15, 2026: each recipe replayed in headless Chrome (Puppeteer 24.43.1, Node.js 22.18.0) on a local test page, in the renderer's order, and the request sent from Node.js, Python, Ruby, PHP and Go to a local mock of the RenderScreenshot API. The order below is taken from the renderer's source code. We have not yet published before-and-after renders from the live API for these recipes.
The five tools
| Option | What the renderer does | Use it for |
|---|---|---|
page.click |
Clicks the first element that matches, then waits 300 ms | Tabs, toggles, "show more" buttons |
page.scripts |
Runs each string in the page, in order; waits for a returned promise. { "url": ... } adds a script tag |
Opening sections, setting state, waiting for content |
page.styles |
Adds each string as a <style> tag; { "url": ... } adds a stylesheet |
Freezing animations, masking data, layout fixes |
page.hide |
Sets visibility: hidden on every match |
Hiding something without moving the layout |
page.remove |
Deletes every match from the page | Popups, promos, anything that should leave no gap |
page.click_required: true fails the capture when the click target is missing; the default is to carry on without the click.
The order of operations
- Load the URL and wait for
wait.until(loadby default). - Wait
wait.delaymilliseconds, if set (10,000 at most). - Built-in blocking clean-up: hide common ad containers (
block.ads), then dismiss cookie banners (block.cookie_banners). page.click, then 300 ms.page.scripts, one by one, each awaited.page.styles.page.hide.page.remove.- Capture.
Three consequences:
wait.delaycannot wait for a click. It runs before the click. To wait after a click, use a script (recipe 3).- Styles land after scripts. A script that measures layout sees the page without your CSS.
- Nothing waits after the last step. Removing an element that triggers an animation on its neighbours captures the first frame of that animation; freeze animations with
page.styles(recipe 1).
The minimal request
Page changes need a POST body; they have no GET query parameters.
curl -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" -H "Content-Type: application/json" \ -d @body.json --output pricing.png
With body.json:
{ "url": "https://example.com/pricing", "page": { "click": "#show-yearly", "scripts": ["document.querySelectorAll('details').forEach((d) => { d.open = true; })"], "styles": ["*, *::before, *::after { animation: none !important; transition: none !important; }"], "hide": [".user-email"], "remove": ["#newsletter-modal"] } }
For a plain capture of the same page without changes, GET /v1/screenshot?url=https%3A%2F%2Fexample.com%2Fpricing is enough.
Recipes
1. Freeze animations and the text cursor
Carousels, spinners and fade-ins make two captures of the same page differ. Stop them:
{ "page": { "styles": ["*, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; }"] } }
In the test page a spinning element reported animation-name: none after this style. browser.reduced_motion: true also helps on sites that respect prefers-reduced-motion, but many do not.
2. Open accordions and <details>
{ "page": { "scripts": ["document.querySelectorAll('details').forEach((d) => { d.open = true; })"] } }
For JavaScript accordions, click their toggles in a script: document.querySelectorAll('.faq-toggle').forEach((b) => b.click()). A script click does not depend on where the element is on screen.
3. Click, then wait for the result
The test page loads a new price 800 ms after its "Yearly" button is clicked. With page.click alone, the capture still showed $10/mo: the renderer had waited its 300 ms and moved on. Adding a script that returns a promise fixed it, and the capture showed $96/yr:
{ "page": { "click": "#show-yearly", "scripts": [ "new Promise((resolve, reject) => { const t0 = Date.now(); (function poll() { if (document.querySelector('#price.loaded')) return resolve(); if (Date.now() - t0 > 5000) return reject(new Error('#price.loaded not found')); setTimeout(poll, 100); })(); })" ] } }
Always give the promise a timeout, as above. A rejected promise fails the capture with an error, which costs no credits; a promise that never settles holds the request open until the API stops waiting for the renderer, about 60 seconds, and then fails.
If the button sits under an overlay, page.click clicks the overlay instead, because a click lands on whatever is on top at that point. In our test that click changed nothing. Click from a script instead: document.querySelector('#show-yearly').click().
4. Hide or remove
On the test page, a 200 px promo block above the footer:
| Option | Promo | Footer position |
|---|---|---|
| none | visible | 327 px from the top |
page.hide: ["#promo"] |
invisible | 327 px (space kept) |
page.remove: ["#promo"] |
gone | 127 px (moved up 200 px) |
Use hide when the gap is acceptable or other elements are positioned against the hidden one; use remove for overlays, modals and anything in the flow you want gone.
5. Mask personal data
{ "page": { "styles": [".user-email, .account-name, [data-private] { filter: blur(6px) !important; }"] } }
Blur keeps the layout and makes it clear something was there. For data that must not be recoverable from the image, prefer page.hide or replace the text with a script: blur at small radii can sometimes be read.
6. Stable content for visual comparisons
Times, counters and random content make every capture different. Replace them before capture:
{ "page": { "scripts": [ "document.querySelectorAll('time, .relative-date').forEach((el) => { el.textContent = '12:00 PM'; })", "document.querySelectorAll('[data-random], .ad-rotator').forEach((el) => el.remove())" ] } }
Pair this with recipe 1 and with browser.timezone so the page itself renders dates the same way every time.
7. Reuse one stylesheet across many pages
When the same fixes apply to a whole site, host them once:
{ "page": { "styles": [{ "url": "https://example.com/screenshot-fixes.css" }], "scripts": [{ "url": "https://example.com/screenshot-prep.js" }] } }
A script added by URL is loaded as a script tag. The renderer waits for it to load, but not for any work it starts afterwards; put waits in an inline script after it.
Send the request from your language
None of the SDKs documents builder methods for the page group, so send the JSON body directly. Each example reads body.json from above and writes pricing.png.
Node.js
import { readFile, writeFile } from 'node:fs/promises'; const API = process.env.RS_API_BASE ?? 'https://api.renderscreenshot.com'; const body = JSON.parse(await readFile('body.json', 'utf8')); const res = await fetch(`${API}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); await writeFile('pricing.png', Buffer.from(await res.arrayBuffer()));
Python
import json, os import httpx body = json.load(open('body.json')) res = httpx.post(f"{os.environ.get('RS_API_BASE', 'https://api.renderscreenshot.com')}/v1/screenshot", headers={'Authorization': f"Bearer {os.environ['RS_API_KEY']}"}, json=body, timeout=70) res.raise_for_status() open('pricing.png', 'wb').write(res.content)
Ruby
require 'json' require 'net/http' body = File.read('body.json') uri = URI("#{ENV.fetch('RS_API_BASE', 'https://api.renderscreenshot.com')}/v1/screenshot") res = Net::HTTP.post(uri, body, 'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}", 'Content-Type' => 'application/json') raise "#{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess) File.binwrite('pricing.png', res.body)
PHP
<?php $body = file_get_contents('body.json'); $ch = curl_init((getenv('RS_API_BASE') ?: 'https://api.renderscreenshot.com') . '/v1/screenshot'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('RS_API_KEY'), 'Content-Type: application/json'], ]); $image = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($status !== 200) throw new RuntimeException("HTTP $status: $image"); file_put_contents('pricing.png', $image);
Java
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.renderscreenshot.com/v1/screenshot")) .header("Authorization", "Bearer " + System.getenv("RS_API_KEY")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofFile(Path.of("body.json"))) .build(); HttpResponse<byte[]> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() != 200) throw new IllegalStateException(new String(response.body())); Files.write(Path.of("pricing.png"), response.body());
Go
package main import ( "bytes" "fmt" "io" "log" "net/http" "os" ) func main() { body, err := os.ReadFile("body.json") if err != nil { log.Fatal(err) } base := os.Getenv("RS_API_BASE") if base == "" { base = "https://api.renderscreenshot.com" } req, _ := http.NewRequest("POST", base+"/v1/screenshot", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("RS_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() image, _ := io.ReadAll(res.Body) if res.StatusCode != http.StatusOK { log.Fatalf("HTTP %d: %s", res.StatusCode, image) } if err := os.WriteFile("pricing.png", image, 0o644); err != nil { log.Fatal(err) } fmt.Println("saved pricing.png") }
The Node.js, Python, Ruby, PHP and Go versions sent the body to the mock API and saved the file. The Java version was not run.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Click had no visible effect | Content loads after the 300 ms wait | Recipe 3: wait in a script |
| Click had no effect at all | An overlay covers the target | Click from a script, or remove the overlay with a script before clicking |
| Capture fails with "Click selector not found" | click_required: true and the selector is missing |
Check the selector on the live page; leave click_required false for mixed URLs |
| Your CSS seems ignored | The site's rule is more specific or uses !important |
Add !important and a more specific selector |
| Script changes are undone | The page re-renders (framework hydration) after your script | Wait for the app to settle in the script first, or target the final DOM |
| Capture fails with a script error | The script throws or its promise rejects | Test it in the browser console on the live URL |
| Gaps where elements were | Used hide |
Use remove |
| Request hangs until timeout | A script promise that never settles | Add a timeout to every promise |
Related parameters
- Page modifications: the reference for every
pageoption. - Block ads and cookie banners: built-in clean-up that runs before your changes.
- Wait options:
until,delayandtimeout. - Authenticated pages: mask account data in logged-in captures.
- Try a recipe with a free API key.