Website Archiving API for Dated Page Snapshots

Archive web pages as a full-page PNG, a PDF and the served HTML, with a hashed manifest under a dated path in your own S3 or R2 bucket. Tested script included.

By Simon Chiu · Updated

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.

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.

Frequently asked questions

Does the API return the rendered HTML of the page?
No. It returns images and PDFs (or a JSON object that links to them), plus the page title, description and favicon. The script fetches the HTML the server sends, which is the page before scripts run. The PDF keeps the visible text in selectable form, so it is the searchable copy of the rendered page.
Can I archive how a page looked in the past?
Only from the day you start. For older versions, capture a snapshot from the Internet Archive's Wayback Machine, such as https://web.archive.org/web/2016/https://example.com/, and hide its toolbar with page.hide.
Should I let the API upload to my bucket or upload the files myself?
The storage option copies each image or PDF to your bucket in a background job on paid plans, with a date-based path template. Upload them yourself when you also need the HTML and a manifest in the same folder, or when you are on the free plan.
What happens with very long pages?
A full-page capture stops at capture.scroll.max_height, 16,000 pixels by default. The PDF is not limited in the same way, because the page flows onto as many sheets as it needs.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.