Website Compliance Archiving with Chain of Custody

Website compliance archiving with fresh captures, request IDs, hashes and a locked bucket, plus an honest look at what makes a web capture hold up as evidence.

By Simon Chiu · Updated

A regulator asks what your site said about a product on 14 March. A customer claims your terms page promised a refund. A moderator removed a post and the author disputes what it said. In each case a screenshot saved to someone's desktop is weak: nobody can say when it was taken, by whom, or whether it was edited. Capture with a fresh render, record the request, the response headers, the times and a SHA-256 hash in a manifest, and write both files to a bucket with a compliance-mode retention lock. Then the question "can you prove this file is what the page showed?" has a documented answer.

This page extends website archiving, which covers routine dated snapshots. Here each capture is a record that someone may challenge.

What makes a web capture hold up

We are not lawyers, and this is not legal advice. Rules differ by country and by forum, so take this list to your counsel. Across systems, the same four questions come up when a web capture is challenged:

Question What answers it in this workflow What does not
Is it authentic? Was it captured from that URL by a known process? The request body, the X-Request-Id, a written procedure, a named operator The image alone
Is it intact? Has it changed since capture? SHA-256 in the manifest, a store checksum, a compliance-mode lock A file on a shared drive
When was it made? Your clock's requested_at and received_at, the API's Date header, and optionally an RFC 3161 timestamp from a third party The file's modified date
Is it complete and in context? Full-page capture, the URL, the viewport and locale used A cropped excerpt

In United States federal courts, for example, Federal Rule of Evidence 901 requires evidence that an item is what you claim. Rules 902(13) and 902(14) let a qualified person certify records made by an electronic process and copies verified by hash. A manifest like the one below is the kind of record such a certification describes. Whether it is enough is for the court.

Two honest limits apply to any server-side capture. It shows what a browser in a data center received, which may differ from what a given user saw, because of personalization, A/B tests or IP-based regions. And it does not prove who wrote the content, only that the URL served it at that time.

Record Request body additions Why
Full page as served "capture": {"mode": "full_page", "scroll": {"simulate": true}}, "cache": {"refresh": true} The whole page, always a new render
One user comment on your own site "capture": {"mode": "element", "selector": "[data-comment-id=\"8812\"]", "selector_required": true} The disputed item; fails instead of capturing the wrong area
Times on the page in UTC "browser": {"timezone": "UTC"} Pages that turn timestamps into local time show UTC, so the record has one clock
Signed-in page, such as an account statement "network": {"cookies": [...]} Session values are filtered from the request logs; see the authenticated pages guide
Text copy for review "output": {"format": "pdf"} Selectable text for search and quotation

Do not set cache.available. With it, an identical request inside the TTL returns an earlier render, which is the wrong answer for evidence. The script below also stops if X-Cache-Hit says true.

Chain of custody

flowchart LR
  O[Operator or job<br/>case ID] --> E[evidence.mjs]
  E -->|POST /v1/screenshot<br/>cache.refresh| R[RenderScreenshot]
  R -->|PNG + X-Request-Id, Date| E
  E -->|SHA-256, manifest| E
  E -->|PutObject, COMPLIANCE lock| B[(Bucket with<br/>Object Lock)]
  E -.->|optional| T[RFC 3161<br/>timestamp authority]

The script

// evidence.mjs: capture a page as evidence and store it with a manifest under a retention lock.
// Node 22+, npm i @aws-sdk/client-s3. The bucket must have S3 Object Lock enabled.
//   RS_API_KEY=... S3_BUCKET=evidence CASE_ID=2026-0142 node evidence.mjs https://example.com/post/123
import { createHash, randomUUID } from 'node:crypto';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';

const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const RETAIN_DAYS = Number(process.env.RETAIN_DAYS ?? 2555); // about 7 years; set by your policy, not by us
const s3 = new S3Client({ region: process.env.S3_REGION ?? 'us-east-1', endpoint: process.env.S3_ENDPOINT, forcePathStyle: Boolean(process.env.S3_ENDPOINT) });
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
const KEEP_HEADERS = ['date', 'content-type', 'x-request-id', 'x-cache-hit', 'x-screenshot-width', 'x-screenshot-height', 'x-screenshot-title', 'x-credits-used'];

const url = process.argv[2];
const captureId = randomUUID();
const prefix = `cases/${process.env.CASE_ID ?? 'unfiled'}/${captureId}`;
const retainUntil = new Date(Date.now() + RETAIN_DAYS * 86_400_000);

async function lockedPut(key, body, contentType) {
  await s3.send(new PutObjectCommand({
    Bucket: process.env.S3_BUCKET, Key: key, Body: body, ContentType: contentType,
    ChecksumAlgorithm: 'SHA256', // the store verifies the bytes it received
    ObjectLockMode: 'COMPLIANCE', ObjectLockRetainUntilDate: retainUntil, // nobody can delete or overwrite before this date
  }));
  return { key, bytes: body.length, sha256: sha256(body) };
}

