DEV Community

Dan E
Dan E

Posted on • Originally published at rendex.dev

Bulk Screenshot and PDF Generation with a Batch API

Bulk Screenshot and PDF Generation with a Batch API

Firing 200 separate screenshot requests one at a time works fine in a script. It falls apart in production: rate limits kick in, error handling gets messy, and you have no way to track overall progress. The batch screenshot API solves this with a single request: submit up to 500 URLs, get a batch ID back, then poll or receive a webhook when all jobs finish.

This guide covers submitting a batch, polling for results, fanning out with a webhook, and knowing when batch beats firing N single requests.

When to Use Batch vs. Single Requests

Single requests are fine for on-demand captures (a user clicks "screenshot", you capture, you display). Batch is the right choice when:

  • You have a fixed list of URLs to capture in one job (site audit, competitor scan, monitoring baseline).
  • You want a single webhook callback when all jobs are done rather than N separate callbacks.
  • You need a shared progress indicator (34 of 48 complete) without building your own state machine.

Batch is URL-only. If you need to render raw HTML payloads, fire those as async single requests with async: true using the REST API.

Plan Limits

Batch size caps are per request, not per day. You can fire multiple batches.

Plan Max URLs per batch
Free 5
Starter 25
Pro 100
Enterprise 500

Credits are charged for the whole batch up front at submission time. If the batch fails before any jobs run, the charge is refunded automatically.

Step 1: Submit a Batch

The batch screenshot API accepts a urls array and a shared defaults object for capture settings. Settings in defaults apply to every URL in the batch. Source-type fields (url, html, markdown) are not allowed in defaults and return a 400 if present.

curl -X POST https://api.rendex.dev/v1/screenshot/batch \
  -H "Authorization: Bearer rdx_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "urls": [
      "https://example.com",
      "https://example.com/pricing",
      "https://example.com/about"
    ],
    "defaults": {
      "format": "png",
      "fullPage": true,
      "width": 1280
    }
  }'

# 202 Accepted — response includes batchId and per-job IDs:
# {
#   "batchId": "a3f9c2e1-...",
#   "totalJobs": 3,
#   "jobs": [
#     { "jobId": "job_01jyav...", "url": "https://example.com", "status": "queued" },
#     ...
#   ]
# }
Enter fullscreen mode Exit fullscreen mode

The JS SDK wraps this in rendex.batch():

import Rendex from "@copperline/rendex"

// npm install @copperline/rendex
const rendex = new Rendex("rdx_YOUR_KEY") // get one at rendex.dev/login

const result = await rendex.batch({
  urls: [
    "https://example.com",
    "https://example.com/pricing",
    "https://example.com/about",
  ],
  defaults: {
    format: "png",
    fullPage: true,
    width: 1280,
  },
})

console.log(result.data.batchId)    // "a3f9c2e1-..."
console.log(result.data.totalJobs)  // 3
Enter fullscreen mode Exit fullscreen mode

The Python SDK uses rendex.batch() with snake_case parameter names:

from rendex import Rendex

# pip install rendex
rendex = Rendex("rdx_YOUR_KEY")  # get one at rendex.dev/login

result = rendex.batch(
    urls=[
        "https://example.com",
        "https://example.com/pricing",
        "https://example.com/about",
    ],
    defaults={
        "format": "png",
        "fullPage": True,
        "width": 1280,
    },
)

batch_id = result["data"]["batchId"]
print(f"Submitted {result['data']['totalJobs']} jobs, batch {batch_id}")
Enter fullscreen mode Exit fullscreen mode

Step 2: Poll for Results

The batch runs asynchronously on Cloudflare Queues. Poll GET /v1/batches/:batchId to check progress. Each job in the response includes a resultUrl (signed R2 link, valid for 24 hours by default) once its status is completed.

Rendex batch screenshot API status panel showing 34 of 48 batch jobs completed, with per-URL status pills and a progress bar

import Rendex from "@copperline/rendex"

const rendex = new Rendex("rdx_YOUR_KEY")

