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:

  • failed items carry an error string such as Page failed to load within 30 seconds. Retry them once; a second failure usually means the page needs different settings (a longer wait.timeout, a wait.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, a batch.failed event. 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 in this topic

Was this page helpful?