How to Capture Screenshots from a List of URLs in Google Sheets
Use Apps Script and a browser screenshot API to turn a Google Sheets URL list into organized website previews, with batching and error handling.

Direct answer: Google Sheets cannot render a website URL as a screenshot by itself. Put one URL per row, use Google Apps Script to send each URL to a browser-based screenshot renderer, then insert the returned image as a blob or store it at a publicly accessible image URL. The most reliable workflow starts from a menu, button, or time-driven trigger instead of a custom cell formula.
This guide builds that workflow from scratch, explains image placement and Google limits, shows batching and retry handling, and then shows how to replace the browser setup with ScreenshotNeo when you want a single HTTP request.
1. Design the sheet before writing code
Create a sheet named Sites with a header row. Keep the input and output columns separate so a failed capture never overwrites the URL.
| Column | Purpose | Example |
|---|---|---|
| A | Source URL | https://example.com |
| B | Screenshot status | OK, HTTP 403, timeout |
| C | Captured at | 2026-09-29T12:00:00Z |
| D | Image or image URL | Inserted over the grid or a hosted URL |
Use one canonical URL per row. Include the scheme (https://), remove accidental spaces, and decide whether redirects should be followed by your screenshot service. Keep a stable row identifier if another system will consume the results.
2. Understand what Apps Script does
UrlFetchApp is an HTTP and HTTPS client. It sends a request to a screenshot service; it does not run a browser or paint a page. The screenshot service must load HTML, CSS, JavaScript, fonts, images, and any lazy-loaded content before returning an image. Google documents UrlFetchApp.fetch() for one request and fetchAll() for multiple requests. If you explicitly configure OAuth scopes, include https://www.googleapis.com/auth/script.external_request in the manifest.

Do not call UrlFetchApp from a custom function used directly in a cell. Start the work from a custom menu, drawing button, installable trigger, or a separate Apps Script execution. This makes authorization and quota failures visible and avoids recalculation running external work repeatedly.
3. Add a menu and a capture function
In the spreadsheet, open Extensions → Apps Script, replace the editor contents, and add your screenshot provider’s endpoint and key. The example below expects an API that accepts a URL and returns an image. Adapt the parameter names to the provider you use.
const CONFIG = {
sheetName: 'Sites',
firstDataRow: 2,
urlColumn: 1,
statusColumn: 2,
capturedAtColumn: 3,
imageColumn: 4,
endpoint: 'https://your-screenshot-service.example/v1/shot',
apiKey: 'YOUR_API_KEY'
};
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Screenshots')
.addItem('Capture all URLs', 'captureAllUrls')
.addItem('Capture selected rows', 'captureSelectedRows')
.addToUi();
}
function captureAllUrls() {
const sheet = SpreadsheetApp.getActive().getSheetByName(CONFIG.sheetName);
if (!sheet) throw new Error('Missing sheet: ' + CONFIG.sheetName);
const lastRow = sheet.getLastRow();
if (lastRow < CONFIG.firstDataRow) return;
captureRows_(sheet, CONFIG.firstDataRow, lastRow);
}
function captureSelectedRows() {
const sheet = SpreadsheetApp.getActiveSheet();
const range = sheet.getActiveRange();
const start = Math.max(range.getRow(), CONFIG.firstDataRow);
const end = Math.min(range.getLastRow(), sheet.getLastRow());
if (end >= start) captureRows_(sheet, start, end);
}
function captureRows_(sheet, startRow, endRow) {
for (let row = startRow; row <= endRow; row++) {
const raw = sheet.getRange(row, CONFIG.urlColumn).getDisplayValue().trim();
if (!raw) continue;
sheet.getRange(row, CONFIG.statusColumn).setValue('CAPTURING');
try {
const url = validateUrl_(raw);
const response = UrlFetchApp.fetch(CONFIG.endpoint, {
method: 'get',
muteHttpExceptions: true,
followRedirects: true,
headers: { Authorization: 'Bearer ' + CONFIG.apiKey },
payload: { url: url, format: 'webp', full_page: 'true' }
});
const code = response.getResponseCode();
if (code < 200 || code >= 300) {
throw new Error('Screenshot service returned HTTP ' + code + ': ' + response.getContentText().slice(0, 300));
}
const blob = response.getBlob().setName('row-' + row + '.webp');
if (blob.getBytes().length > 2 * 1024 * 1024) {
throw new Error('Image is larger than Google Sheets\' 2 MB insertImage limit');
}
sheet.insertImage(blob, CONFIG.imageColumn, row);
sheet.getRange(row, CONFIG.statusColumn).setValue('OK');
sheet.getRange(row, CONFIG.capturedAtColumn).setValue(new Date());
} catch (error) {
sheet.getRange(row, CONFIG.statusColumn).setValue('ERROR: ' + error.message.slice(0, 200));
}
}
}
function validateUrl_(value) {
let parsed;
try { parsed = new URL(value); } catch (e) { throw new Error('Invalid URL'); }
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
return parsed.toString();
}
Authorize the script when prompted, reload the spreadsheet, and use the new Screenshots menu. The script writes a status for every row, so a later run can target only failures.
4. Choose how the image appears in Sheets
Over-grid image from a blob
Sheet.insertImage(blob, column, row) inserts an image positioned over the cells. Google documents a maximum supported image size of 2 MB for this method. Resize or request a smaller output when a capture exceeds that limit. Over-grid images are convenient for visual review, but moving rows can require repositioning images.
Over-grid image from a URL
Sheet.insertImage(url, column, row) requires the URL to be publicly accessible. A URL protected by an API key, a private Drive permission, a short-lived signature, or an IP allowlist will not load for every spreadsheet viewer. Use this option only when the image host’s access and retention policy match your sharing requirements.
Image inside a cell
In-cell images are a separate presentation choice. They can keep a row compact and sort more predictably, but the image must be available through a supported image or image-URL method. Google notes that a CellImage content URL is requester-tagged and expires after a short period; sharing settings can also affect access. Do not treat a Google-hosted content URL as a permanent public asset. For durable results, store the image in a controlled bucket or image host and retain the original capture URL and timestamp in adjacent columns.
5. Make the screenshot request useful
Most screenshot APIs expose options that map directly to this workflow:
- Output: PNG for lossless text, JPEG for smaller photographic images, or WebP for a compact preview. Request a width and quality that keep blobs below 2 MB.
- Viewport: set width, height, device preset, device scale factor, or retina scale. Use a consistent viewport when comparing sites.
- Full page: capture the complete document and load lazy images before returning. A viewport shot is faster and smaller.
- Element capture: provide a CSS selector when the sheet needs a logo, pricing table, or other component rather than the whole page.
- Timing: wait for a selector, a fixed delay, or network idle. Use the smallest reliable wait; long delays consume Apps Script runtime.
- Interaction: click a selector before capture, execute custom JavaScript, or apply custom CSS to close a modal or reveal content.
- Privacy and access: send custom headers, cookies, a user agent, an Authorization header, timezone, or geolocation when the page requires them.
- Filtering: block ads, trackers, selected requests, or resource types to reduce noise and speed up captures.
- Appearance: set dark mode, transparent background, image resizing, or a PDF paper size and margins if the output is a PDF rather than an image.
- Reuse: enable caching with a chosen TTL when repeated captures do not need fresh content. Record the capture time so readers know whether an image is cached.
Validate these names against your provider’s current API documentation. The research sources establish the Apps Script and Sheets behavior, not universal screenshot-service parameters or limits.
6. Process a long list safely
Apps Script executions have runtime and service quotas. A loop that works for 20 rows can stop partway through a much larger sheet. Add a batch size, checkpoint, and resumable status. For independent requests, UrlFetchApp.fetchAll() can send several calls together, but check the provider’s concurrency limit and your Apps Script quota before increasing parallelism.
function captureBatch() {
const sheet = SpreadsheetApp.getActive().getSheetByName(CONFIG.sheetName);
const start = Number(PropertiesService.getScriptProperties().getProperty('NEXT_ROW') || CONFIG.firstDataRow);
const end = Math.min(start + 24, sheet.getLastRow());
if (start > sheet.getLastRow()) return;
captureRows_(sheet, start, end);
PropertiesService.getScriptProperties().setProperty('NEXT_ROW', String(end + 1));
}
function resetBatch() {
PropertiesService.getScriptProperties().deleteProperty('NEXT_ROW');
}
Schedule captureBatch with a time-driven trigger, or run it from a menu until the status column covers the list. Keep failed rows marked with the HTTP code and a short message; retry only transient failures such as 429, 502, 503, and network timeouts.
7. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. Your Apps Script still handles the spreadsheet loop, but each row can use one GET request to the capture endpoint. See the ScreenshotNeo documentation for the current parameter reference.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
For Apps Script, replace the generic endpoint and authorization block with the access-key query parameter:
const response = UrlFetchApp.fetch('https://api.screenshotneo.com/v1/shot', {
method: 'get',
muteHttpExceptions: true,
payload: { access_key: 'YOUR_API_KEY', url: url, format: 'webp', full_page: 'true' }
});
ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; the response identifies the result with X-Page-Verdict and X-Billed headers. Its options include full-page capture with lazy images, CSS-selector element capture, dark mode, device presets or custom viewports, retina scale, custom CSS and JavaScript, clicks, selector or network-idle waits, request blocking, headers, cookies, user agent, Authorization, timezone, geolocation, transparent backgrounds, resizing, caching, signed links, asynchronous jobs with signed webhooks, bulk capture up to 100 URLs per call, a usage API, and PDFs.
An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 shots per month without a card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account to get the key.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Authorization prompt repeats | Script scope or account authorization is incomplete. | Run the function from the Apps Script editor, accept permissions, and confirm the external-request scope in the manifest if scopes are explicit. |
| HTTP 400 | Malformed URL or unsupported option. | Log the final request, validate the scheme, URL-encode query values, and remove options your provider does not support. |
| HTTP 401 or 403 | Wrong key, missing header, or a private target page. | Check the key, authentication format, target cookies, and provider allowlists. Never put a secret in a cell shared with viewers. |
| Blank or incomplete image | JavaScript, lazy loading, consent dialog, or a page timeout. | Increase a selector or network-idle wait, enable full-page lazy-image loading, click or hide the dialog, or capture after a required element appears. |
| Image will not insert | Blob exceeds 2 MB, or URL is not public. | Request WebP/JPEG at a smaller size for blob insertion, or use a durable public image URL. |
| Rows stop midway | Apps Script runtime or provider quota. | Use checkpointed batches, exponential backoff for 429/5xx responses, and a trigger to resume. |
| Viewers see missing in-cell images | Requester-tagged content URL expired or sharing differs. | Store a durable image URL and test access using a separate viewer account. |
| Repeated captures are slow or costly | Every row renders the same page again. | Enable a provider cache with a defined TTL, deduplicate URLs, and record the last successful capture. |
9. Performance, reliability, and cost checklist
- Normalize and deduplicate URLs before requesting them.
- Choose viewport capture for previews and full-page capture only when the entire document is needed.
- Use WebP or JPEG and a practical width to stay below the 2 MB Sheets blob limit.
- Batch work, checkpoint progress, and retry transient errors with backoff.
- Store status, timestamp, HTTP code, verdict, and billing information beside each image.
- Keep API keys in Apps Script Properties or a secret manager, never in a shared worksheet.
- Decide whether images must remain private, publicly fetchable, or durable after the sheet is copied.
- Estimate cost from the number of successful captures and the provider’s billing rules. ScreenshotNeo bills only clean shots; bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing.
10. Frequently asked questions
Can a formula such as =IMAGE(A2) create the screenshot?
No. It can display an image URL, but it does not render an arbitrary website. Render first with Apps Script and a browser-based service.
Should I save images in Drive?
Drive can provide durable storage and controlled sharing, but you must handle permissions and image URLs. Over-grid blob insertion avoids a separate host but is subject to the 2 MB limit.
Can I capture pages behind a login?
Only when the renderer supports the required cookies, headers, or authentication and you are authorized to access the page. Treat credentials as secrets and avoid writing them into the spreadsheet.
How often should a trigger refresh the list?
Set the interval to the freshness your workflow needs, then account for screenshot-service quotas, Apps Script runtime, and the number of rows. A checkpointed batch is safer than one very large execution.
Is a PDF better than an image for archival?
A PDF can preserve a multipage document and print layout. A WebP or PNG is usually easier to preview in a sheet. Choose based on how the result will be reviewed and shared.
11. Final implementation checklist
- Create URL, status, timestamp, and output columns.
- Add the Apps Script menu and validate every URL.
- Choose a screenshot renderer and configure viewport, waits, output format, and authentication.
- Insert blobs under 2 MB or store publicly accessible durable image URLs.
- Run a small sample, inspect failures, then enable checkpointed batches or triggers.
- Record capture metadata and review sharing permissions before distributing the sheet.


