Web evidence capture with cryptographic timestamps: what it proves

Web evidence capture with cryptographic timestamps: SHA-256, an OpenTimestamps anchor, what a capture proves and does not, and why it beats a screenshot.

Web pages change. Prices are edited, terms of service are rewritten, a competitor's claim disappears the day after you notice it. When an agent finds something worth keeping, "I saw it" is not enough, and neither is a screenshot. This article explains what a timestamped web capture is made of, a cryptographic hash and a public timestamp, how 402check's proof_create builds one, and exactly what such a capture proves and what it does not.

Why a screenshot is weak evidence

A screenshot is an image file on someone's disk. Nothing in it ties it to a URL or a moment: the URL bar and the clock in the image are pixels like any other, editing leaves no trace, and the file's own date is whatever the computer says. Its value rests entirely on trusting whoever made it.

A timestamped capture changes two things. A third party fetches and renders the page, so the person who wants the evidence is not the one producing it. And the result is fixed with a hash that is then anchored in a public timestamp, so any later change to the files can be detected, and the time can be checked without trusting anyone's clock.

Hashes: detecting any change

A cryptographic hash turns any input into a short fixed-size digest. NIST's Secure Hash Standard, FIPS 180-4, which specifies SHA-256 among others, states that its algorithms are called secure because it is "computationally infeasible 1) to find a message that corresponds to a given message digest, or 2) to find two different messages that produce the same message digest." In practice: if you hold a file and its SHA-256, you can detect whether a single byte of the file was changed.

A hash alone does not say when the file existed. For that you need a timestamp.

OpenTimestamps: anchoring the time

OpenTimestamps is an open-source timestamping standard that anchors to the Bitcoin blockchain. In its own words, "a timestamp proves that some data existed prior to some point in time." Public calendar servers accept a digest for free, with no account, and commit it to Bitcoin.

The commitment is not instant. The reference client's documentation says "it takes a few hours for the timestamp to get confirmed by the Bitcoin blockchain." Until then the timestamp is incomplete; it can be upgraded later to include the path to a Bitcoin block. Verifying a complete timestamp independently needs block data: the client's README says "you need a local Bitcoin Core node (a pruned node is fine)."

What is sent to the calendar is only the digest. The page itself never leaves our storage.

How proof_create builds a capture

A call is a POST with one field, the URL:

  1. URL check. Only public http and https URLs are accepted. Private, local and link-local addresses, and our own hostname, are refused with 400 invalid_url before anything is fetched.
  2. One page load. Cloudflare's headless browser renders the page, waiting for the network to go idle, and returns the rendered HTML and a full-page PNG screenshot from that same load. The HTML and the image show the same rendering.
  3. Hashes. SHA-256 of the HTML (the serialized DOM after rendering, UTF-8) and of the PNG bytes.
  4. Storage. Both files and the proof record are stored in Cloudflare R2 under a time-sortable id.
  5. Anchor. A single digest that covers the id, the URL, the capture time, both hashes and the artifact links goes to the OpenTimestamps calendar.

This is the core of it:

TypeScript src/tools/proof.ts
  const htmlBytes = new TextEncoder().encode(html);
  const unanchored = {
    id,
    url: target,
    capturedAt: capturedAt.toISOString(),
    sha256: { html: toHex(await sha256(htmlBytes)), png: toHex(await sha256(png)) },
    artifacts: { html: `${origin}/v1/proof/${id}/html`, png: `${origin}/v1/proof/${id}/png` },
  };
  const [proofAnchor] = await Promise.all([
    anchor(await sha256(new TextEncoder().encode(JSON.stringify(unanchored))), fetchImpl),

The anchored digest is the SHA-256 of JSON.stringify({ id, url, capturedAt, sha256, artifacts }) with the keys in exactly that order, and html before png inside sha256 and artifacts. That order is part of the contract: anyone can rebuild the same bytes from the proof record and get the same digest.

The response gives the agent everything it needs to keep: the id, the URL, capturedAt, both hashes, links to both artifacts, and an anchor object with the calendar's response (otsBase64) and a status.

What it proves, and what it does not

Be precise about this, because overstating evidence is worse than having none.

A capture shows that:

  • the files you hold are the files that were hashed, if their SHA-256 values match the proof;
  • the digest covering those hashes, the URL and the capture time existed no later than the Bitcoin block that confirms it, once the timestamp is complete.

402check also records that the HTML and the PNG come from one page load, requested at capturedAt; that part rests on trusting the capture service (see below).

It does not show that:

  • The page looked the same to everyone. Content can vary by location, cookies, login state or A/B tests. We capture what a headless browser without your session saw.
  • The final URL is the one you asked for. The browser follows redirects; url records the requested address, and the captured page may come from another one.
  • The HTML is the server's raw response. The hash covers the rendered DOM after scripts ran, not the HTTP response bytes.
  • Who wrote the content. A timestamp is about existence in time, not authorship.
  • The capture was honest. The rendering step relies on trusting the capture service. The anchor stops anyone, including us, from changing the files after the fact without detection; it cannot prove what the browser saw in the first place.

Two v1 details also matter. The anchor starts as pending; we do not upgrade it to confirmed ourselves yet. otsBase64 is the calendar's timestamp fragment, not a complete detached .ots file: a verifier builds the detached file from it plus the digest above, and upgrades it with the OpenTimestamps tools. If the calendar is unreachable, the proof is still stored with anchor.status: "failed" rather than failing the call.

Whether a capture like this is accepted as evidence depends on where and how you use it. It is a well-documented technical record, not legal advice, and it is not a timestamp from a qualified trust service provider.

Archived pages are served carefully

An archived page is hostile content: it may contain scripts written by anyone. The stored HTML is served with a restrictive Content-Security-Policy, sandbox; default-src 'none', so its scripts cannot run against our origin and it cannot load anything from third parties. The PNG is served as a plain image.

Try it

A capture costs $0.25 and is never part of the free tier, so it needs a credits key or an x402 payment. Reading the proof back is free:

Shell
curl -X POST "https://402check.com/v1/proof" \
  -H "Authorization: Bearer rk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/"}'

# later, with the id from the response: re-read the proof and re-hash the screenshot
curl "https://402check.com/v1/proof/01M5YCTV80A6V3J9N5RXW7TC8E"
curl -s "https://402check.com/v1/proof/01M5YCTV80A6V3J9N5RXW7TC8E/png" | shasum -a 256

If the printed hash equals sha256.png in the proof, the screenshot is unchanged. The full response format is on the proof_create docs page, and reading proofs back is documented under proof_get.