async function waitForBatch(batchId: string, intervalMs = 2000): Promise<void> {
  while (true) {
    const status = await rendex.batchStatus(batchId)
    const { batch } = status.data

    console.log(
      `${batch.completedJobs}/${batch.totalJobs} done` +
      ` (failed: ${batch.failedJobs})`
    )

    if (batch.status === "completed" || batch.status === "partial") {
      for (const job of batch.jobs) {
        if (job.status === "completed" && job.resultUrl) {
          console.log(job.resultUrl) // signed PNG URL
        }
      }
      break
    }

    await new Promise((r) => setTimeout(r, intervalMs))
  }
}

waitForBatch("a3f9c2e1-...")
Enter fullscreen mode Exit fullscreen mode

The batch status field is one of: processing, completed, partial (some jobs failed), or failed.

Step 3: Receive a Webhook When Done

Polling works for scripts. For production services, pass webhookUrl on the batch request. Rendex calls it once when all jobs in the batch have settled, with the same payload shape as the poll response. The request is HMAC-signed so you can verify the source.

curl -X POST https://api.rendex.dev/v1/screenshot/batch \
  -H "Authorization: Bearer rdx_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "urls": ["https://example.com", "https://example.com/pricing"],
    "defaults": { "format": "png", "fullPage": true },
    "webhookUrl": "https://your-app.example.com/hooks/rendex-batch"
  }'
Enter fullscreen mode Exit fullscreen mode
// Example: Next.js App Router handler
import { NextRequest, NextResponse } from "next/server"
import { verifyWebhookSignature } from "@copperline/rendex/webhooks"

export async function POST(req: NextRequest) {
  const body = await req.text()
  const sig = req.headers.get("rendex-signature") ?? ""

  const valid = verifyWebhookSignature({
    body,
    signature: sig,
    secret: process.env.RENDEX_WEBHOOK_SECRET!,
  })

  if (!valid) return NextResponse.json({ error: "bad signature" }, { status: 401 })

  const payload = JSON.parse(body)
  const { batchId, status, jobs } = payload.data

  const resultUrls = jobs
    .filter((j) => j.status === "completed")
    .map((j) => j.resultUrl)

  console.log(`Batch ${batchId} settled (${status}): ${resultUrls.length} screenshots ready`)

  return NextResponse.json({ received: true })
}
Enter fullscreen mode Exit fullscreen mode

Controlling How Long Result URLs Last

By default, signed result URLs expire after 24 hours. Use the cacheTtl field (in seconds) to extend or shorten this. The minimum is 3,600 seconds (1 hour) and the maximum is 2,592,000 seconds (30 days).

{
  "urls": ["https://example.com"],
  "defaults": { "format": "png" },
  "cacheTtl": 604800
}
Enter fullscreen mode Exit fullscreen mode

Troubleshooting

BATCH_LIMIT_EXCEEDED (400): Your batch exceeds the per-plan URL cap. Either split into smaller batches or upgrade. The error response includes the cap for your current plan and the next tier.

Some jobs show status "failed": Individual jobs can fail without failing the whole batch. Check the error field on each job entry. Common causes: the URL returned a non-200 status, the page timed out, or a wait selector was not found.

Webhook not received: Rendex validates the webhook URL against a block list (private IPs, loopback). The URL must be publicly reachable. For local development, use a tunnel like cloudflared tunnel or ngrok and poll instead.

PLAN_UPGRADE_REQUIRED on a batch with geo: The geo parameter in defaults requires a Pro or Enterprise plan. Free and Starter batches cannot use geo-targeting.

Next Steps

Batch capture works well alongside scheduled monitoring. The website monitoring with automated screenshots guide shows a Cloudflare Workers cron that fires a batch of key pages hourly and alerts on visual changes.

For the full request schema and all defaults options, see the API reference.

Ready to run your first batch? The free screenshot tool lets you try a single capture without code. Get an API key (100 free calls/month, no credit card) to start submitting batches.

Top comments (0)