How to prove a PDF wasn't altered: hashes and signed receipts
, 6 min read
Months after you sent an invoice or a contract, someone says the PDF they have is not the one you produced. Two tools settle it: a hash of the file, recorded when you made it, and a signature over that hash by someone else. pagenull gives you both on every render.
A hash pins the exact bytes
SHA-256 turns a file into 64 hex characters. Change one byte and the hash changes completely, so a stored hash tells you whether a file is the same one. Compute it yourself at any time:
sha256sum invoice.pdf # Linux
shasum -a 256 invoice.pdf # macOSA hash you store yourself proves the file hasn’t changed since you stored the hash. It doesn’t prove when the file was made, or that you didn’t compute the hash later. For that, a third party has to sign it.
A receipt signs the hash, the time and the renderer
Every pagenull response has a pagenull-receipt header: v1.<payload>.<signature>. The payload is base64url JSON:
{
"v": 1,
"kid": "3b1f0c9a8e2d4f61",
"render_id": "3f9a1c2e-7b4d-4e0a-9c1f-2d6b8e5a0f13",
"sha256": "9f2c4b…e41a",
"timestamp": "2026-11-04T09:12:07.418Z",
"region": "hetzner-helsinki",
"renderer": "sha256:4be1…",
"stored": false
}The signature is Ed25519 over the payload. Our public keys are at https://pagenull.dev/.well-known/pagenull-keys.json, so anyone can check a receipt without asking us.
Keep the receipt with the file
const res = await fetch("https://api.pagenull.dev/v1/pdf", { method: "POST", headers, body });
const pdf = Buffer.from(await res.arrayBuffer());
await saveDocument({ pdf, receipt: res.headers.get("pagenull-receipt") });Check it on the web
Paste the receipt into /verify and optionally pick the file: your browser computes the hash, so the file never leaves your machine. Or call the same check from code, sending only the receipt and the hash:
curl https://pagenull.dev/api/receipts/verify \
-H "Content-Type: application/json" \
-d "{\"receipt\":\"$RECEIPT\",\"sha256\":\"$(sha256sum invoice.pdf | cut -d' ' -f1)\"}"
# {"valid":true,"payload":{…},"fileMatches":true}Check it offline
The check needs nothing from us but the public key. In Node.js:
import { createHash, createPublicKey, verify } from "node:crypto";
import { readFileSync } from "node:fs";
const [receipt, file] = process.argv.slice(2);
const [version, payloadB64, sigB64] = receipt.split(".");
const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
const { keys } = await (await fetch("https://pagenull.dev/.well-known/pagenull-keys.json")).json();
const jwk = keys.find((k) => k.kid === payload.kid);
const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: jwk.x }, format: "jwk" });
const signed = version === "v1" && verify(null, Buffer.from(payloadB64), key, Buffer.from(sigB64, "base64url"));
const sameFile = createHash("sha256").update(readFileSync(file)).digest("hex") === payload.sha256;
console.log({ signed, sameFile, timestamp: payload.timestamp });Note that the signature covers the base64url text of the payload exactly as sent, so verify against that string, not re-encoded JSON. Save a copy of the keys file with your records if you want the check to work without our site.
What a receipt proves, and what it doesn’t
- Proves: pagenull produced exactly this file, at this time, in this region, with this renderer image, and the API didn’t keep it.
- Doesn’t prove: that the content is correct, who asked for it, or that anything was deleted. Deletion can’t be proven from the outside; ours rests on the architecture and the DPA.
- Not a PDF signature: the receipt travels next to the file, not inside it, so PDF readers don’t show it. If recipients need a signature inside the PDF (PAdES), that is a separate step.