Archiving web pages for compliance: screenshots, PDFs, hashes and trusted timestamps
Originally published on siftwright.com. Sooner or later someone asks "what did that page say on the 3rd of March?" It might be your own pricing page in a customer dispute, a partner's advertising claim, the version of y
Originally published on siftwright.com.
Sooner or later someone asks "what did that page say on the 3rd of March?" It might be your own pricing page in a customer dispute, a partner's advertising claim, the version of your terms a user accepted, or a competitor's comparison table that a lawyer wants to see. A browser bookmark will not help, because the page has changed since. A screenshot pasted into a ticket helps a bit, but anyone can edit a PNG, and nothing shows when it was taken.
This post builds a small archiver in Python that produces a record you can defend. For every capture it saves:
- the raw HTML and HTTP response headers, exactly as the server sent them,
- a full-page PNG and an A4 PDF of the rendered page,
- a manifest with the SHA-256 hash of every file, the URLs, the HTTP status and the capture settings,
- an RFC 3161 timestamp token over the manifest, signed by an independent timestamping authority.
Then a second script checks the archive later and fails loudly if a single byte has changed.
What was tested: we ran both scripts on 13 November 2026 against a public page, with real timestamps from FreeTSA and tamper tests. The rendering step in that run used a local Chromium with the same settings, not the /v1/screenshot endpoint shown in the code, because we had no test key available that day. The request and response shape in the code match our API docs. Everything after the capture (hashing, manifest, timestamp, verification) ran exactly as written.
This is engineering guidance, not legal advice. What counts as acceptable evidence depends on your jurisdiction and on who will look at it. The aim here is to make tampering detectable and the time of capture provable, which is what most reviewers ask first.
Why one screenshot is not enough
Each format answers a different question, and each has a weakness on its own:
| Artifact | Answers | Weakness |
|---|---|---|
| Raw HTML + headers | What did the server send? | Doesn't show what a person saw. Scripts, CSS and images load later and may change. |
| Full-page PNG | What did the page look like? | Text is not searchable. One viewport, one moment. |
| A readable, printable, searchable copy | Uses print CSS, so layout can differ from the screen. | |
| Manifest + hashes | Has anything changed since capture? | A hash proves integrity, not time. |
| Timestamp token | Did this exact manifest exist at time T? | Only covers what is in the manifest. |
Together they cover each other's gaps. The HTML shows the text and markup, the PNG shows the visual, the PDF is what you hand to a person, and the hashes plus timestamp tie them to a moment.
How the trusted timestamp works
RFC 3161 is the standard behind the timestamps in signed PDFs and code signing. You send a timestamping authority (TSA) only a hash of your data, never the data. The TSA signs a token that says "this hash existed at this time", using its own clock and certificate. Later anyone with the TSA's certificate can check the token against your file with openssl, with no account and no network call.
The trick that keeps this cheap is to timestamp one file, the manifest. Because the manifest lists the SHA-256 of every other file, one token covers the whole capture: change the PNG and its hash no longer matches the manifest, change the manifest and the token no longer matches.
We used FreeTSA because it is free and needs no signup. For records that matter, use a TSA your organisation or your counterparty trusts (many certificate authorities run one, often included with code-signing certificates), and consider timestamping with two of them.
The archiver
You need Python 3.9+ and the openssl command line tool, which is already on most Linux and macOS machines. Set SIFTWRIGHT_API_KEY for the capture step.
import hashlib, json, os, subprocess, sys, urllib.request
from datetime import datetime, timezone
from pathlib import Path
API = "https://siftwright.com/v1/screenshot"
TSA = "https://freetsa.org/tsr" # any RFC 3161 TSA works
ARCHIVE = Path("archive")
UA = "ExampleCo-compliance-archiver/1.0 (+https://example.com/archiving)"
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def fetch_source(url):
"""The raw HTML and response headers, exactly as the server sent them."""
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=60) as r:
return {"status": r.status, "final_url": r.url,
"headers": dict(r.headers.items()), "body": r.read(),
"fetched_at": now()}
def capture(url, fmt):
"""A rendered PNG or PDF of the page from the screenshot API."""
body = {"url": url, "format": fmt, "fullPage": True,
"viewportWidth": 1280, "pdfPageFormat": "A4", "blockCookieBanners": True}
req = urllib.request.Request(API, json.dumps(body).encode(), {
"Authorization": f"Bearer {os.environ['SIFTWRIGHT_API_KEY']}",
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=300) as r:
res = json.load(r)["results"][0]
if res.get("status") != "ok":
raise RuntimeError(f"{fmt} capture failed: {res.get('error', res.get('status'))}")
with urllib.request.urlopen(res["fileUrl"], timeout=120) as r: # signed link, no key
return r.read(), now()
def timestamp(path: Path):
"""Ask an RFC 3161 TSA to sign the SHA-256 of a file. Only the hash leaves your machine."""
tsq, tsr = path.with_suffix(".tsq"), path.with_suffix(".tsr")
subprocess.run(["openssl", "ts", "-query", "-data", str(path), "-sha256",
"-cert", "-out", str(tsq)], check=True)
req = urllib.request.Request(TSA, tsq.read_bytes(),
{"Content-Type": "application/timestamp-query"})
with urllib.request.urlopen(req, timeout=60) as r:
tsr.write_bytes(r.read())
return tsr
def archive(url, case_id):
folder = ARCHIVE / case_id / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
folder.mkdir(parents=True)
files = {}
src = fetch_source(url)
(folder / "page.html").write_bytes(src["body"])
files["page.html"] = {"sha256": sha256(src["body"]), "bytes": len(src["body"]),
"captured_at": src["fetched_at"]}
for fmt in ("png", "pdf"):
data, at = capture(url, fmt)
name = f"page.{fmt}"
(folder / name).write_bytes(data)
files[name] = {"sha256": sha256(data), "bytes": len(data), "captured_at": at}
manifest = {
"case_id": case_id,
"requested_url": url,
"final_url": src["final_url"],
"http_status": src["status"],
"response_headers": src["headers"],
"files": files,
"capture_settings": {"viewportWidth": 1280, "fullPage": True,
"pdfPageFormat": "A4", "blockCookieBanners": True},
"archiver": UA,
"created_at": now(),
}
mpath = folder / "manifest.json"
mpath.write_text(json.dumps(manifest, indent=2, sort_keys=True))
timestamp(mpath)
return folder
if __name__ == "__main__":
print(archive(sys.argv[1], sys.argv[2]))
Run it with a URL and a case or ticket ID:
python archive.py https://www.iana.org/help/example-domains case-0042
That produces a folder per capture:
archive/case-0042/20261113T162209Z/
manifest.json 2,292 bytes
manifest.tsq 70 bytes (the request sent to the TSA)
manifest.tsr 4,643 bytes (the signed timestamp token)
page.html 6,774 bytes
page.pdf 103,830 bytes
page.png 119,743 bytes
The whole run took 3.9 seconds for that small page, with the TSA round trip under a second. A heavier page will spend most of its time in rendering.
Design choices worth copying
One folder per capture, named by UTC time. Never overwrite an archive. If you capture the same page again, you get a second folder, and the two can be compared.
The raw HTML comes from your own request, not from the renderer. It is the cheapest artifact, it keeps the response headers (including the server's own Date header, a useful second clock), and it records redirects: final_url will differ from requested_url if the page moved. In our run the server's Date header said 16:22:09, our manifest was created at 16:22:12 and the TSA stamped 16:22:13. Three independent clocks within four seconds of each other is a good sign that nothing odd happened.
Identify your archiver honestly. The User-Agent names your organisation and links to a page about why you archive. Some sites serve different content to unusual user agents, so if you need to prove what a normal visitor saw, the rendered PNG and PDF from a real browser are your primary evidence and the HTML is supporting material.
Record the capture settings. Viewport width, full-page mode and cookie-banner handling all change the result. Writing them into the manifest means a reviewer can see how the page was rendered, and a later capture can repeat it.
sort_keys=True on the manifest. It makes manifests diffable and stable, which helps when you compare two captures of the same page.
Verifying an archive later
Verification needs two public files from the TSA: its root certificate (cacert.pem) and its signing certificate (tsa.crt). For FreeTSA they are linked from their site. Save copies next to your archives, because you want to be able to verify in ten years even if the website has moved.
import hashlib, json, subprocess, sys
from pathlib import Path
def verify(folder, ca="cacert.pem", tsa_cert="tsa.crt"):
folder = Path(folder)
manifest = json.loads((folder / "manifest.json").read_text())
ok = True
for name, meta in manifest["files"].items():
actual = hashlib.sha256((folder / name).read_bytes()).hexdigest()
match = actual == meta["sha256"]
ok &= match
print(f"{'OK ' if match else 'FAIL'} {name} {actual[:16]}...")
# Proves manifest.json (and so every hash in it) existed unchanged at the TSA's time
r = subprocess.run(["openssl", "ts", "-verify", "-data", str(folder / "manifest.json"),
"-in", str(folder / "manifest.tsr"), "-CAfile", ca,
"-untrusted", tsa_cert], capture_output=True, text=True)
print((r.stdout + r.stderr).strip())
ok &= r.returncode == 0
reply = subprocess.run(["openssl", "ts", "-reply", "-in", str(folder / "manifest.tsr"),
"-text"], capture_output=True, text=True).stdout
print(next(l for l in reply.splitlines() if l.startswith("Time stamp:")))
return ok
if __name__ == "__main__":
sys.exit(0 if verify(sys.argv[1]) else 1)
On the untouched archive:
OK page.html 3b8669627aee51b9...
OK page.pdf df04b80fe65335f9...
OK page.png e9018bc892a27763...
Verification: OK
Time stamp: Nov 13 16:22:13 2026 GMT
(openssl also prints a warning that tsa.crt "is not a CA cert". That is expected: it is the TSA's signing certificate, passed with -untrusted so openssl can build the chain to the root.)
We then ran two tamper tests on copies of the folder.
Changing the image. Appending a single byte to page.png made that line FAIL page.png ea5457b616c3f749... and the script exited with status 1. The timestamp itself still verified, correctly, because the manifest was untouched. The manifest is what tells you the PNG changed.
Changing the manifest. Editing "http_status": 200 to 404 left every file hash matching, but openssl refused the token with message imprint mismatch and Verification: FAILED. Exit status 1 again. So you cannot quietly rewrite the record either.
That is the property you want: any change, to the evidence or to the description of it, is detectable by anyone with openssl and two certificate files.
What we learned from testing
Re-capturing does not reproduce the hash, and that is fine. We rendered the same static page twice in a row. The two PNGs were byte-identical, but the two PDFs had different hashes, because PDF files embed their own creation time and document ID. HTML from busy pages also changes between requests. Hashes prove that this capture has not been altered since it was made. They do not prove that a later capture would come out the same, so never build a check that expects that.
Timestamp the manifest, not each file. It is one TSA request per capture instead of three, and the manifest also carries the context (URL, status, settings) that a bare file hash would lose.
Keep the .tsq too. It is not needed for verification, but it shows exactly what was sent to the TSA, which is useful when someone asks how the process works.
Storing archives so they stay trustworthy
Hashes and a timestamp make tampering detectable. Storage decides whether it is possible:
- Write-once storage. Amazon S3 Object Lock in compliance mode, Azure immutable blob storage and Google Cloud Storage retention policies all stop objects being deleted or overwritten until a retention date, even by an admin. Upload the whole capture folder there.
- Separate duties. The service that captures should be able to write, but not delete. Reviewers read from a different role.
- Keep the certificates and a copy of the scripts with the archives, so verification does not depend on a vendor or a website still existing.
- Log every capture (URL, case ID, folder, manifest hash) in a system you already audit, such as your ticketing tool. A capture that appears in two independent places is harder to dispute.
Scheduling and scale
For one-off evidence, run the script by hand when a case opens. For ongoing records, such as your own terms, pricing and promotional pages, a daily or weekly cron job over a short URL list is usually enough. Each page costs two renders (PNG and PDF), so 20 pages captured daily is about 1,200 results a month, well inside the smallest API plan. If you only need a record when something changes, compare the new HTML hash with the previous capture first and skip the render when nothing changed; the Google News monitoring post uses the same dedupe pattern for alerts.
If you also need the page's assets (scripts, styles, images) for a faithful replay, the archival standard is WARC, the format the Internet Archive uses. Tools such as wget --warc-file or Browsertrix produce it. WARC and this approach work well together: WARC for a replayable copy, screenshots and PDFs for something a person can read without special software.
Limits
- A capture shows what one browser, in one location, with one viewport saw. Pages that vary by country, login state, A/B test or cookie consent will look different to other visitors. Record what you can in the manifest and capture more than once if it matters.
- The timestamp proves when you had the files, not when the website published the content.
- Cookie-banner blocking makes captures readable but is a change to the page. If the banner itself is the evidence, turn it off.
- A free TSA has no service guarantee. For anything you may need in court, use a commercial or qualified TSA and keep its certificates.
Try it
The screenshot API returns full-page PNG, JPEG or PDF captures from one endpoint, with cookie-banner handling and signed download links, and failed captures are not counted against your plan. Plans start at $29 per month. For print layout, page sizes and batch PDF rendering, see HTML to PDF at scale and the HTML to PDF API. If you need archives of a fixed set of pages delivered on a schedule without running anything yourself, ask us about a custom data feed at [email protected].
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.