To take bulk screenshots, send your URLs to POST /v1/batch in groups no larger than your plan's batch size, wait for each batch to finish (inline for 10 URLs or fewer, by polling or webhook above that), and send the failed URLs again once. This guide gives the limits per plan, explains which mode you get and why, and includes a tested runner in Node.js and Python that turns a file of URLs into a CSV of image links. It is part of the guides; the request and response fields are in the batch endpoint reference.
Tested on October 15, 2026, with Node.js 22.18.0 and Python 3.14.8 (httpx 0.28.1): both runners against a local mock of the RenderScreenshot API that follows the batch rules on this page, with a list of 25 URLs that included 2 failing pages. SDK examples ran in Node.js, Python, Ruby, PHP and Go against the same mock; Java was not run.
Limits per plan
| Plan | URLs per batch | Items rendered in parallel | Requests per minute |
|---|---|---|---|
| Free | 5 | 2 | 10 |
| Starter | 20 | 5 | 60 |
| Growth | 50 | 10 | 120 |
| Scale | 100 | 20 | 200 |
A batch larger than your limit is rejected with 403 and the code batch_size_exceeded before anything renders. Each completed item costs 1 credit and failed items cost nothing, but the API checks up front that your remaining credits cover the number of URLs; if not, it returns insufficient_credits. Plans and prices are on the pricing page.
Sync or async: which one you get
| Condition | Mode | Response |
|---|---|---|
| 10 URLs or fewer, no webhook | Synchronous | 200 with every result |
| More than 10 URLs | Asynchronous | 202 with an id; poll GET /v1/batch/:id |
webhook.url in the body, or a webhook set in the dashboard |
Asynchronous | 202, then a batch.completed event |
A dashboard webhook makes every batch asynchronous, including small ones. Code that expects inline results from a 5-URL batch must handle 202 once someone sets that webhook. The webhooks guide covers the events and signature checks.
The two requests
Start a batch. The same options apply to every URL:
curl -X POST https://api.renderscreenshot.com/v1/batch \ -H "Authorization: Bearer $RS_API_KEY" -H "Content-Type: application/json" \ -d '{ "urls": ["https://example.com/", "https://example.org/", "https://example.net/"], "options": { "preset": "og_card", "block": { "cookie_banners": true, "ads": true } } }'
Check an async batch:
curl https://api.renderscreenshot.com/v1/batch/batch_abc123 -H "Authorization: Bearer $RS_API_KEY"
While it runs, the response has status: "processing", completed, failed and progress (a percentage). When it ends, it has the same shape as a synchronous result: top-level total, completed, failed and credits_used, and a results array. Each result has position, url, status, image (with image_url, width, height, size, format) or an error message, and response_time_ms.
For different options per URL, send requests instead of urls:
{ "requests": [ { "url": "https://example.com/", "preset": "og_card" }, { "url": "https://example.com/pricing", "preset": "full_page" }, { "url": "https://example.com/blog", "viewport": { "width": 1920 }, "browser": { "dark_mode": true } } ] }
Options a batch accepts
Batch items accept preset and these groups: viewport; capture (mode, selector, selector_required, beyond_viewport); output (format, quality, transparent, filename); wait (until, delay, for_selector, timeout); block (ads, trackers, cookie_banners, chat_widgets); browser (dark_mode, reduced_motion, media, user_agent); and cache (enabled, ttl).
Not available in a batch: page.* (scripts, styles, click, hide, remove), network.* (cookies, headers, auth), block.requests, block.resources, capture.scroll, capture.clip and output.resize. If you need those, send individual POST /v1/screenshot requests, with no more requests in flight than your plan's concurrent limit.
A runner for any list size
This script reads urls.txt, splits it by your batch size, waits for each batch, retries the failures once in a new batch, and writes results.csv. It handles 429 with Retry-After and exits with status 2 if any URL still failed.
Node.js
// bulk.mjs: screenshot a list of URLs with POST /v1/batch, poll async batches, retry failures once, // and write results.csv. Node 22+, no dependencies. // Usage: RS_API_KEY=rs_live_... BATCH_SIZE=20 node bulk.mjs urls.txt import { readFile, writeFile } from 'node:fs/promises'; const API = process.env.RS_API_BASE ?? 'https://api.renderscreenshot.com'; const BATCH_SIZE = Number(process.env.BATCH_SIZE ?? 5); // your plan's batch size: 5, 20, 50 or 100 const OPTIONS = { preset: 'og_card', block: { cookie_banners: true, ads: true } }; const headers = { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function call(method, path, body) { for (let attempt = 0; attempt < 5; attempt++) { const res = await fetch(`${API}${path}`, { method, headers, body: body && JSON.stringify(body) }); if (res.status === 429) { await sleep(Number(res.headers.get('retry-after') ?? 30) * 1000); continue; } const json = await res.json(); if (!res.ok) throw Object.assign(new Error(json.error?.message ?? res.statusText), { code: json.error?.code, status: res.status }); return json; } throw new Error(`${method} ${path}: still rate limited after 5 attempts`); } async function runBatch(urls) { let batch = await call('POST', '/v1/batch', { urls, options: OPTIONS }); let wait = 2000; while (batch.status === 'processing' || batch.status === 'pending') { // async: more than 10 URLs, or a webhook is set await sleep(wait); wait = Math.min(wait * 1.5, 15_000); batch = await call('GET', `/v1/batch/${batch.id}`); console.log(`${batch.id}: ${batch.completed ?? 0} done, ${batch.failed ?? 0} failed of ${batch.total}`); } return batch.results; } const urls = (await readFile(process.argv[2] ?? 'urls.txt', 'utf8')).split('\n').map((s) => s.trim()).filter(Boolean); const results = new Map(); for (let i = 0; i < urls.length; i += BATCH_SIZE) { for (const r of await runBatch(urls.slice(i, i + BATCH_SIZE))) results.set(r.url, r); } const failed = [...results.values()].filter((r) => r.status === 'failed').map((r) => r.url); if (failed.length) { console.log(`retrying ${failed.length} failed URL(s) once`); for (let i = 0; i < failed.length; i += BATCH_SIZE) { for (const r of await runBatch(failed.slice(i, i + BATCH_SIZE))) results.set(r.url, r); } } const csv = ['url,status,image_url,error', ...urls.map((u) => { const r = results.get(u); return [u, r.status, r.image?.image_url ?? '', JSON.stringify(r.error ?? '')].join(','); })]; await writeFile('results.csv', csv.join('\n') + '\n'); const ok = urls.filter((u) => results.get(u).status === 'completed').length; console.log(`${ok}/${urls.length} captured; see results.csv`); process.exitCode = ok === urls.length ? 0 : 2;
Run against the mock with BATCH_SIZE=20 and 25 URLs, it sent one async batch of 20 (two polls) and one synchronous batch of 5, retried the 2 failed URLs once, and printed 23/25 captured; see results.csv.
Python
# bulk.py: the same batch runner in Python 3.9+. pip install httpx import csv import os import sys import time import httpx API = os.environ.get('RS_API_BASE', 'https://api.renderscreenshot.com') BATCH_SIZE = int(os.environ.get('BATCH_SIZE', '5')) OPTIONS = {'preset': 'og_card', 'block': {'cookie_banners': True, 'ads': True}} http = httpx.Client(base_url=API, timeout=120, headers={'Authorization': f"Bearer {os.environ['RS_API_KEY']}"}) def call(method, path, body=None): for _ in range(5): res = http.request(method, path, json=body) if res.status_code == 429: time.sleep(int(res.headers.get('retry-after', '30'))) continue res.raise_for_status() return res.json() raise RuntimeError(f'{method} {path}: still rate limited') def run_batch(urls): batch = call('POST', '/v1/batch', {'urls': urls, 'options': OPTIONS}) wait = 2.0 while batch['status'] in ('processing', 'pending'): time.sleep(wait) wait = min(wait * 1.5, 15) batch = call('GET', f"/v1/batch/{batch['id']}") return batch['results'] def run(urls): results = {} for i in range(0, len(urls), BATCH_SIZE): results.update({r['url']: r for r in run_batch(urls[i:i + BATCH_SIZE])}) failed = [u for u, r in results.items() if r['status'] == 'failed'] for i in range(0, len(failed), BATCH_SIZE): # one retry pass results.update({r['url']: r for r in run_batch(failed[i:i + BATCH_SIZE])}) return results if __name__ == '__main__': urls = [line.strip() for line in open(sys.argv[1] if len(sys.argv) > 1 else 'urls.txt') if line.strip()] results = run(urls) with open('results.csv', 'w', newline='') as f: w = csv.writer(f) w.writerow(['url', 'status', 'image_url', 'error']) for u in urls: r = results[u] w.writerow([u, r['status'], (r.get('image') or {}).get('image_url', ''), r.get('error') or '']) ok = sum(results[u]['status'] == 'completed' for u in urls) print(f'{ok}/{len(urls)} captured') sys.exit(0 if ok == len(urls) else 2)
Same input, same result: 23/25 captured, exit status 2.
Partial failures
A batch result is per item. One slow page never fails the others, so always read status per row:
faileditems carry anerrorstring such asPage failed to load within 30 seconds. Retry them once; a second failure usually means the page needs different settings (a longerwait.timeout, await.for_selector), not another retry.- Track failures by URL, not by position, when you re-batch them: positions restart at 0 in the new batch.
- A batch whose processing failed as a whole ends with
status: "failed"and, if a webhook is set, abatch.failedevent. Resubmit the URLs that did not complete.
SDK examples
batch sends the same options for every URL; getBatch checks an async batch.
Node.js
import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); let batch = await client.batch(urls, TakeOptions.url('').preset('og_card')); while (batch.status === 'processing') { await new Promise((r) => setTimeout(r, 2000)); batch = await client.getBatch(batch.id); }
Python
batch = client.batch(urls, TakeOptions.url('').preset('og_card')) while batch['status'] == 'processing': time.sleep(2) batch = client.get_batch(batch['id'])
Ruby
batch = client.batch(urls, options: RenderScreenshot::TakeOptions.url('').preset('og_card')) while batch['status'] == 'processing' sleep 2 batch = client.get_batch(batch['id']) end
PHP
$batch = $client->batch($urls, TakeOptions::url('')->preset('og_card')); while ($batch['status'] === 'processing') { sleep(2); $batch = $client->getBatch($batch['id']); }
Java
BatchResponse batch = client.batch(urls, TakeOptions.url("").preset("og_card")); while ("processing".equals(batch.getStatus())) { Thread.sleep(2000); batch = client.getBatch(batch.getId()); }
Go
batch, err := client.Batch(ctx, urls, rs.URL("").Preset("og_card")) for err == nil && batch.Status == "processing" { time.Sleep(2 * time.Second) batch, err = client.GetBatch(ctx, batch.ID) }
Polling or webhooks
| Polling | Webhook | |
|---|---|---|
| Best for | Scripts, cron jobs, CI | Web apps, queues |
| Needs a public endpoint | No | Yes |
| Latency after the batch ends | Up to one poll interval | Seconds |
| Failure mode | Your process dies while waiting | Endpoint down longer than the retries last |
| Backstop | None needed | Poll batches you started but never heard about |
Poll every 2 seconds at first and back off to 10 or 15 seconds; each poll counts against your requests per minute.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
403 batch_size_exceeded |
More URLs than your plan's batch size | Split the list; set BATCH_SIZE in the runner |
403 insufficient_credits at submit |
Remaining credits below the URL count | Smaller batches, or a larger plan |
202 for a batch of 3 URLs |
A dashboard webhook is set | Handle async, or pass the batch to your webhook flow |
429 too_many_requests while polling |
Polling too often across many batches | Back off; honor Retry-After |
| Cookies or page scripts ignored | network.* and page.* are not batch options |
Use single requests for those URLs |
| Same URL twice, one result | Results map by URL in your code | Key by position within a batch |
| Many items fail with timeouts | Heavy pages and a short wait.timeout |
Raise wait.timeout (60 at most) or add wait.for_selector |
Related
- Batch endpoint: every field of the request and response.
- Async screenshots and webhooks: events, signatures and retries.
- Credits and billing: what a batch costs.
- Rate limits: handling
429. - Run the runner on a short list with a free API key.