Transparency log
GEThttps://api.kennmarq.com/v1/log/root
GEThttps://api.kennmarq.com/v1/log/proof/{entry}
GEThttps://api.kennmarq.com/v1/log/consistency
GEThttps://api.kennmarq.com/v1/log/entries
GEThttps://api.kennmarq.com/v1/log/roots
GEThttps://api.kennmarq.com/v1/log/key
Every signature and time-stamp we hand out is first appended to a public, append-only log. A root over the whole log is signed every hour while it grows, and at least once a day. With a root, its signature and our public key, anyone can check that an entry is in the log, and that the log was never rewritten. No key needed. The transparency log page shows the latest root.
What's in an entry
Every entry is a JSON object with v (1), kind and logged_at (UTC, to the second), plus the fields of its kind. They're all hashes, times and our own identifiers: never an organisation's name or ID, a file name, a watermark ID, a model or an app name. Whoever holds a file or a token can recompute its fields; nobody else learns anything from them.
| kind | Records | Fields |
|---|---|---|
c2pa.signature | A file marked through POST /v1/mark | environment, manifest (its label), asset_sha256 (the file as we returned it), claim_signature_sha256 (the COSE signature: the CBOR in the manifest's c2pa.signature box), signer_sha256 (the signing certificate, DER), tsa_serial, tsa_time, tsa_token_sha256 (the time-stamp token in the signature, DER) |
c2pa.local_signature | A manifest signed through POST /v1/sign (local mode) | environment, manifest, binding_sha256 (the hash in the manifest's c2pa.hash.data assertion: we never see the file), claim_signature_sha256, signer_sha256, tsa_serial, tsa_time, tsa_token_sha256 |
rfc3161.time_stamp | A token from POST /v1/tsa/{key} | environment, tsa_serial, tsa_time, tsa_token_sha256 (the TimeStampToken, DER), imprint_alg and imprint (the hash you sent) |
audit.head | The head of an organisation's audit log, anchored when it moves | org (SHA-256 of "audit-org:" followed by the organisation's ID), seq and head (the audit chain's last entry and hash) |
checker.result | A signed result from POST /v1/verify | jws_sha256 (the compact JWS, as returned), file_sha256 (the checked file), key_id (the checker key: SHA-256 of its DER SubjectPublicKeyInfo) |
The log is an RFC 9162 Merkle tree (the hashing of Certificate Transparency): an entry's leaf hash is SHA-256 of a 0x00 byte followed by the entry's canonical JSON (keys sorted, no whitespace; every value is ASCII). API answers carry that exact text as entry_json. Your POST /v1/mark and POST /v1/tsa responses name the entry they made in X-Log-Index.
The latest root
curl https://api.kennmarq.com/v1/log/root{
"object": "log_root",
"seq": 412,
"tree_size": 1834467,
"timestamp": 1794067200000,
"signed_at": "2026-11-12T00:00:00.000Z",
"root_hash": "9c8d5e0a4b7f…",
"key_id": "5b2e7a19c0d4…",
"signature": "MEUCIQDx…"
}signature is ECDSA P-256 with SHA-256 (DER, base64) over RFC 6962's tree head: the bytes 00 01, then timestamp (milliseconds) and tree_size as 8-byte big-endian numbers, then the 32 bytes of root_hash. GET /v1/log/roots lists every root, newest first (?before=<seq> pages back, ?limit= up to 100).
Inclusion proof
Name the entry by its index (X-Log-Index), its leaf hash, or, for a marked file, its manifest's label (urn:c2pa:…, readable with any C2PA tool). Add ?tree_size= to prove it against a particular signed root; otherwise the latest one is used.
curl https://api.kennmarq.com/v1/log/proof/urn:c2pa:3fa85f64-5717-4562-b3fc-2c963f66afa6{
"object": "log_proof",
"status": "included",
"leaf_index": 1834001,
"leaf_hash": "d1f0…",
"entry": { "v": 1, "kind": "c2pa.signature", "manifest": "urn:c2pa:3fa85f64-…", "asset_sha256": "…", … },
"entry_json": "{\"asset_sha256\":\"…\",…,\"v\":1}",
"tree_size": 1834467,
"audit_path": ["7a3c…", "0e91…", …],
"root": { "object": "log_root", "seq": 412, … }
}An entry newer than the latest root answers "status": "pending", with the entry and no proof yet: the next root covers it within the hour.
Consistency proof
Proof that the tree of second entries starts with the tree of first: nothing was removed, changed or reordered in between. Both must be sizes of signed roots; second defaults to the latest. Keep the roots you've seen and check each new one against them.
curl "https://api.kennmarq.com/v1/log/consistency?first=1790012&second=1834467"{
"object": "log_consistency",
"first": { "object": "log_root", "tree_size": 1790012, … },
"second": { "object": "log_root", "tree_size": 1834467, … },
"proof": ["44be…", "c07d…", …]
}Every entry
For auditors who rebuild the tree themselves: ?start= and an optional ?end= (exclusive), up to 1,000 entries a call, oldest first, each with leaf_index, leaf_hash, entry and entry_json.
curl "https://api.kennmarq.com/v1/log/entries?start=0&end=1000"The public key
curl "https://api.kennmarq.com/v1/log/key?format=pem" -o kennmarq.com-log-key.pemWithout format=pem it answers JSON: key_id (SHA-256 of the key's DER SubjectPublicKeyInfo, as each root names it), algorithm and public_key. An older key stays available by ?key_id=.
Checking it yourself
The root's signature, with OpenSSL (and jq):
curl -s "https://api.kennmarq.com/v1/log/key?format=pem" -o log-key.pem
curl -s "https://api.kennmarq.com/v1/log/root" -o root.json
printf '0001%016x%016x%s' "$(jq .timestamp root.json)" "$(jq .tree_size root.json)" \
"$(jq -r .root_hash root.json)" | xxd -r -p > tree-head.bin
jq -r .signature root.json | base64 --decode > signature.der
openssl dgst -sha256 -verify log-key.pem -signature signature.der tree-head.bin # Verified OKAn inclusion proof (RFC 9162 §2.1.3.2), in Python:
import hashlib, json, urllib.request
proof = json.load(urllib.request.urlopen("https://api.kennmarq.com/v1/log/proof/urn:c2pa:3fa85f64-5717-4562-b3fc-2c963f66afa6"))
sha256 = lambda data: hashlib.sha256(data).digest()
r = sha256(b"\x00" + proof["entry_json"].encode())
assert r.hex() == proof["leaf_hash"]
fn, sn = proof["leaf_index"], proof["tree_size"] - 1
for step in map(bytes.fromhex, proof["audit_path"]):
assert sn > 0
if fn % 2 == 1 or fn == sn:
r = sha256(b"\x01" + step + r)
while fn % 2 == 0 and fn != 0:
fn, sn = fn >> 1, sn >> 1
else:
r = sha256(b"\x01" + r + step)
fn, sn = fn >> 1, sn >> 1
assert sn == 0 and r.hex() == proof["root"]["root_hash"]
print("in the log, under root", proof["root"]["seq"])Then check that root's signature as above, and that the entry's fields match your file: asset_sha256 is the SHA-256 of the file we returned, and manifest its manifest's label. For a time-stamp, tsa_token_sha256 is the SHA-256 of the token in your reply.
Limits and caching
120 calls a minute per address, with the usual X-RateLimit-* headers. Answers about the latest root may be cached for a minute; answers about a tree size you named never change and may be cached for a day.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_request | A parameter isn't a whole number, a tree size isn't one of our signed roots, or an entry name is malformed. |
| 404 | not_found | No such entry or key, or no root signed yet. |
| 429 | rate_limited | Over 120 calls a minute from one address. Retry-After says when to retry. |
| 500 | internal_error | Something failed on our side, including a proof that didn't verify before it was sent. Retry. |