How to Capture Bulk Screenshots of URLs from Google Sheets with Apps Script
Use Google Sheets as a resumable screenshot queue with Apps Script, a browser screenshot API, and per-row results, error handling, and image storage.
Direct answer: Use a Google Sheet as the URL queue and results table, and a bound Google Apps Script to validate each URL, send it to a browser-based screenshot API with UrlFetchApp, then store the returned image or a link on that row. Apps Script can make HTTP requests, but Google does not document it as a browser renderer; a normal fetch is not a dependable way to capture a modern page after JavaScript has rendered it. The API endpoint, authentication, request parameters, output format, and rate limits depend on the provider.
This guide builds a resumable workflow without assuming a third-party API’s undocumented request format. The example call in the do-it-yourself section uses ScreenshotNeo’s documented endpoint. Replace it with another provider only after confirming its current API contract. See the ScreenshotNeo API documentation.
1. Set up the sheet
Create a sheet named Screenshots with these columns in row 1:
| Column | Header | Purpose |
|---|---|---|
| A | URL | One page URL per row |
| B | Status | Pending, processing, done, or error |
| C | Screenshot | Image, Drive link, or API-returned URL |
| D | Error | Readable failure detail |
Keep row 1 for headers. The script below processes a limited number of rows per run, marks each row as it goes, and can resume from the first row that is not done. That makes interruption recoverable and keeps one bad URL from discarding other results.
2. Choose how screenshots will appear in the sheet
Insert image bytes over the grid
Sheet.insertImage(blob, column, row) places an image over the sheet grid at the specified row and column; it is not text inside a cell. Google documents a maximum supported blob size of 2 MB for this method. Confirm that the response is an image and within the limit before inserting it. The image must be removed or replaced deliberately if you rerun that row.
Put a public image URL in a cell
This is lightweight, but Google’s URL insertion method requires a publicly accessible image URL. Do not make private captures public just to display them. Check whether the provider’s links are public, authenticated, or temporary, and whether the pages contain sensitive information.
Save to Drive and put a link in the cell
For an archive, create a Drive file from the image blob and write its ID or link to the sheet. DriveApp.createFile(blob) requires Drive authorization. Decide who should be able to access the file; creating a file does not establish a safe sharing policy for it.
References: Sheet image insertion and DriveApp createFile.
3. Add the Apps Script
In the spreadsheet, open Extensions → Apps Script. Add the following code. It demonstrates a complete row-processing loop and ScreenshotNeo request. The API key is read from Script Properties rather than stored in a visible cell or source code.
const SHEET_NAME = 'Screenshots';
const FIRST_DATA_ROW = 2;
const URL_COL = 1;
const STATUS_COL = 2;
const OUTPUT_COL = 3;
const ERROR_COL = 4;
const MAX_ROWS_PER_RUN = 10;
const API_URL = 'https://api.screenshotneo.com/v1/shot';
function captureNextBatch() {
const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
if (!sheet) throw new Error(`Missing sheet: ${SHEET_NAME}`);
const apiKey = PropertiesService.getScriptProperties().getProperty('SCREENSHOTNEO_API_KEY');
if (!apiKey) throw new Error('Set SCREENSHOTNEO_API_KEY in Apps Script Project Settings → Script Properties.');
const lastRow = sheet.getLastRow();
if (lastRow < FIRST_DATA_ROW) return;
const rowCount = lastRow - FIRST_DATA_ROW + 1;
const rows = sheet.getRange(FIRST_DATA_ROW, 1, rowCount, ERROR_COL).getValues();
let processed = 0;
for (let i = 0; i < rows.length && processed < MAX_ROWS_PER_RUN; i++) {
const rowNumber = FIRST_DATA_ROW + i;
const rawUrl = rows[i][URL_COL - 1];
const status = String(rows[i][STATUS_COL - 1] || '').trim().toLowerCase();
if (!rawUrl || status === 'done' || status === 'processing') continue;
let url;
try {
url = normalizeHttpUrl_(rawUrl);
} catch (err) {
writeResult_(sheet, rowNumber, 'error', '', String(err.message || err));
processed++;
continue;
}
sheet.getRange(rowNumber, STATUS_COL).setValue('processing');
sheet.getRange(rowNumber, ERROR_COL).clearContent();
try {
const response = UrlFetchApp.fetch(API_URL, {
method: 'get',
payload: { access_key: apiKey, url: url },
muteHttpExceptions: true
});
const code = response.getResponseCode();
const headers = response.getHeaders();
const contentType = String(headers['Content-Type'] || headers['content-type'] || '').toLowerCase();
const blob = response.getBlob();
if (code < 200 || code >= 300) {
const detail = blob.getDataAsString().slice(0, 500);
throw new Error(`Screenshot API HTTP ${code}: ${detail}`);
}
if (!contentType.startsWith('image/')) {
const detail = blob.getDataAsString().slice(0, 500);
throw new Error(`Expected an image response, received ${contentType || 'unknown content type'}: ${detail}`);
}
if (blob.getBytes().length > 2 * 1024 * 1024) {
const file = DriveApp.createFile(blob.setName(`screenshot-row-${rowNumber}`));
sheet.getRange(rowNumber, OUTPUT_COL).setValue(file.getUrl());
writeResult_(sheet, rowNumber, 'done', file.getUrl(), 'Image exceeded the 2 MB sheet insertion limit; saved to Drive. Review Drive sharing permissions.');
} else {
// Images are over-grid objects; the cell contains a note for discoverability.
const image = sheet.insertImage(blob, OUTPUT_COL, rowNumber);
image.setAltTextTitle(`Screenshot for row ${rowNumber}`);
sheet.getRange(rowNumber, OUTPUT_COL).setNote('Screenshot inserted over the grid.');
sheet.getRange(rowNumber, STATUS_COL).setValue('done');
sheet.getRange(rowNumber, ERROR_COL).clearContent();
}
} catch (err) {
writeResult_(sheet, rowNumber, 'error', '', String(err.message || err));
}
processed++;
}
}
function normalizeHttpUrl_(value) {
const text = String(value).trim();
if (!text) throw new Error('URL is blank.');
let parsed;
try { parsed = new URL(text); } catch (_) { throw new Error('URL is malformed or missing a scheme. Include https:// or http://.'); }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Only http:// and https:// URLs are supported.');
}
if (!parsed.hostname || !parsed.hostname.includes('.')) throw new Error('URL must include a valid hostname.');
return parsed.toString();
}
function writeResult_(sheet, row, status, output, error) {
sheet.getRange(row, STATUS_COL).setValue(status);
if (output) sheet.getRange(row, OUTPUT_COL).setValue(output);
sheet.getRange(row, ERROR_COL).setValue(error || '');
}
The script uses Apps Script’s URL fetch payload encoding for a GET request. If you choose a different provider, adapt this request to that provider’s documented method, parameter names, credentials, and response format. Do not assume that another screenshot API accepts the same request.
Configure the API key
- In Apps Script, open Project Settings.
- Under Script Properties, add
SCREENSHOTNEO_API_KEYwith your API key as its value. - Save the project, return to the editor, and run
captureNextBatch. - Review the authorization prompt. URL Fetch requires the external request scope; spreadsheet access is also needed. The Drive fallback requests Drive authorization when used.
Apps Script may infer scopes. If the project has an explicit oauthScopes list in its manifest, include https://www.googleapis.com/auth/script.external_request and the spreadsheet scope needed for the operations. Add a Drive scope only if using the Drive file fallback. Google references: UrlFetchApp and Apps Script authorization.
4. Run, resume, and scale the batch
Run captureNextBatch manually for the first pass and inspect several rows. Rows marked done are skipped; rows marked error can be retried by clearing their status or setting it to pending. Rows left as processing after an interrupted execution should be reviewed and reset if they did not complete.
Keep MAX_ROWS_PER_RUN conservative, then adjust it based on observed provider latency, image sizes, Apps Script runtime, and the provider’s rate limits. Apps Script documents a six-minute execution limit. Its daily URL Fetch quota is listed as 20,000 calls for consumer accounts and 100,000 for Workspace accounts; these limits can change and are not a promise that a screenshot provider will accept or complete that many jobs. See Google Apps Script quotas.
For greater throughput, UrlFetchApp.fetchAll() can make multiple independent requests and return their responses. It does not remove provider rate limits or Apps Script quotas. Sequential requests are easier to pace and to map failures to rows. Parallel requests may finish sooner, but require more careful per-request error handling and provider throttling. Start sequentially unless measured processing time justifies batching.
For a large queue, use bounded runs and a checkpoint. The status column in this example acts as a simple checkpoint: each completed row is marked done, and a later run skips it. A time-driven trigger can run later batches, but check the account’s trigger behavior, quotas, and authorization requirements. Avoid doing slow screenshot work directly in an edit trigger without confirming its restrictions and execution behavior.
5. Troubleshoot common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| Authorization error on fetch | The script has not been authorized for external requests, or explicit scopes omit the URL Fetch scope. | Run from the editor to trigger authorization; add script.external_request to an explicit scope list. |
| API returns 401 or 403 | Missing, invalid, revoked, or unauthorized API key; provider access or plan restriction. | Check the Script Property name and value, then consult the provider’s current authentication and account documentation. Do not paste secrets into the sheet. |
| API returns 400 | Bad URL or request shape, unsupported option, or provider validation failure. | Check the URL and compare method, parameter names, and required fields with the provider’s current API docs. |
| API returns 429 | Provider rate limiting or account usage limit. | Reduce batch size and request pace. Retry later with a bounded delay; do not retry continuously in the same execution. |
| Expected image response, got JSON or HTML | The API returned an error document, job result, or hosted-link response rather than image bytes. | Inspect the response body and provider docs. Handle JSON/job responses according to the documented contract instead of inserting them as images. |
| Image insertion exceeds supported size | The response blob is over 2 MB. | Use a smaller output, a provider-supported resize/format option, or save the blob to Drive and put a controlled-access link in the sheet. |
| Script reaches the six-minute limit | Too many rows, slow pages, large output, or slow provider responses. | Lower MAX_ROWS_PER_RUN, preserve row-level status, and resume in another execution. |
| Site shows a block page or login | The site rejects automated access, needs authentication, or restricts the screenshot service’s browser. | Check the target site’s access requirements and the screenshot provider’s supported authentication options. Do not assume Apps Script’s Google-originated IP and the provider browser have the same network path. |
| Rows stay in processing | The execution stopped after marking a row but before writing the result. | Inspect the execution log and provider result if available; reset the row to pending and rerun it. |
| Image or Drive link is inaccessible to collaborators | Drive file permissions do not include them, or the external image URL is private or expired. | Set sharing deliberately according to your organization’s policy, or store a link that collaborators can access. Avoid making sensitive images public. |
6. Reliability, privacy, and cost considerations
- Retries: Retry transient timeouts and rate limits cautiously, with a small retry count and delay. Do not retry invalid URLs, authentication failures, or unsupported requests unchanged. Preserve the row’s error detail.
- Partial completion: Record status per row and never make success depend on every URL in the batch succeeding. A later run should continue past done rows.
- Rendering: Confirm browser engine, JavaScript wait behavior, viewport, full-page support, and authentication with the provider. Apps Script fetches the API; the provider’s browser does the page rendering.
- Output and storage: Check format, pixel dimensions, response size, link expiry, retention, access permissions, and whether the provider returns bytes or a URL. The Google blob insertion limit and public-URL requirement can determine the best output route.
- Privacy: Screenshots may expose personal, confidential, or account-specific content. Check organizational rules and the provider’s retention, processing location, and access terms before sending sensitive URLs or sharing output.
- Cost: Apps Script quotas and screenshot-provider billing are separate. Estimate the number of URLs, likely retries, desired rendering options, and storage needs against the provider’s current pricing and limits before a large run. No universal batch size or provider cost applies.
7. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. From Apps Script, the same request pattern can fetch a screenshot directly; see the API docs for current options and response details.
const response = UrlFetchApp.fetch('https://api.screenshotneo.com/v1/shot', {
method: 'get',
payload: {
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com'
},
muteHttpExceptions: true
});
const screenshotBlob = response.getBlob();
It removes cookie banners, popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. These features are available on every plan. You can also call the same API with cURL, Python, or Node.js:
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}`);
Sign up free for 1,000 screenshots a month, with no card required.
8. FAQ
Can Apps Script take a screenshot without an external service?
Apps Script’s documented URL Fetch service makes HTTP requests; it is not a documented browser renderer. For a rendered page image, use a browser-based screenshot service or run a browser you control elsewhere.
Can I put the screenshot inside a cell?
insertImage(blob, column, row) inserts an over-grid image. To keep a normal cell value, put a permitted image URL or a Drive link in the cell instead.
Should I use a time-driven trigger?
It can start later bounded runs, but confirm current trigger behavior, quotas, and authorization for your account. Keep the processing resumable either way.
Why does a site look different in the capture?
The result depends on the screenshot browser’s viewport, wait condition, authentication, and access to the page. Check those provider-specific controls and the target site’s behavior.


