How to Schedule Website Screenshots for a Client Approval Archive
Build a recurring screenshot archive for client approvals with Playwright, GitHub Actions, stable filenames, and retention you control.
To schedule website screenshots for a client approval archive, combine three separate pieces: a browser script that captures agreed pages, a scheduler that runs it, and storage where reviewers can find the files for the required retention period. A practical self-managed setup is Playwright plus GitHub Actions and workflow artifacts. GitHub schedules are not exact-time guarantees, and artifacts expire according to configured retention, so choose storage to match the archive period you promised the client.
This guide builds a reusable Playwright capture script, schedules it with GitHub Actions, and covers the naming, consistency, access, and troubleshooting details that make the archive useful for approvals.
1. Define what the client is approving
Before writing the script, agree on the evidence and the review process. A screenshot preserves what a page looked like at a point in time; it does not by itself record approval, explain a change, or guarantee that the capture environment matches a visitor’s.
- Pages: list the exact URLs and give each one a stable short name.
- Capture area: choose the visible viewport, full page, or a specific element. Full-page captures can be very tall and may expose content below the fold that the client did not expect to review.
- Viewport: agree on width and height, and whether a separate mobile capture is required.
- Cadence: specify how often screenshots should be taken and what happens after a deployment or requested recapture.
- Page state: identify any consent banner, login, interaction, or dynamic content that affects the evidence. Use authorized test credentials and avoid putting secrets in the repository.
- Archive and access: pick a storage destination, access controls, retention period, and a clear way for the client to approve or request changes.
- Context: record capture time and timezone, viewport, browser version, URL, and deployment or build identifier when available.
These details are practical conventions for an approval archive rather than requirements imposed by Playwright or GitHub. They help reviewers understand what a file represents and make recaptures easier to compare.
2. Capture the pages with Playwright
Playwright can capture screenshots and compare screenshots in its test assertions. Its documentation warns that rendering can vary with the operating system, browser version, settings, hardware, power source, and headless mode. For repeatable records, run the same browser and operating system image each time where practical, and store the browser version with the capture metadata.
Start with a Node.js project and install Playwright. Commit the lockfile so the dependency versions are controlled.
npm init -y
npm install --save-dev playwright
npx playwright install chromium
Create scripts/capture.mjs. The example takes a full-page screenshot of each configured URL, waits for the page load event and then for network quiet, and emits a JSON manifest alongside the images. The URLs are environment-configurable so a workflow can supply the client-specific list without editing code.
import { chromium } from 'playwright';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
const rawTargets = process.env.CAPTURE_TARGETS;
if (!rawTargets) {
throw new Error('Set CAPTURE_TARGETS to a JSON array of {name, url} objects');
}
const targets = JSON.parse(rawTargets);
if (!Array.isArray(targets) || targets.length === 0) {
throw new Error('CAPTURE_TARGETS must be a non-empty JSON array');
}
for (const target of targets) {
if (!target.name || !target.url) throw new Error('Each target needs name and url');
const parsed = new URL(target.url);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(`Unsupported URL protocol for ${target.name}`);
}
}
const outputDir = process.env.OUTPUT_DIR ?? 'screenshots';
const width = Number(process.env.VIEWPORT_WIDTH ?? 1440);
const height = Number(process.env.VIEWPORT_HEIGHT ?? 1000);
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
throw new Error('Viewport dimensions must be positive integers');
}
await mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width, height } });
const manifest = [];
const safeName = (value) => value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-|-$/g, '');
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
try {
for (const target of targets) {
const page = await context.newPage();
const startedAt = new Date().toISOString();
try {
const response = await page.goto(target.url, {
waitUntil: 'load',
timeout: 60000
});
// Network idle is useful for many pages, but sites with continuous polling
// may never reach it. The timeout is handled by the per-page error record.
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
const file = `${safeName(target.name)}-${stamp}.png`;
await page.screenshot({ path: path.join(outputDir, file), fullPage: true });
manifest.push({
name: target.name,
url: target.url,
capturedAt: startedAt,
completedAt: new Date().toISOString(),
timezone: 'UTC',
viewport: { width, height },
browser: 'Chromium (Playwright)',
httpStatus: response?.status() ?? null,
file,
error: null
});
console.log(`Captured ${target.name}: ${file}`);
} catch (error) {
manifest.push({
name: target.name,
url: target.url,
capturedAt: startedAt,
completedAt: new Date().toISOString(),
timezone: 'UTC',
viewport: { width, height },
file: null,
error: String(error)
});
console.error(`Failed ${target.name}: ${error}`);
} finally {
await page.close();
}
}
} finally {
await context.close();
await browser.close();
}
await writeFile(path.join(outputDir, 'manifest.json'), JSON.stringify({
generatedAt: new Date().toISOString(),
deploymentId: process.env.DEPLOYMENT_ID ?? null,
captures: manifest
}, null, 2));
if (manifest.some((entry) => entry.error)) process.exitCode = 1;
Set CAPTURE_TARGETS to JSON such as [{"name":"home","url":"https://example.com/"},{"name":"pricing","url":"https://example.com/pricing"}]. Do not include private URLs or credentials in a public repository. For authenticated pages, load credentials from the CI secret store and supply them through a supported login flow; avoid printing them in logs or committing browser state files.
Choose a wait strategy per page
The example waits for load and makes a bounded attempt at networkidle. There is no universal wait condition: analytics, chat, polling, animations, client-side rendering, and late-loading fonts can all affect the final image. For a page with a known ready marker, wait for that selector instead:
await page.goto(target.url, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.locator('[data-approval-ready="true"]').waitFor({ timeout: 30000 });
await page.screenshot({ path: file, fullPage: true });
If the page has a known animation or transient notification that should not be part of the evidence, handle it deliberately in the capture script. Do not add arbitrary long sleeps as a substitute for identifying the state that means the page is ready.
3. Schedule captures with GitHub Actions
Create .github/workflows/client-screenshots.yml. The workflow runs the capture script on a cron schedule and uploads the output as an artifact for review. The schedule targets the latest commit on the default branch. GitHub permits a minimum interval of five minutes, but runs can be delayed during high load; GitHub specifically advises that the schedule event may be delayed. Avoid scheduling exactly on the hour where possible.
name: Client screenshot archive
on:
schedule:
# Intended cadence: daily at 02:17 UTC. Scheduled runs can be delayed.
- cron: '17 2 * * *'
workflow_dispatch:
permissions:
contents: read
a jobs:
capture:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CAPTURE_TARGETS: '[{"name":"home","url":"https://example.com/"},{"name":"pricing","url":"https://example.com/pricing"}]'
VIEWPORT_WIDTH: '1440'
VIEWPORT_HEIGHT: '1000'
DEPLOYMENT_ID: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: node scripts/capture.mjs
- uses: actions/upload-artifact@v4
if: always()
with:
name: client-screenshots-${{ github.run_id }}
path: screenshots/
if-no-files-found: warn
retention-days: 30
Correct the YAML key in the example to jobs: (the workflow section must be named jobs), so the complete workflow has a valid GitHub Actions structure. The retention-days value is an example; set it to the review window you need and check the repository, organization, or enterprise retention settings.
GitHub’s current documentation gives 90 days as the default retention for build logs and artifacts, subject to settings. The Playwright CI example configures a 30-day artifact retention. Neither value is a permanent archive guarantee. If the client needs longer retention, export or copy the files and manifest into an access-controlled storage destination with the required lifecycle policy.
Run a capture on demand
The workflow_dispatch trigger lets an authorized user start a run manually from GitHub Actions. Use it for approval recaptures or after a deployment, and label the run or manifest with the deployment identifier. A manual trigger complements the recurring schedule; it does not replace an explicit client approval process.
4. Store evidence so a reviewer can use it
Use predictable names, preserve the manifest, and make the approval action clear. A filename convention such as client-page-viewport-2026-10-04T02-17-00Z.png tells a reviewer which page and capture time the image represents. Keep the original URL and other context in the manifest even if the filename is shorter.
- Keep captures grouped by client, project, page, and date or workflow run.
- Restrict access to client-sensitive screenshots. Use an appropriate trusted artifact destination or encrypt uploaded reports and traces; Playwright’s CI guidance calls out these precautions.
- Decide how long files must remain available, then configure retention or export them to longer-lived storage.
- Give reviewers a stable location and instructions to approve, comment, or request a recapture.
- Keep a failed-page record in the manifest so a missing screenshot is not mistaken for a successful capture.
Artifacts are convenient for sharing the results of a workflow run, but a workflow artifact is not automatically a client-facing approval portal. Confirm that reviewers can access the destination and that its retention matches the agreement.
5. Decide whether you need visual regression too
A timestamped archive and visual regression answer different questions. An archive lets someone inspect what the page looked like at a time. A baseline comparison highlights visual differences against an accepted image. Playwright supports screenshot assertions, and Percy documents a Playwright integration. Add comparison tooling when the client needs change detection or a review of diffs; do not assume that saving screenshots creates a baseline approval workflow.
6. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The scheduled run does not start at the stated minute | GitHub Actions schedule runs can be delayed, particularly during high load. | Treat cron as an intended cadence rather than an exact-time clock. Schedule away from the top of the hour and use a different system if the archive requires strict timing. |
| No scheduled runs after repository inactivity | GitHub documents that scheduled workflows in public repositories are automatically disabled after 60 days of repository inactivity. | Check the workflow and repository activity, then re-enable or trigger the workflow as needed. |
CAPTURE_TARGETS parsing fails |
The environment variable is not valid JSON or is empty. | Use a JSON array of objects with non-empty name and url fields. Keep YAML quoting around the JSON value. |
| Navigation times out | The origin is slow, unreachable from the runner, or waiting never finishes. | Check the URL and origin availability. Use a suitable waitUntil condition, a page-specific ready selector, and a bounded timeout. Do not make every timeout infinite. |
| Capture finishes but content is missing | Client-side rendering or lazy content was not ready at capture time. | Wait for a page-specific ready marker or required element. For below-fold content, verify that the chosen full-page capture behavior and page itself load it. |
| Network-idle wait repeatedly times out | Polling, analytics, or other long-lived requests keep the page active. | Use a known content selector or a more appropriate lifecycle event rather than relying on network idle for that page. |
| Images differ between runs | Browser, operating system, fonts, hardware, headless settings, dynamic content, or animations changed. | Keep the runner and browser version consistent, record them, wait for deterministic page state, and control or mask genuinely variable content when suitable. |
| Artifact upload says no files found | The script failed before writing images, output path differs, or all targets failed. | Check the capture step and output directory. Preserve the manifest and logs; fix the path or capture failure before treating the run as evidence. |
| Old screenshots disappear | Artifact retention expired or platform settings imposed a shorter period. | Set retention to the required review window and export captures to durable, access-controlled storage when that window exceeds artifact retention. |
| Client cannot open the archive | Workflow artifacts may require repository access and are not necessarily a suitable client review destination. | Choose a stable destination, grant the intended reviewers access, and test the approval path with the client. |
7. Performance, reliability, and cost
Runtime: total duration grows with the number of URLs and each page’s load and readiness time. Capture pages sequentially initially; this is simpler and avoids creating a burst of browser traffic against the client’s site. If runtime becomes a problem, measure first and add bounded concurrency carefully.
Reliability: use explicit timeouts, record each URL’s outcome, and fail the job when a capture fails so the archive does not silently look complete. Scheduled runs may be delayed. For time-sensitive approvals, include a manual dispatch path and state the expected timing to the client.
Consistency: keep the runner image, browser installation, viewport, and capture settings stable. Record enough metadata to explain differences. A screenshot can vary across machines and browser versions even when the URL is unchanged.
Storage and cost: the reviewed documentation establishes artifact retention behavior but does not establish a price for this workflow. Account for the compute and storage terms of the GitHub plan and any external archive you choose. Large full-page images and frequent captures consume more storage; use a cadence and capture area that match the approval need. Avoid promising permanent retention unless the selected storage actually provides it.
8. Or skip the browser setup
If you do not want to maintain Playwright and a scheduler, ScreenshotNeo is a website screenshot API and MCP server. A GET request takes a URL and returns an image or PDF. It is useful for scheduled archives where you want capture handling without managing a browser installation. The API parameters used by other screenshot APIs also work, which can make switching straightforward.
Use the API from your scheduled job; the scheduler and archive destination still determine when captures run and how long the files are retained. See the ScreenshotNeo API documentation for options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
timeout=90,
)
r.raise_for_status()
with open("shot.webp", "wb") as image:
image.write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots, and 1,000 screenshots a month are free with no card; paid plans start at $5 for 3,000. ScreenshotNeo is listed at screenshotneo.com.
Sign up for ScreenshotNeo free: 1,000 screenshots a month, no card required.
FAQ
Can I run this every five minutes?
GitHub’s documented minimum schedule interval is five minutes, but high load can delay runs. Do not treat it as a precise clock.
Will a screenshot prove the client approved the page?
No. It records a visual state. Keep an explicit approval or comment process alongside the archive.
Should the archive use full-page screenshots?
Use full-page captures when the client must review below-the-fold content. Use a fixed viewport when the approval concerns the initial visible experience or when predictable image dimensions matter.
Can I keep artifacts forever?
Artifacts have configured retention. Export them to a suitable long-lived archive if the client needs access beyond that retention window.


