You anchored a set of files months ago and kept their proof bundles. Now something needs to re-check them: a release job, an audit script, a colleague on a laptop with no network. Calling the service that issued the proofs should not be a requirement. With the open-source verify-proof package, it is not: the check is pure computation on the file and its bundle, and it works the same whether or not the issuing service still exists.

Install

pip install verify-proof

The base install pulls no dependencies; it uses only the Python standard library. It gives you a verify-proof command and a verify_proof module. A Node port with the same bundle format is on npm under the same name.

Hash a file

hash_file() streams the file through SHA-256 and returns the lowercase hex digest:

from verify_proof import hash_file

file_hash = hash_file("report.pdf")
print(file_hash)
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2

Or from the shell:

verify-proof hash report.pdf
# SHA256: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
# File: report.pdf

This hash is what gets anchored. The file stays where it is.

The bundle

A bundle is one JSON document with everything the offline check needs. From ProofLedger, it comes from the API (owner only, Bearer API key) and also arrives as data.bundle in the proof.polygon.anchored webhook payload, so a pipeline can save it the moment the anchor lands:

curl -H "Authorization: Bearer sk_your_key" "https://proofledger.io/api/v1/proof/42/bundle?download=1" -o report.pdf.proof.json

Abridged, it looks like this:

{
  "format": "verify-proof/1",
  "service": "proofledger",
  "hash": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
  "algorithm": "sha256",
  "blockchain": "polygon",
  "tx_id": "0xabc123...",
  "anchored_at": "2026-10-15T02:11:09.512Z",
  "merkle_path": [],
  "proof_id": 42,
  "certificate_url": "https://proofledger.io/cert/42"
}

merkle_path is empty because each ProofLedger proof is its own Polygon transaction. A bundle from a service that batches many hashes into one transaction carries a path here, and verify-proof handles that case too (more below). Keep the bundle next to the file; the convention <file>.proof.json is what the rest of this post assumes.

Verify

from verify_proof import hash_file, load_proof, verify_proof

file_hash = hash_file("report.pdf")
bundle = load_proof("report.pdf.proof.json")
result = verify_proof(file_hash, bundle)

if result["verified"]:
    print(result["message"])
    print(result["blockchain"], result["tx_id"], result["anchored_at"])
    print(result.get("explorer_url"))   # Polygonscan link when the chain is polygon
else:
    print(result["error"])

verify_proof() returns a dict. verified is the verdict. file_hash and proof_hash are the two values it compared. blockchain, tx_id, anchored_at and service are copied from the bundle. On success message spells out what was checked and names the transaction to look up; on failure error says why, and the two reasons are a hash mismatch (the file changed, or it is a different file) and a bundle with no transaction id.

What it checks, and what it does not

Three things happen inside verify_proof():

1. The recomputed hash is compared with the bundle's hash. Any difference is a failure. 2. If the bundle carries a merkle_path, the root is computed by combining each sibling with the running hash, left or right as the path says, hashing the raw bytes at each step. The result is returned as merkle_root for you to compare with the root recorded in the transaction. ProofLedger bundles have no path, so this step is skipped. 3. The bundle must name a transaction (tx_id). If it does, verified is True.

What it does not do: read the chain. The transaction id and the anchor time are what the bundle says until someone looks the transaction up. That is deliberate. Offline means offline, and the on-chain check is a separate step: open explorer_url (or paste tx_id into any Polygon block explorer) and confirm the transaction carries the hash. Its block timestamp is the anchor time. Run that step once when you first receive a bundle, or whenever the answer matters; the local check covers the question that comes up most often, which is whether the file in hand is still the file that was anchored.

Command line and exit codes

verify-proof verify report.pdf --proof report.pdf.proof.json
# VERIFIED: File matches the proof bundle. The bundle says it was anchored on polygon at 2026-10-15T02:11:09.512Z in transaction 0xabc123.... Look that transaction up on a block explorer to confirm it: https://polygonscan.com/tx/0xabc123...

Exit status is 0 on VERIFIED and 1 on FAILED, a missing file or a missing bundle, which makes it usable as a gate in a shell script or a CI step with no wrapper. A successful run prints one extra line to stderr when a human is at the terminal; it is suppressed when output is piped, and VERIFY_PROOF_NO_HINT=1 turns it off everywhere.

Sweeping a directory

A script that checks every file in a folder against its bundle and exits non-zero if any fail:

#!/usr/bin/env python3
import os
import sys
from verify_proof import hash_file, load_proof, verify_proof

def sweep(folder):
    ok = True
    for name in sorted(os.listdir(folder)):
        if name.endswith(".proof.json"):
            continue
        path = os.path.join(folder, name)
        bundle_path = path + ".proof.json"
        if not os.path.isfile(path):
            continue
        if not os.path.exists(bundle_path):
            print(f"SKIP   {name}: no bundle")
            continue
        try:
            result = verify_proof(hash_file(path), load_proof(bundle_path))
        except (OSError, ValueError) as exc:
            print(f"ERROR  {name}: {exc}")
            ok = False
            continue
        if result["verified"]:
            print(f"OK     {name}: {result['blockchain']} {result['tx_id']}")
        else:
            print(f"FAIL   {name}: {result['error']}")
            ok = False
    return ok

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("usage: sweep.py <folder>")
        sys.exit(2)
    sys.exit(0 if sweep(sys.argv[1]) else 1)

Nothing in it opens a socket. Drop it into a nightly job and it will tell you, from the bytes alone, whether every file still matches what was anchored.

Creating bundles in the first place

ProofLedger is proof of existence as an API: send a file's SHA-256 hash, get a Polygon-anchored certificate anyone can verify without us. The same package can submit a hash; create is its one networked command and needs an API key:

export PROOFLEDGER_API_KEY=sk_your_key
verify-proof create report.pdf --no-filename   # sends the hash only, not the file name

Then save the bundle with the curl above (the Polygon anchor usually lands within a minute), and every later check is offline.

A proof shows that a file with this exact hash existed by the anchor time and has not changed since. It does not show who made the file or that its contents are true.

To check a proof without ProofLedger: verify-proof verify against the bundle, then the transaction on any Polygon block explorer. The API and the bundle endpoint are documented at https://proofledger.io/api.html.