Batch Screenshot API: How to Avoid Rate Limits and Optimize Every API Call
If you are capturing more than a handful of screenshots per day, individual API calls will eventually hit rate limits. Every plan has a per-minute burst limit (10 on Free, 30 on Starter, up to 200 on Scale), and once you cross it, you start getting 429 responses.
The batch endpoint solves this. One request, up to 50 URLs, automatic pacing, and you only pay for screenshots that succeed.
This guide covers exactly how the batch endpoint works, when to use it instead of individual calls, and how to structure your code so you never waste a credit or hit an unnecessary rate limit.
Quick Start: Your First Batch Request
Here is a working batch request you can run right now. Replace YOUR_API_KEY with your key from the dashboard:
curl -X POST https://app.snap-render.com/v1/screenshot/batch \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"urls": [
"https://github.com",
"https://stripe.com",
"https://linear.app"
],
"format": "jpeg",
"quality": 80
}'
You get back a 202 Accepted response immediately:
{
"jobId": "c91a6fb2-9feb-49fa-b187-28451d352473",
"status": "pending",
"statusUrl": "/v1/screenshot/batch/c91a6fb2-...",
"total": 3,
"completed": 0,
"failed": 0,
"estimatedSeconds": 12,
"items": [
{ "url": "https://github.com", "status": "pending" },
{ "url": "https://stripe.com", "status": "pending" },
{ "url": "https://linear.app", "status": "pending" }
]
}
Then poll the status URL until the job finishes:
curl https://app.snap-render.com/v1/screenshot/batch/c91a6fb2-... \
-H "X-API-Key: YOUR_API_KEY"
Each completed item includes a presigned download URL that stays valid for 24 hours.
Why Batch Requests Are Better Than Individual Calls
1. You Avoid Rate Limits Entirely
Individual GET /v1/screenshot calls are subject to your plan's per-minute burst limit. Fire 15 requests in 10 seconds on the Free plan (10/min limit) and 5 of them come back as 429 Too Many Requests.
Batch requests handle pacing internally. The API processes your URLs at the maximum rate your plan allows, automatically waiting between minute windows when needed. You submit 50 URLs, walk away, and come back to 50 completed screenshots.
| Plan | Burst limit (individual) | Batch behavior |
|---|---|---|
| Free | 10 req/min | Processes 10/min, pauses, continues |
| Starter | 30 req/min | Processes 30/min, pauses if needed |
| Growth | 60 req/min | All 50 URLs fit in one window |
| Business | 120 req/min | All 50 URLs fit in one window |
| Scale | 200 req/min | All 50 URLs fit in one window |
2. Failed Screenshots Do Not Cost You Credits
Credits are reserved when you submit the batch. If a URL times out, returns an error, or the domain is unreachable, those credits are automatically rolled back. You only pay for screenshots that actually succeed.
This is different from individual calls, where the credit is consumed before the screenshot is attempted, and you need a cache hit on retry to avoid double-charging.
3. You Get an Accurate Time Estimate
The estimatedSeconds field in the response tells you roughly how long the batch will take, based on your plan's rate limits and the number of URLs. Use it to set appropriate polling intervals or display progress to your users.
4. Domain Rate Limits Are Handled Automatically
Each plan also has a per-domain rate limit (10-30 requests per minute to the same domain). If your batch contains 30 URLs all pointing to docs.example.com, the API paces them automatically instead of rejecting them.
How to Poll Efficiently
Do not poll every second. Use the estimatedSeconds value to set a reasonable interval:
Node.js
const { SnapRender } = require('snaprender');
const client = new SnapRender({ apiKey: 'YOUR_API_KEY' });
async function batchCapture(urls) {
// Submit the batch
const response = await fetch('https://app.snap-render.com/v1/screenshot/batch', {
method: 'POST',
headers: {
'X-API-Key': 'YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
urls,
format: 'jpeg',
quality: 80,
}),
});
const job = await response.json();
console.log(`Job ${job.jobId} started. Estimated: ${job.estimatedSeconds}s`);
// Poll with smart intervals
const pollInterval = Math.max(10, Math.floor(job.estimatedSeconds / 10));
let result;
while (true) {
await new Promise(r => setTimeout(r, pollInterval * 1000));
const poll = await fetch(
`https://app.snap-render.com/v1/screenshot/batch/${job.jobId}`,
{ headers: { 'X-API-Key': 'YOUR_API_KEY' } },
);
result = await poll.json();
console.log(`Progress: ${result.completed}/${result.total} done, ${result.failed} failed`);
if (result.status === 'completed' || result.status === 'failed') break;
}
// Download completed items
for (const item of result.items) {
if (item.status === 'completed') {
console.log(`${item.url} -> ${item.downloadUrl}`);
} else {
console.log(`${item.url} FAILED: ${item.error}`);
}
}
return result;
}
batchCapture([
'https://github.com',
'https://stripe.com',
'https://vercel.com',
'https://linear.app',
'https://tailwindcss.com',
]);
Python
import requests
import time
API_KEY = "YOUR_API_KEY"
BASE = "https://app.snap-render.com"
def batch_capture(urls, format="jpeg", quality=80):
# Submit batch
resp = requests.post(
f"{BASE}/v1/screenshot/batch",
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
json={"urls": urls, "format": format, "quality": quality},
)
job = resp.json()
print(f"Job {job['jobId']} started. Estimated: {job['estimatedSeconds']}s")
# Poll with smart interval
poll_interval = max(10, job["estimatedSeconds"] // 10)
while True:
time.sleep(poll_interval)
poll = requests.get(
f"{BASE}/v1/screenshot/batch/{job['jobId']}",
headers={"X-API-Key": API_KEY},
).json()
print(f"Progress: {poll['completed']}/{poll['total']} done, {poll['failed']} failed")
if poll["status"] in ("completed", "failed"):
break
# Process results
for item in poll["items"]:
if item["status"] == "completed":
print(f" OK: {item['url']} -> {item['downloadUrl']}")
else:
print(f" FAIL: {item['url']} - {item['error']}")
return poll
batch_capture([
"https://github.com",
"https://stripe.com",
"https://vercel.com",
"https://linear.app",
"https://tailwindcss.com",
])
cURL + Shell Script
#!/bin/bash
KEY="YOUR_API_KEY"
BASE="https://app.snap-render.com"
# Submit batch
JOB=$(curl -s -X POST "$BASE/v1/screenshot/batch" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"urls": [
"https://github.com",
"https://stripe.com",
"https://vercel.com"
],
"format": "png"
}')
JOB_ID=$(echo "$JOB" | python3 -c "import sys,json; print(json.load(sys.stdin)['jobId'])")
EST=$(echo "$JOB" | python3 -c "import sys,json; print(json.load(sys.stdin)['estimatedSeconds'])")
echo "Job $JOB_ID started (est: ${EST}s)"
# Poll until done
while true; do
sleep 10
STATUS=$(curl -s "$BASE/v1/screenshot/batch/$JOB_ID" -H "X-API-Key: $KEY")
S=$(echo "$STATUS" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])")
C=$(echo "$STATUS" | python3 -c "import sys,json; print(json.load(sys.stdin)['completed'])")
echo " status=$S completed=$C"
[ "$S" = "completed" ] || [ "$S" = "failed" ] && break
done
# Download results
echo "$STATUS" | python3 -c "
import sys, json
for item in json.load(sys.stdin)['items']:
if item['status'] == 'completed':
print(f\"OK: {item['url']}\")
else:
print(f\"FAIL: {item['url']} - {item.get('error', 'unknown')}\")
"
Optimization Strategies
Use JPEG with Lower Quality for Bulk Jobs
PNG files are 3-5x larger than JPEG at comparable visual quality. For monitoring, archival, or thumbnail workflows where pixel-perfect fidelity is not required, use JPEG at quality 60-80. The screenshots render faster and download faster.
{
"urls": ["..."],
"format": "jpeg",
"quality": 70
}
Spread URLs Across Domains
If you are monitoring 50 pages on the same domain, the per-domain rate limit (10-30/min depending on plan) becomes the bottleneck. If possible, mix domains in each batch so the domain limit does not throttle your throughput.
Slow (all same domain):
{
"urls": [
"https://docs.example.com/page1",
"https://docs.example.com/page2",
"https://docs.example.com/page50"
]
}
Faster (mixed domains):
{
"urls": [
"https://docs.example.com/page1",
"https://blog.example.com/post1",
"https://app.example.com/dashboard",
"https://docs.example.com/page2"
]
}
Use Webhooks Instead of Polling
If you have a server that can receive HTTP callbacks, set up a webhook for the screenshot.completed event. The API will POST to your URL the moment the batch finishes. No polling loop, no wasted requests.
Combine With Caching for Repeat Captures
Individual GET /v1/screenshot requests benefit from SnapRender's built-in cache (up to 30-day TTL on higher plans). If you are re-capturing the same URLs periodically, use individual cached requests for recent URLs and batch for the rest.
Check Your Usage Before Submitting
The /v1/usage endpoint tells you how many credits you have left this month:
curl https://app.snap-render.com/v1/usage -H "X-API-Key: YOUR_API_KEY"
{
"plan": "starter",
"usage": {
"screenshots_used": 847,
"screenshots_limit": 2000,
"screenshots_remaining": 1153
}
}
If you have 30 credits left and submit a batch of 50, the entire batch is rejected (not partially processed). Check first, then submit a batch that fits.
Timing Reference: How Long Will My Batch Take?
The estimatedSeconds in the response accounts for your plan's rate limits, the number of URLs, and the domain distribution. Here are real-world benchmarks:
| URLs | Plan | All different domains | All same domain |
|---|---|---|---|
| 10 | Free | ~40s | ~40s |
| 50 | Free | ~280s (~5 min) | ~280s (~5 min) |
| 10 | Starter | ~40s | ~40s |
| 50 | Starter | ~200s (~3 min) | ~200s (~3 min) |
| 50 | Growth+ | ~200s (~3 min) | ~200s (~3 min) |
The floor is about 3-5 seconds per URL (Chromium render time). Rate limits only add wait time when you have more URLs than your plan allows per minute.
Common Mistakes to Avoid
Submitting more than 50 URLs. The maximum batch size is 50. You will get a 400 VALIDATION_ERROR. If you have 200 URLs, split them into 4 batches of 50.
Including private or localhost URLs. SSRF protection validates all URLs upfront. One bad URL (localhost, 10.0.0.1, 169.254.169.254) rejects the entire batch. Validate your URL list before submitting.
Polling too aggressively. Polling every second for a 5-minute batch wastes 300 requests. Use the estimatedSeconds value: poll every estimatedSeconds / 10 seconds, with a minimum of 10 seconds.
Not handling partial failures. Some URLs in your batch may fail (DNS errors, timeouts, server errors). Always check each item's status field. Failed items include an error message explaining what went wrong. Credits for failed items are refunded automatically.
Ignoring the 24-hour download window. Presigned download URLs expire after 24 hours. Download or store results promptly. If you miss the window, you need to re-submit the batch.
Batch vs. Individual: When to Use Which
| Scenario | Use batch | Use individual |
|---|---|---|
| Capturing 10+ URLs at once | Yes | No |
| Real-time single screenshot | No | Yes |
| Scheduled monitoring (cron) | Yes | Depends on count |
| User-triggered single capture | No | Yes |
| Generating thumbnails for a list | Yes | No |
| Re-capturing a cached URL | No | Yes (cache hit is free) |
Full API Reference
Submit: POST /v1/screenshot/batch
| Parameter | Type | Default | Description |
|---|---|---|---|
urls |
string[] | required | 1-50 URLs to capture |
format |
string | png |
png, jpeg, webp, pdf |
width |
number | 1280 | Viewport width (320-3840) |
height |
number | 800 | Viewport height (200-10000) |
full_page |
boolean | false | Capture full scrollable page |
quality |
number | 90 | JPEG/WebP quality (1-100) |
delay |
number | 0 | Wait ms after page load (0-10000) |
dark_mode |
boolean | false | Emulate dark color scheme |
block_ads |
boolean | true | Block ads and trackers |
block_cookie_banners |
boolean | true | Remove cookie consent dialogs |
device |
string | - | Device preset (e.g., iphone_15_pro) |
Poll: GET /v1/screenshot/batch/:jobId
Returns the same structure with updated status, completed, failed counts, and downloadUrl on completed items.
Ready to try it? Get your free API key (500 screenshots/month, no credit card) and run the examples above. For more details, see the batch screenshots feature guide and the full API documentation.