I built the Apify actor I use in the examples. I will say that now, so you can read the rest with the right amount of skepticism.
The job is simple. You have a list of public URLs. You want a full-page PNG, a mobile JPEG, or a PDF of each one, plus a public image URL you can store. You do not want to start a new browser for every page, and you want to know the bill before you press Run.
If that is your job, you can do it for $0.60 per 1,000 screenshots, plus a $0.003 start fee per run. Platform usage is included.
What I actually use
I use Bulk Website Screenshot on Apify Store. It opens one shared browser for the whole list. Each URL becomes a dataset row with a screenshotUrl, the final loaded URL, width, height, title, HTTP status, and an error if the capture failed. The files themselves live in the run's key-value store.
What it will do:
- PNG, JPEG, or PDF. JPEG has a
jpegQualitysetting, default 80. - Full page (the default) or just the viewport.
- Desktop at 1280x800 by default, or emulate iPhone 15, Pixel 7, or iPad Pro 11.
- Dark mode, and retina-quality images if you set
deviceScaleFactorto 2. - Capture one element with a CSS selector, for example
#pricingormain. - Hide cookie banners by default, and any extra selectors you pass. Nothing is clicked or accepted.
- Scroll first so lazy images load, then wait for load, network idle, a selector, or a fixed delay.
What it will not do:
- Log in, inject cookies, or bypass captchas or paywalls.
- Ignore robots.txt or a crawl-delay.
- Use residential proxies. An optional datacenter proxy is the only proxy option.
If the page is behind a sign-in, this is the wrong tool.
An honest comparison
I am not going to crown a winner. These tools overlap, they do not replace each other.
cheapapi/website-screenshot-api is the one people often pick when they want a cheap Store actor with more device profiles and WebP. I re-checked its pricing page on 14 November 2026 (Asia/Jerusalem). It is $0.89 per 1,000 successful screenshots on the Free plan, $0.83 on Starter, $0.74 on Scale, and $0.61 on Business. The start fee is $0.00001, and you also pay Apify platform usage on top. It can click cookie consent, pass cookies for authenticated pages, and emulate more devices. If you need those, use it.
apify/screenshot-url has no actor event fee. You pay platform usage only. That can be cheap or not, depending on memory and how long the run takes. I am not going to invent a compute bill I did not measure today.
The DatLab actor is $0.60 per 1,000 screenshots, plus $0.003 per run, with platform usage included. Failed pages, robots.txt blocks, and HTTP errors are not charged. The start fee is higher than cheapapi's, so a single URL costs $0.0036. In a batch of 1,000, the all-in price is $0.603 on any plan.
Walkthrough: a list of public URLs
You will need an Apify account and an API token from Console, under Settings, then API and Integrations. The Free plan is $0, includes $5 of prepaid usage each month, and does not require a card. Unused credit expires at the end of the cycle. You can also run the actor from its Store page with no code. The examples below are the API path.
The URLs are public pages from the actor's own input prefill. Check robots.txt before you point this at a host you do not run.
JavaScript, official client
import { ApifyClient } from "apify-client"; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor("datlab/bulk-website-screenshot").call({ urls: [ { url: "https://apify.com" }, { url: "https://crawlee.dev" }, ], format: "png", fullPage: true, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); for (const item of items) { console.log(item.url, item.screenshotUrl, item.error); } Python, official client
import os from apify_client import ApifyClient client = ApifyClient(os.environ["APIFY_TOKEN"]) run = client.actor("datlab/bulk-website-screenshot").call( run_input={ "urls": [ {"url": "https://apify.com"}, {"url": "https://crawlee.dev"}, ], "format": "png", "fullPage": True, } ) items = client.dataset(run["defaultDatasetId"]).list_items().items for item in items: print(item["url"], item.get("screenshotUrl"), item.get("error")) A successful item looks like this (trimmed, from a real run shape):
{ "url": "https://crawlee.dev/", "loadedUrl": "https://crawlee.dev/", "screenshotUrl": "https://api.apify.com/v2/key-value-stores/…/records/crawlee-dev-0d35627b.png", "screenshotKey": "crawlee-dev-0d35627b.png", "format": "png", "fullPage": true, "truncatedAtHeight": null, "width": 1280, "height": 2874, "sizeBytes": 335092, "device": "desktop", "title": "Crawlee · Build reliable crawlers. Fast.", "httpStatusCode": 200, "error": null } Failed or blocked URLs come back with screenshotUrl: null and an error. You are not charged for those.
Mobile, lazy images, or just one element
Same actor, different input. device switches the viewport, pixel ratio, user agent, and touch. scrollToBottom walks the page first so lazy-loaded images have a chance to render. elementSelector crops to the first match.
run = client.actor("datlab/bulk-website-screenshot").call( run_input={ "urls": [{"url": "https://crawlee.dev"}], "format": "jpeg", "jpegQuality": 80, "fullPage": True, "device": "iPhone 15", "scrollToBottom": True, "hideCookieBanners": True, "selectorsToHide": ".chat-widget, #newsletter-popup", } ) A few input notes, because the field names are easy to guess wrong:
-
urlsis required. Each item can be{"url": "..."}or a plain string. -
waitUntilisload(default),domcontentloaded, ornetworkidle. Puppeteer-stylenetworkidle0andnetworkidle2are accepted and treated asnetworkidle. -
maxFullPageHeightdefaults to 15,000 px. Taller pages are cut, andtruncatedAtHeighttells you where. - PDF output is sized to the screenshot viewport, so the page does not reflow to a paper width and clip wide content.
pdfFormatis kept for compatibility and does not change that. -
maxConcurrencydefaults to 2, which fits the default 1 GB memory. Raise it only if you also raise memory. -
minDelayPerHostMsdefaults to 500 ms. A larger robots.txt crawl-delay wins.
If you want an agent to call this as a tool, Apify hosts an MCP server at https://mcp.apify.com. You can pin the actor with https://mcp.apify.com/?tools=fetch-actor-details,datlab/bulk-website-screenshot. See the Apify MCP docs.
What a run actually costs
The prices are pay per event. I am doing the arithmetic in the open, not quoting a made-up invoice.
- 1,000 URLs in one run: 1,000 x $0.0006 + $0.003 = $0.603.
- 50 URLs in one run: 50 x $0.0006 + $0.003 = $0.033.
- A single URL in its own run: $0.0006 + $0.003 = $0.0036.
Failed, robots-blocked, and HTTP-error rows are not billed. The $0.003 start fee is why I batch URLs instead of starting a run per page. If you only need one shot, that start fee is most of the bill, and a usage-only actor can come out cheaper. If you have a list, the start fee is a rounding error.
When a shot comes out wrong
The row has an error and no image. Some sites block automated browsers (HTTP 403) or disallow the path in robots.txt. The actor reports that and does not try to get around it. You are not charged.
The screenshot is cut off. The page is taller than maxFullPageHeight. Raise the limit, or capture a single element instead.
Images are missing at the bottom. Turn on scrollToBottom. You can also set waitUntilNetworkIdleAfterScroll if a fixed delay is not enough, or wait for a selector with waitForSelector.
The image looks soft. Set deviceScaleFactor to 2, or pick a mobile device. Those already use a 2.6x to 3x pixel ratio.
You wanted a logged-in view. It cannot do that, by design.
You are responsible for having the right to capture and use the screenshots (copyright, trademarks, site terms, applicable law). The actor only captures public pages you list.
If a public page comes out messy, leave a comment with the URL. I will take a look.
The actor is here: datlab/bulk-website-screenshot. If you need the page as Markdown instead of a picture, I also have Web Page to Markdown and Website to Markdown Crawler.

