Pages change without notice: a terms page gets a new clause, an article gets a quiet correction, a product page loses a claim. When someone later asks "what did it say then?", the live site cannot answer. Capture each page on a schedule as a full-page PNG for the look, a PDF for the text, and the served HTML for the source, then write all three with a hashed manifest under a dated path in a bucket you own. This page has the requests, a tested script, and the trade-offs of each file.
It belongs to the use cases section. The web archiving glossary entry compares PNG, PDF, MHTML and WARC in depth. If the archive must stand up as evidence, read compliance archiving next.
What one capture holds
Every capture goes into its own folder: archive/<host>/<yyyy>/<mm>/<dd>/<hhmmss>Z/.
| File | Made by | Answers | Limits |
|---|---|---|---|
page.png |
RenderScreenshot, full-page capture | "What did a reader see?" | Height capped at 16,000 px by default; text is pixels |
page.pdf |
RenderScreenshot, output.format: "pdf" |
"What did it say?" Text is selectable and searchable | Laid out for paper, so wide layouts reflow and may differ from the screen |
served.html |
Your script, a plain HTTP GET | "What did the server send?" | The page before JavaScript runs; client-rendered content is missing |
manifest.json |
Your script | When, which request IDs, SHA-256 of every file | Your clock; see the compliance page for stronger time proof |
The API does not return the DOM after scripts have run. For a news site or a docs site that renders on the server, served.html is close to what readers got. For a single-page app, the PNG and the PDF are the record, and the HTML only shows the app shell.
The request gallery
| Capture | Request body | Why |
|---|---|---|
| Full page, desktop | {"url": "https://example.com/terms", "capture": {"mode": "full_page", "scroll": {"simulate": true}}, "block": {"cookie_banners": true, "ads": true}} |
Scrolling first makes lazy images load before the capture |
| PDF on A4 | {"url": "https://example.com/terms", "output": {"format": "pdf"}, "pdf": {"paper": "a4", "print_background": true, "margin": "1cm"}} |
Searchable text, printable for a file |
| Mobile layout | {"url": "https://example.com/terms", "device": "iphone_14", "capture": {"mode": "full_page"}} |
Some claims appear only in the mobile layout |
| News article with a consent wall | add "page": {"click": "#accept-all"} or "block": {"cookie_banners": true} |
The archive should show the article, not the dialog |
| Wayback Machine snapshot | {"url": "https://web.archive.org/web/2016/https://example.com/", "page": {"hide": ["#wm-ipp-base"]}} |
A past version, without the archive toolbar (that element ID is the toolbar at the time of writing) |
Leave cache.available off for archiving. Each run must be a fresh render, not a copy of yesterday's. For pages longer than the height cap, the full-page screenshots guide explains the scroll settings, and splitting long screenshots covers pages that do not fit in one image.
How it works
flowchart LR C[cron: daily or weekly] --> A[archive.mjs] A -->|POST /v1/screenshot<br/>full_page PNG| R[RenderScreenshot] A -->|POST /v1/screenshot<br/>PDF| R A -->|GET page| T[Target site] R --> A T --> A A -->|PutObject x4<br/>archive/host/yyyy/mm/dd/time| B[(Your S3 / R2 bucket)]
The script
// archive.mjs: archive each URL as a full-page PNG, a PDF and the served HTML, under a dated path in your bucket. // Node 22+, npm i @aws-sdk/client-s3 // RS_API_KEY=... S3_BUCKET=web-archive node archive.mjs https://example.com/ https://example.com/terms import { createHash } from 'node:crypto'; import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com'; const BUCKET = process.env.S3_BUCKET; const s3 = new S3Client({ region: process.env.S3_REGION ?? 'auto', endpoint: process.env.S3_ENDPOINT, // set for R2, MinIO and other S3-compatible stores forcePathStyle: Boolean(process.env.S3_ENDPOINT), }); const sha256 = (buf) => createHash('sha256').update(buf).digest('hex'); // Non-ASCII header values arrive percent-encoded; plain ASCII may contain a bare '%'. const decode = (v) => { try { return decodeURIComponent(v); } catch { return v; } }; async function render(url, options) { const res = await fetch(`${API}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ url, ...options }), signal: AbortSignal.timeout(90_000), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(`${url}: HTTP ${res.status} ${data.error?.code ?? ''} (request ${data.request_id ?? 'n/a'})`); } const header = (name) => res.headers.get(name); return { bytes: Buffer.from(await res.arrayBuffer()), requestId: header('x-request-id'), title: header('x-screenshot-title') }; } async function put(key, body, contentType) { await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: body, ContentType: contentType })); return { key, bytes: body.length, sha256: sha256(body) }; } async function archive(url) { const at = new Date(); const [date, time] = at.toISOString().split('T'); const prefix = `archive/${new URL(url).hostname}/${date.replaceAll('-', '/')}/${time.slice(0, 8).replaceAll(':', '')}Z`; const common = { block: { cookie_banners: true, ads: true, chat_widgets: true }, wait: { until: 'networkidle', timeout: 45 } }; const png = await render(url, { ...common, capture: { mode: 'full_page', scroll: { simulate: true } } }); const pdf = await render(url, { ...common, output: { format: 'pdf' }, pdf: { paper: 'a4', print_background: true, margin: '1cm' } }); const page = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(30_000) }); // served HTML, before scripts run const html = Buffer.from(await page.arrayBuffer()); const files = [ { ...(await put(`${prefix}/page.png`, png.bytes, 'image/png')), request_id: png.requestId }, { ...(await put(`${prefix}/page.pdf`, pdf.bytes, 'application/pdf')), request_id: pdf.requestId }, { ...(await put(`${prefix}/served.html`, html, 'text/html')), http_status: page.status, final_url: page.url }, ]; const manifest = { url, captured_at: at.toISOString(), title: png.title ? decode(png.title) : null, files }; await put(`${prefix}/manifest.json`, Buffer.from(JSON.stringify(manifest, null, 2)), 'application/json'); return prefix; } let failed = 0; for (const url of process.argv.slice(2)) { try { console.log('archived', await archive(url)); } catch (err) { console.error(err.message); failed++; } } process.exitCode = failed ? 1 : 0;
Tested on 2026-10-18 with Node.js 22.18.0 and @aws-sdk/client-s3 3.1152.0. The render calls went to a local stub that returns the API's documented headers; the uploads went to a local MinIO server; the HTML fetch went to the real https://example.com/. The run wrote four objects under archive/example.com/2026/10/19/<time>Z/ (the folder date is UTC), with the request IDs, hashes, HTTP status and decoded page title in the manifest. A bad API key and a missing bucket each made it exit 1. We did not call the production API for this page.
Point S3_ENDPOINT at https://<account_id>.r2.cloudflarestorage.com for Cloudflare R2, or leave it unset for Amazon S3. The keys come from the standard AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY variables.
Or let the API write to your bucket
On paid plans, the storage option copies each fresh render to your S3-compatible bucket, so the image bytes never pass through your server:
{ "url": "https://example.com/terms", "capture": { "mode": "full_page", "scroll": { "simulate": true } }, "storage": { "path": "archive/{domain}/{year}/{month}/{day}/{hash}.{ext}", "acl": "private" } }
The template must contain {hash} and {ext}. The date parts come from the render. The upload runs in a background job after the response, with retries for throttling and server errors, and a failed upload never changes the API response. Use this route for image-only archives. Keep the script route when the HTML and a manifest must sit in the same folder as the files they describe.
Choosing a schedule
- Terms, privacy and pricing pages: daily. They change rarely, but the day of a change matters.
- News front pages: several times a day, since the lead story moves by the hour. Archive each article once after publication and again a day later to catch corrections.
- A full site: weekly, from the sitemap. Archive the pages, not every URL with tracking parameters.
When you need full replay of a site, with every request and response, use a WARC crawler such as Browsertrix or ArchiveBox. Screenshots and PDFs are the readable record; WARC is the forensic one. The glossary entry compares them.
What it costs
Each URL in the script makes two renders, the PNG and the PDF. The HTML fetch does not touch the API.
| Archive | Renders per month | Plan that covers it |
|---|---|---|
| 1,000 pages, once a month | 2,000 | Starter, 2,500 credits for $10 |
| 150 pages, daily | about 9,300 | Growth, 12,000 credits for $40 |
| 1,000 pages, daily, PNG only | about 31,000 | Scale, 60,000 credits for $100 |
For 150 pages a day, one archived capture (two files) costs about $0.0086. Check the pricing page for current numbers.
Next steps
Get a free API key, create a bucket, and run node archive.mjs against three pages you care about. Then turn on versioning or a retention lock on the bucket, so an archive cannot be quietly overwritten.