try {
  // The request as sent, minus your API key. Credentials for the target belong in network.auth, which logs filter.
  const request = {
    url,
    viewport: { width: 1280, height: 800 },
    capture: { mode: 'full_page', scroll: { simulate: true } },
    browser: { timezone: 'UTC', locale: 'en-US' }, // on-page dates render in UTC
    wait: { until: 'networkidle', timeout: 45 },
    output: { format: 'png' },
    cache: { refresh: true }, // always a fresh render, never an earlier copy
  };
  const requestedAt = new Date().toISOString();
  const res = await fetch(`${API}/v1/screenshot`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(request),
    signal: AbortSignal.timeout(90_000),
  });
  if (!res.ok) throw new Error(`capture failed: HTTP ${res.status} ${await res.text()}`);
  if (res.headers.get('x-cache-hit') === 'true') throw new Error('served from cache; evidence must be a fresh render');
  const png = Buffer.from(await res.arrayBuffer());
  const receivedAt = new Date().toISOString();

  const image = await lockedPut(`${prefix}/capture.png`, png, 'image/png');
  const manifest = {
    capture_id: captureId,
    case_id: process.env.CASE_ID ?? null,
    operator: process.env.OPERATOR ?? process.env.USER ?? null,
    url,
    requested_at: requestedAt,
    received_at: receivedAt,
    renderer: { service: 'RenderScreenshot', endpoint: 'POST /v1/screenshot', request },
    response_headers: Object.fromEntries(KEEP_HEADERS.map((h) => [h, res.headers.get(h)]).filter(([, v]) => v !== null)),
    files: [image],
    retain_until: retainUntil.toISOString(),
  };
  const m = await lockedPut(`${prefix}/manifest.json`, Buffer.from(JSON.stringify(manifest, null, 2)), 'application/json');
  console.log(`${prefix} image sha256=${image.sha256} manifest sha256=${m.sha256}`);
} catch (err) {
  console.error(err.message);
  process.exitCode = 1;
}

Tested on 2026-10-18 with Node.js 22.18.0 and @aws-sdk/client-s3 3.1152.0. The render call went to a local stub that returns the API's documented headers. The uploads went to a local MinIO server with Object Lock, which reported both objects in COMPLIANCE mode with 2,554 days left. A bucket without Object Lock refused the upload, and the script exited 1, as it did for a bad API key. We did not call the production API for this page.

Three details matter. The retention period comes from your policy. Seven years is only an example, and compliance mode cannot be shortened, even by the account root user, so test with a short period first. The manifest stores the request body without your API key. Keep target credentials in network.auth or network.cookies, because the request logs filter those values but store custom headers as sent. And X-Request-Id ties your record to ours. The response headers reference lists every header the API sends.

Independent time proof

Your server's clock is a claim you make about yourself. An RFC 3161 timestamp authority signs the manifest's hash with its own time, and anyone can verify that signature later. With OpenSSL:

openssl ts -query -data manifest.json -sha256 -cert -out manifest.tsq
curl -s -H 'Content-Type: application/timestamp-query' --data-binary @manifest.tsq https://freetsa.org/tsr -o manifest.tsr
openssl ts -verify -data manifest.json -in manifest.tsr -CAfile cacert.pem -untrusted tsa.crt

We ran these commands on 2026-10-18 with OpenSSL 4.0.3 against FreeTSA, using its published cacert.pem and tsa.crt. The reply was granted and verified as OK. The same reply checked against a changed file failed with message imprint mismatch. FreeTSA is a free service. For regulated work, pick an authority your counsel accepts and store the .tsr file next to the manifest.

Storage that cannot be edited

  • Amazon S3 Object Lock in compliance mode blocks deletes and overwrites of a version until its retain-until date. AWS publishes a third-party assessment of Object Lock for SEC Rule 17a-4 record-keeping. Object Lock needs versioning on the bucket.
  • Cloudflare R2 applies retention through bucket lock rules that you configure on the bucket. Set a rule with your retention period, and drop the two ObjectLock fields from the script, which are written for S3.
  • Your own copy of the logs. Store the console output and job logs of each run with the case, so the operator and time of each capture are on record.

What it costs

Each capture is one render. A retry after a failed render costs nothing.

Program Renders per month Plan that covers it
A legal team, 100 captures for open cases about 100 Free, 150 credits a month
40 regulated pages daily, PNG and PDF about 2,480 Starter, 2,500 credits for $10
Moderation team, 300 user-content captures a day about 9,300 Growth, 12,000 credits for $40

At 300 captures a day, each record costs about $0.0043. Current prices are on the pricing page.

Next steps

Create a free API key, make a test bucket with Object Lock and a one-day retention, and run the script against a page you own. Then write down the procedure: who runs it, for which cases, and where the files live. That document is part of your evidence too.

Frequently asked questions

Is a screenshot from an API admissible as evidence?
It can be, but no tool makes a capture admissible. A court looks at who made it, how, when, and whether it is unchanged. A documented process, a hash and an operator who can explain both are what count. Ask your counsel about your jurisdiction.
Does RenderScreenshot record the target page's HTTP status and headers?
No. Responses carry the API's own headers, such as X-Request-Id, and the page title, description and favicon. Fetch the target's status and headers yourself if you need them, and record that this was a separate request.
Can I rely on the dashboard request logs as my audit trail?
Treat them as a second record outside your systems, useful to match a request ID. Do not treat them as your retention store. Keep your own manifest and files under your retention policy.
Can I archive social media posts this way?
Public posts that load without a login, yes. Many platforms show a login wall to logged-out browsers. For your own company accounts, use the platform's export or an archiving vendor connected to its API.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.