A file arrives from the field. It has C2PA content credentials: the camera model, GPS coordinates, capture time, an edit history. Solid provenance data.
Now it gets uploaded to a claims portal. The portal re-encodes it. Or it gets forwarded as an email attachment and the MIME handler strips the XMP block. Or someone converts it to a different format before anyone thinks to extract the metadata.
The C2PA manifest is gone. It lived inside the file, and the file changed.
A blockchain anchor doesn't work that way. Once a SHA-256 hash is written to an immutable ledger, the proof exists independently. No re-encoding touches it. No transfer strips it.
This post builds a Python pipeline that reads C2PA provenance data, hashes the file locally, and anchors the hash via REST API. Two evidence layers. One script.
What Each Layer Proves
C2PA (Content Credentials) answers provenance questions: which device captured the file, what software processed it, what edits were applied, and what the signing certificate's timestamp says. The standard has broad industry backing. Samsung Galaxy S25 and Google Pixel 10 now sign images natively at the camera level, so coverage from hardware is growing.
The limitation is architectural. A C2PA credential is a signed block embedded in the file. Strip the file's metadata, convert the format, or run it through a tool that doesn't preserve the XMP block, and the credential disappears. There's no independent copy.
A blockchain anchor is a different kind of proof. It answers: did this exact byte sequence exist at this point in time? The hash is computed from the file's bytes on your local machine. Only the hash goes anywhere. The anchor lives on a public ledger that anyone can read without touching the original file.
These are different questions. C2PA says where and how. Blockchain says when. If what's being disputed is timing, whether a file was created before or after a specific event, you need both.
Reading the C2PA Manifest
pip install c2pa-python requests
The c2pa-python package wraps the Rust C2PA SDK. The Reader class takes a MIME type and a binary stream. Check the README for your installed version, since the API has evolved across releases.
import c2pa
import json
MIME_MAP = {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
"mp4": "video/mp4",
"mov": "video/quicktime",
"pdf": "application/pdf",
}
def get_mime(filepath: str) -> str:
ext = filepath.rsplit(".", 1)[-1].lower()
return MIME_MAP.get(ext, "application/octet-stream")
def read_c2pa(filepath: str) -> dict | None:
"""Return parsed manifest store, or None if no C2PA data."""
try:
with open(filepath, "rb") as f:
reader = c2pa.Reader(get_mime(filepath), f)
raw = reader.json()
return json.loads(raw) if raw else None
except Exception:
return None
If the file has no C2PA credentials, you get None. That's expected. The anchor pipeline runs either way.
The manifest store JSON has a nested structure. The active manifest lives under manifests[active_manifest]. Here's how to pull the fields most useful for an evidence record:
def summarize_manifest(store: dict) -> dict:
active_key = store.get("active_manifest", "")
active = store.get("manifests", {}).get(active_key, {})
sig_info = active.get("signature_info", {})
assertions = [
a.get("label")
for a in active.get("assertions", [])
if a.get("label")
]
return {
"claim_generator": active.get("claim_generator"),
"format": active.get("format"),
"signing_time": sig_info.get("time"),
"signing_issuer": sig_info.get("issuer"),
"assertions": assertions,
}
signing_time is the timestamp from the credential authority. Useful context. But it's not an independent timestamp. It comes from the certificate authority that issued the manifest. Don't treat it as equivalent to a blockchain anchor.
Hashing and Anchoring
The file never leaves your machine. Only the SHA-256 hash goes to the API.
import hashlib
import time
import requests
ANCHOR_URL = "https://proofledger.io/api/v1/proof"
API_KEY = "sk_YOUR_KEY_HERE"
def sha256_file(filepath: str) -> str:
h = hashlib.sha256()
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def anchor_hash(sha256: str, filename: str) -> dict:
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
body = {"sha256": sha256, "filename": filename}
for attempt in range(3):
resp = requests.post(ANCHOR_URL, json=body, headers=headers, timeout=30)
if resp.status_code == 201:
return resp.json()
if resp.status_code == 400:
raise ValueError(f"Bad request: {resp.text}")
if resp.status_code in (401, 403):
raise PermissionError("Invalid API key or insufficient plan.")
if resp.status_code == 409:
return {**resp.json(), "_is_duplicate": True}
if resp.status_code == 429:
wait = int(resp.headers.get("Retry-After", 60))
print(f"Rate-limited. Waiting {wait}s.")
time.sleep(wait)
continue
resp.raise_for_status()
raise RuntimeError("Failed after 3 attempts.")
This calls the ProofLedger v1 REST API. The 409 response means the hash was already anchored. The response body includes duplicate_of, pointing to the original proof ID. Treat the earliest anchor as canonical.
The API requires a Professional or Business plan key (format: sk_...). Polygon anchoring happens automatically on submission. Bitcoin is an optional paid escalation per proof.
Third-Party Verification
Anyone with the SHA-256 can verify the anchor without credentials:
def verify_anchor(sha256: str) -> dict:
url = f"https://proofledger.io/api/v1/verify?hash={sha256}"
resp = requests.get(url, timeout=15)
resp.raise_for_status()
return resp.json()
This endpoint is public and rate-limited to 120 requests per hour per IP. No API key needed. Opposing counsel can run this check on a hash you provide.
result = verify_anchor(file_hash)
if result.get("found"):
print(f"Anchored: {result['proof']['anchored_at']}")
print(f"Explorer: {result.get('explorer_url')}")
else:
print("Not found.")
Putting It Together
import json
from pathlib import Path
def process_file(filepath: str) -> dict:
path = Path(filepath)
sha256 = sha256_file(filepath)
raw_manifest = read_c2pa(filepath)
manifest_summary = summarize_manifest(raw_manifest) if raw_manifest else None
proof = anchor_hash(sha256, path.name)
record = {
"file": path.name,
"sha256": sha256,
"c2pa": manifest_summary,
"anchor": {
"proof_id": proof.get("id"),
"status": proof.get("status"),
"certificate_url": proof.get("certificate_url"),
"is_duplicate": proof.get("_is_duplicate", False),
"duplicate_of": proof.get("duplicate_of"),
},
}
sidecar = path.with_suffix(".proof.json")
sidecar.write_text(json.dumps(record, indent=2))
return record
if __name__ == "__main__":
import sys
for filepath in sys.argv[1:]:
r = process_file(filepath)
dup_note = " (duplicate)" if r["anchor"]["is_duplicate"] else ""
print(f"{r['file']}: {r['anchor']['proof_id']}{dup_note}")
Run it on any files:
python c2pa_anchor.py site_photo.jpg inspection_report.pdf dashcam.mp4
Each file gets a .proof.json sidecar with the C2PA manifest summary (null if no credentials were present), the SHA-256, and the anchor record.
What You Have
For each file you process:
- Original file, unchanged, on your machine
- C2PA manifest summary when credentials are present: signing time, claim generator, assertion labels
- SHA-256 anchored to a public ledger
- A
.proof.jsonsidecar tying both layers together
The C2PA layer tells you origin: which device, which pipeline, what the signing certificate says. The blockchain layer tells you existence: these bytes were recorded before anyone could dispute the timing.
Strip the C2PA data and the blockchain anchor still holds. The ledger doesn't care what happened to the file afterward.
C2PA coverage at the hardware level is growing. More files will arrive with credentials already embedded. Capturing those credentials alongside the anchor, before they get stripped somewhere downstream, adds maybe 20 lines to a file intake script.
One practical note: pin c2pa-python to a specific version in your requirements.txt. The SDK is actively developed and the API has changed between minor releases. Pin it, test it, and check the release notes before upgrading.
What format are the bulk of your incoming files? And are you seeing real C2PA credentials on production files yet, or mostly synthetic credentials in test images?