Skip to content

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.

Entry kinds
kindRecordsFields
c2pa.signatureA file marked through POST /v1/markenvironment, 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_signatureA 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_stampA 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.headThe head of an organisation's audit log, anchored when it movesorg (SHA-256 of "audit-org:" followed by the organisation's ID), seq and head (the audit chain's last entry and hash)
checker.resultA signed result from POST /v1/verifyjws_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
curl https://api.kennmarq.com/v1/log/root
200 application/json
{
  "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
curl https://api.kennmarq.com/v1/log/proof/urn:c2pa:3fa85f64-5717-4562-b3fc-2c963f66afa6
200 application/json
{
  "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
curl "https://api.kennmarq.com/v1/log/consistency?first=1790012&second=1834467"
200 application/json
{
  "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
curl "https://api.kennmarq.com/v1/log/entries?start=0&end=1000"

The public key

curl
curl "https://api.kennmarq.com/v1/log/key?format=pem" -o kennmarq.com-log-key.pem

Without 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):

Shell
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 OK

An inclusion proof (RFC 9162 §2.1.3.2), in Python:

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.

Our time-stamps are our own word, not a third party's: that's why every one of them is in this log, where no one, us included, can backdate or remove one without the roots disagreeing.

Errors

Errors of the log endpoints
StatusCodeWhen
400invalid_requestA parameter isn't a whole number, a tree size isn't one of our signed roots, or an entry name is malformed.
404not_foundNo such entry or key, or no root signed yet.
429rate_limitedOver 120 calls a minute from one address. Retry-After says when to retry.
500internal_errorSomething failed on our side, including a proof that didn't verify before it was sent. Retry.