Taking one screenshot with Puppeteer or Playwright is easy. Taking thousands reliably is not. You end up managing Chromium versions in Docker, memory limits, lazy-loaded images that never appear, cookie banners covering half the page, timeouts, retries and a queue.
This guide shows how to skip that and call a hosted screenshot API instead: send a list of URLs, get back PNG, JPEG, WebP or PDF files plus a JSON record per URL. I'll show plain HTTP, JavaScript and Python, and I'll be upfront about the limits.
When a screenshot API makes sense
- Visual monitoring: a daily capture of your landing pages to spot broken layouts.
- SEO and client reports: desktop and mobile above-the-fold captures side by side.
- Archiving: save articles, documentation or your own pages as PDF.
- Link previews and thumbnails for dashboards, directories or internal tools.
- QA: check how a release looks on a phone, a tablet and a large screen.
If you only need a single screenshot once a month, a local Playwright script is fine. Once you're dealing with batches, schedules or several devices, a hosted service saves a lot of maintenance.
The tool: Website Screenshot & PDF API on Apify
Website Screenshot & PDF API is an Apify Actor, a hosted program you can call through the Apify API. What it does:
- Full-page or viewport captures (full page up to a configurable height, default 10,000 px)
- Device presets: desktop 1366×768, laptop 1920×1080, tablet (iPad, 2×), mobile (iPhone 390×844, 2×) or a custom size
- Formats: PNG, JPEG, WebP and PDF (A4, Letter, Legal or A3, portrait or landscape, with backgrounds)
- Cookie banners hidden with CSS for about 45 common consent managers. Nothing is clicked or accepted.
- Lazy-image scrolling, wait for a selector, extra delay, or network-idle waiting
-
Element capture: only
header,#pricingand so on - Dark mode emulation
- Batch: up to 5,000 URLs per run, up to 10 pages in parallel
Respectful by default: it checks each site's robots.txt, including the targets of redirects, and skips disallowed URLs. It only captures public websites (localhost and private-network addresses are blocked), and it doesn't try to get around logins, paywalls, CAPTCHAs or bot protection.
Pricing
You pay per successful capture:
| Event | Free plan | Higher Apify plans |
|---|---|---|
| Screenshot (PNG/JPEG/WebP) | $0.002 | down to $0.0008 |
| $0.003 | down to $0.0015 |
There's also a tiny start fee per run ($0.00005 per GB of memory). Failed, timed-out, robots-blocked and invalid URLs are free. Each one gets an error record in the dataset instead of a charge. Pages that answer with an HTTP error (4xx/5xx) are free error records too, unless you explicitly turn on captureErrorPages. Apify's free plan includes monthly credits, which covers a lot of test screenshots.
Apify also has its own Screenshot URL Actor with URL lists, PDF output, scrolling and selectors to hide. Both work well, so pick whichever fits your workflow. This one focuses on device presets, WebP, cookie-banner hiding, element capture and per-URL error codes with pay-per-success pricing.
Step 1: Get an Apify API token
- Create a free account at apify.com.
- Open Settings → API & Integrations and copy your API token.
- Store it as an environment variable:
export APIFY_TOKEN=...
Step 2: One request with curl (any language)
The synchronous endpoint runs the Actor and returns the dataset in a single HTTP call:
curl -X POST \
"https://api.apify.com/v2/acts/batuweb~website-screenshot/run-sync-get-dataset-items" \
-H "Authorization: Bearer $APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"urls": [{ "url": "https://crawlee.dev" }],
"format": "png",
"fullPage": true,
"device": "desktop"
}'
The response is a JSON array with one item per URL. imageUrl is a direct link to the file:
[
{
"success": true,
"url": "https://crawlee.dev",
"finalUrl": "https://crawlee.dev/",
"status": 200,
"title": "Crawlee · Build reliable crawlers. Fast.",
"format": "png",
"device": "desktop",
"viewportWidth": 1366,
"viewportHeight": 768,
"fullPage": true,
"width": 1366,
"height": 2874,
"truncated": false,
"pageCount": null,
"fileSize": 330185,
"imageUrl": "https://api.apify.com/v2/key-value-stores/<storeId>/records/0001-crawlee-dev-0d35627b.png?signature=...",
"key": "0001-crawlee-dev-0d35627b.png",
"errorCode": null,
"error": null,
"capturedAt": "2026-10-19T08:11:52.257Z"
}
]
This works from n8n, Make, Zapier, Google Apps Script or a cron job.
Step 3: Full-page and mobile screenshots with JavaScript
npm install apify-client
import { ApifyClient } from 'apify-client';
import { writeFile } from 'node:fs/promises';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('batuweb/website-screenshot').call({
urls: [
{ url: 'https://crawlee.dev' },
{ url: 'https://lightertorch.com' },
],
format: 'webp', // png | jpeg | webp | pdf
quality: 80, // JPEG/WebP only
device: 'mobile', // desktop | laptop | tablet | mobile | custom
fullPage: false, // only the visible viewport ("above the fold")
hideCookieBanners: true,
maxConcurrency: 3,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const item of items) {
if (!item.success) {
console.log('skipped (free):', item.url, item.errorCode);
continue;
}
const res = await fetch(item.imageUrl);
await writeFile(item.key, Buffer.from(await res.arrayBuffer()));
console.log('saved', item.key, item.width + 'x' + item.height);
}
Want only one section of a page? Add selector: '#pricing' (image formats only). For JavaScript-heavy pages, use waitUntil: 'networkidle' or waitForSelector: '.chart' together with delayMs: 1000.
Step 4: Website to PDF with Python
pip install apify-client requests
import os
import requests
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("batuweb/website-screenshot").call(run_input={
"urls": [
{"url": "https://docs.apify.com/platform/actors"},
{"url": "https://crawlee.dev/js/docs/quick-start"},
],
"format": "pdf",
"pdfPaper": "a4", # a4 | letter | legal | a3
"pdfLandscape": False,
"pdfPrintBackground": True,
"scrollToBottom": True, # load lazy images before printing
"maxFullPageHeight": 10000, # long pages are cut here (truncated: true)
})
for item in client.dataset(run.default_dataset_id).iterate_items():
if not item["success"]:
print("free error record:", item["url"], item["errorCode"])
continue
with open(item["key"], "wb") as f:
f.write(requests.get(item["imageUrl"], timeout=60).content)
print("saved", item["key"], item.get("pageCount"), "pages")
The files also stay in the run's key-value store, under the key shown in each record, for the store's normal retention period. So you can fetch them later with client.key_value_store(run.default_key_value_store_id).get_record(key).
(This uses the current apify-client 3.x for Python, where call() returns a Run object. With the older 1.x client, use run["defaultDatasetId"] instead.)
Step 5: Batch screenshots on a schedule
For visual monitoring, save the input as an Apify task and add a schedule (for example every morning at 07:00). Each run creates a dataset you can export as JSON, CSV or Excel, or push to a webhook, Google Drive or Slack through Apify integrations.
Two settings help control cost in big batches:
-
maxScreenshots: stop after N successful captures. - The run's max total charge: the Actor stops cleanly once your budget is reached.
You can try ready-made examples without writing any code:
- Full-page screenshot of a website
- Mobile screenshot of a homepage
- Convert a website page to PDF
- Batch screenshots of multiple URLs
Error codes you may see (all free)
errorCode |
Meaning |
|---|---|
ROBOTS_DISALLOWED |
The URL or its redirect target is disallowed by robots.txt, or robots.txt couldn't be fetched (treated as "disallow all") |
HTTP_403, HTTP_404, ... |
The page answered with an HTTP error |
TIMEOUT, DNS_ERROR, TLS_ERROR, CONNECTION_ERROR
|
The page couldn't be loaded within navigationTimeoutSecs (after retries) |
BLOCKED_PRIVATE_ADDRESS |
The URL or a redirect points to localhost, a private network or a cloud metadata address |
SELECTOR_NOT_FOUND |
Your selector or waitForSelector didn't match a visible element |
INVALID_URL, TOO_MANY_REDIRECTS
|
Exactly what it says |
Honest limitations
- robots.txt is a courtesy signal, not permission. It doesn't replace a site's terms of use, copyright or privacy law. Only capture pages you're allowed to: your own sites, clients' sites with their OK, or public pages whose terms allow it. Using this tool doesn't give you legal clearance for any particular use.
- No login, paywall or CAPTCHA bypass. Sites that block automated browsers return an error (free). A block page served with HTTP 200 looks like a normal page and is captured as rendered.
-
Height limits: full-page captures and PDFs are cut at
maxFullPageHeight(max 16,000 px) and markedtruncated: true. WebP is also limited to 16,383 pixels per side. - Cookie banners from less common consent tools may still show. Add their selectors to
selectorsToHide. - If a site's robots.txt is down or rate-limited, that site is skipped for the run. Retry later.
FAQ
Do I need to install Chromium or Puppeteer? No. The browser runs on Apify, so you only send HTTP requests.
How much do 1,000 screenshots cost? About $2 on the Apify Free plan, and down to $0.80 on higher plans, plus a negligible start fee. Failed URLs cost nothing.
Can it capture pages behind a login? No, only publicly reachable pages.
Can AI agents use it? Yes. Like any Apify Actor, it can be called through the Apify MCP server or the REST API.
Related tools
- SEO Audit Tool – Site Crawler, Broken Links & Meta Checker: pair visual captures with a technical SEO check of the same pages.
- Google Trends Scraper: interest over time, regions and related queries as JSON.
Disclosure: I built the Website Screenshot & PDF API Actor.
Top comments (0)