You need to show that a file existed by a certain time and has not changed since, from inside your own app or pipeline, without handing the file to anyone. File system timestamps and EXIF fields can be edited by whoever holds the file, so they do not settle the question.

ProofLedger is proof of existence as an API: send a file's SHA-256 hash, get back a certificate anchored in a public Polygon transaction that anyone can verify without ProofLedger. This post walks the whole flow in Python: hash locally, create the proof, find out when it anchored (webhook or polling), verify by hash, and store the bundle so the proof stands on its own.

The surface you are working with

Base URL https://proofledger.io. Every keyed call sends Authorization: Bearer sk_..., with keys issued from Account > API Keys on every plan including Free. There are six proof endpoints: POST /api/v1/proof, GET /api/v1/proof/{id}, GET /api/v1/proof/{id}/bundle, GET /api/v1/proofs, POST /api/v1/proofs/batch and the public GET /api/v1/verify. Webhook management lives under /api/v1/webhooks, and GET /api/v1/branding is read-only. Keyed calls are limited to 120 requests per minute per key; a 429 carries Retry-After.

Step 1: hash the file locally

The file never leaves your machine. Only the 64-character hex digest is sent, with an optional file name and an optional record id of your own.

import hashlib

def hash_file(path: str) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()

file_hash = hash_file("report-q3.pdf")
# e.g. a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2

Same bytes, same hash. One changed byte, a completely different hash. That property is the whole basis of the proof.

Step 2: create the proof

POST /api/v1/proof takes sha256 (required), filename (optional, up to 500 characters, shown on the public certificate, so keep personal details out of it) and external_id (optional, up to 128 characters, private to your account). Send your own record id as external_id and a repeat of the same request returns the existing proof with HTTP 200 and already_existed: true instead of creating another.

Add an Idempotency-Key header, any unique string of 1 to 200 printable ASCII characters, and a resend with the same key and body within 24 hours replays the stored response with Idempotent-Replayed: true. Generate the key once per submission and reuse it on every retry of that submission.

import os, time, uuid, requests

BASE = "https://proofledger.io/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['PROOFLEDGER_API_KEY']}"}

def create_proof(sha256: str, filename: str, external_id: str) -> dict:
    body = {"sha256": sha256, "filename": filename, "external_id": external_id}
    headers = {**HEADERS, "Idempotency-Key": str(uuid.uuid4())}
    for attempt in range(4):
        resp = requests.post(f"{BASE}/proof", json=body, headers=headers, timeout=15)
        if resp.status_code in (200, 201):
            return resp.json()
        if resp.status_code == 429:
            time.sleep(int(resp.headers.get("Retry-After", "60")))
            continue
        if resp.status_code in (400, 401, 403):
            raise RuntimeError(f"{resp.status_code}: {resp.text}")
        time.sleep(2 ** attempt)
    raise RuntimeError("could not create proof")

proof = create_proof(file_hash, "report-q3.pdf", "doc-8812")
print(proof["id"], proof["polygon_status"], proof["certificate_url"])

A 201 means the proof was accepted and queued. The body carries id, sha256, external_id, filename, polygon_status (PENDING at this point), created_at, certificate_url, verification_url, and chain_tx and explorer_url as null until the anchor lands. If the same hash was anchored before, by any account, the response also carries duplicate_of, an object with the id and created_at of that earliest proof; your proof is still recorded on its own. A 400 is a malformed hash, 401 a bad key, 403 a disabled account.

Step 3a: let a webhook tell you when it anchored

Register a public https URL once (private, loopback and VPN addresses are refused; up to 5 active endpoints per account; events are sent only for proofs created after registration):

curl -X POST https://proofledger.io/api/v1/webhooks \
  -H "Authorization: Bearer sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://api.example.com/proofledger/events"}'

The 201 response returns the endpoint and a secret that is shown once. Store it. POST /api/v1/webhooks/{id}/test sends a signed webhook.test event now, so you can check your handler before a real proof arrives.

Each delivery carries two headers: ProofLedger-Signature, in the form t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body" with your secret>, and ProofLedger-Event-Id. The JSON body is {id, type, created_at, data: {proof, bundle}}, where type is proof.polygon.anchored and data.bundle is the same document GET /api/v1/proof/{id}/bundle returns. Your handler must answer with a 2xx within 10 seconds. Failed deliveries are retried after 1, 5, 30, 120, 360, 720 and 1,440 minutes, then the endpoint is switched off and the account emailed. Delivery is at-least-once, so dedupe by event id.

import hmac, hashlib, json, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["PROOFLEDGER_WEBHOOK_SECRET"]
seen_events = set()  # use your database in production


def signature_ok(header: str, body: bytes) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(","))
    expected = hmac.new(SECRET.encode(), parts["t"].encode() + b"." + body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])


@app.post("/proofledger/events")
def events():
    if not signature_ok(request.headers.get("ProofLedger-Signature", ""), request.get_data()):
        abort(400)
    event = request.get_json()
    if event["id"] in seen_events:
        return "", 200
    seen_events.add(event["id"])
    if event["type"] == "proof.polygon.anchored":
        proof, bundle = event["data"]["proof"], event["data"]["bundle"]
        with open(f"proof-{proof['id']}.json", "w") as f:
            json.dump(bundle, f, indent=2)
    return "", 200

Sign over the raw bytes, not a re-serialised JSON object, and compare with compare_digest.

Step 3b: or poll

Without a webhook, poll GET /api/v1/proof/{id} (owner-only) until polygon_status is ANCHORED. The Polygon transaction usually lands within a minute.

import time

def wait_for_anchor(proof_id: int, timeout: int = 300) -> dict:
    deadline = time.time() + timeout
    while time.time() < deadline:
        data = requests.get(f"{BASE}/proof/{proof_id}", headers=HEADERS, timeout=10).json()
        if data["polygon_status"] == "ANCHORED":
            return data
        time.sleep(10)
    raise TimeoutError("still PENDING; keep polling or use a webhook")

anchored = wait_for_anchor(proof["id"])
print(anchored["chain_tx"], anchored["explorer_url"], anchored["polygon_anchored_at"])

chain_tx is the Polygon transaction hash, explorer_url the Polygonscan link for it, and polygon_anchored_at the time the transaction was recorded. For many proofs at once, GET /api/v1/proofs?anchored_since=<ISO time> lists what finished since your last check, cursor paged.

Step 4: verify by hash, no key needed

GET /api/v1/verify?hash=<sha256> is public and answers whether ProofLedger holds a proof for that hash, with the transaction and links. It is limited to 120 requests per hour per IP.

r = requests.get(f"{BASE}/verify", params={"hash": file_hash}, timeout=10).json()
if r["found"]:
    print(r["proof"]["polygon_status"], r["proof"]["chain_tx"], r["explorer_url"])
else:
    print("no proof for", r["sha256"])

Anyone holding the file can recompute its hash and run this lookup without your API key or your records.

Step 5: store the bundle beside the file

The record of a proof is the public Polygon transaction plus the proof bundle. The certificate page, the verify endpoint and ProofLedger's database are convenience views of that record. Fetch the bundle once the proof is anchored and keep it with the file:

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

It is one JSON document in the verify-proof format: hash, algorithm, blockchain (polygon), tx_id, anchored_at, merkle_path (empty, since each proof is its own transaction), proof_id, certificate_url, verification_url and a how_to_verify list. If you use webhooks you already have it: it arrives as data.bundle.

What you end up with

A proof shows that a file with this exact SHA-256 hash existed by the anchor time and has not changed since. It does not show who made the file, that its contents are true, or who owns it. Your file never left your machine, so this works the same for a one-page PDF and a 4 GB video.

Checking a proof without ProofLedger

With the bundle beside the file, verify-proof verify report-q3.pdf --proof report-q3.pdf.proof.json (PyPI or npm) recomputes the hash, checks it against the bundle offline, and prints the Polygon transaction to look up on any block explorer. Neither step needs ProofLedger to be reachable. The full API reference is at https://proofledger.io/api.html.