Verifying attestations
Every attestation is signed with your tenant's Ed25519 key and written to an append-only ledger. There are five ways to check one, and they form a ladder: each level relies less on trusting our API's answer than the one before, from a quick hash comparison all the way to a public proof an auditor can open with no Invoance account.
Before you begin
The authenticated endpoints (levels 1–4) need an API key with the read scope, sent as Authorization: Bearer invoance_live_… or X-API-Key. The public proof endpoints (level 5) and the key-discovery endpoint need no auth at all. Every example is a complete request; swap invoance_live_xxx for your key.
https://api.invoance.com/v1The verification ladder
Weakest to strongest. Levels 1 and 2 ask the API to compare; levels 3 and 4 move the math to your side; level 5 lets anyone run the check without an account, against a key we cannot swap after the fact.
Send a SHA-256 you computed; the API says whether it matches an anchored hash. Fast, but you are trusting the server's comparison.
The SDK hashes the content you hold client-side, then runs the same hash verify. Removes hand-rolled hashing mistakes; still trusts the server's answer.
Fetch the exact bytes stored at ingest and recompute payload_hash yourself. The comparison happens on your machine.
Check the Ed25519 signature over the signed record. Against the embedded key it proves internal consistency; against a pinned key from /keys/{domain} it ties the record to the issuer.
A link anyone can open and verify with no Invoance account. The server checks the signature against the write-once tenant key, never the record's own copy.
For the platform-wide picture across events, documents, and attestations, see how verification works.
Compute a SHA-256 over the content you hold (the model input, the model output, or the canonical request JSON) and ask the API whether it matches one of the anchored hashes. This is the everyday check; each call counts against your plan's api_verifications_per_month quota.
/v1/ai/attestations/{attestation_id}/verifyVerify by hashBody takes a single content_hash: exactly 64 hex characters (a SHA-256 digest), otherwise 400. The API compares it against attestation_hash, input_hash, output_hash, and payload_hash in that order and reports which field matched.
curl -X POST https://api.invoance.com/v1/ai/attestations/9549c332-a52b-4b12-8d3f-0f6c2e7a1b90/verify \
-H "Authorization: Bearer invoance_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "content_hash": "8c74176675eed4e2ff88bc0182af0f5f3a70b7e2c11d94a6b0d8f3e5c2a19b47" }'client.attestations.verifyimport hashlib
from invoance import InvoanceClient
async with InvoanceClient() as client:
output_text = "Approved: refund of $42.00"
result = await client.attestations.verify(
"9549c332-a52b-4b12-8d3f-0f6c2e7a1b90",
content_hash=hashlib.sha256(output_text.encode()).hexdigest(),
)
print(result.match_result, result.matched_field) # True output_hash| Field | Meaning |
|---|---|
| match_result | true when the submitted hash equals one of the anchored hashes. |
| matched_field | attestation_hash, input_hash, output_hash, or payload_hash; null on no match. attestation_hash equals payload_hash and is compared first, so a canonical-payload hash reports matched_field "attestation_hash". |
| anchored_hash | The hash recorded at ingest (the matched field's value). |
| anchored_at | When the record was accepted, on our server clock. |
| organization | The issuing org: name, issuer_name, primary_domain, domain_verified. |
Hand the SDK the content instead of a hash. It serializes the payload compactly, computes the SHA-256 client-side, and calls the same hash-verify endpoint. You stop worrying about hashing mistakes on your side; the comparison itself still happens on the server.
client.attestations.verify_payload / verifyPayload# The SDK helpers are client-side sugar over the hash-verify endpoint.
# Reproduce the canonical JSON exactly (compact, server field order), hash it, verify:
HASH=$(echo -n '{"type":"output","payload":{"input":"Customer asks: can I get a refund on order #1042?","output":"Approved: refund of $42.00"},"context":{"model_provider":"openai","model_name":"gpt-4o","model_version":"2024-11-20"},"subject":{"user_id":"user_42"}}' | shasum -a 256 | cut -d' ' -f1)
curl -X POST https://api.invoance.com/v1/ai/attestations/9549c332-a52b-4b12-8d3f-0f6c2e7a1b90/verify \
-H "Authorization: Bearer invoance_live_xxx" \
-H "Content-Type: application/json" \
-d "{\"content_hash\": \"$HASH\"}"payload_hashis computed over the server's canonical serialization: compact JSON (no whitespace) with fields in struct order — type, payload (input, output), context (model_provider, model_name, model_version), then subject with extra keys in alphabetical order, and trace_idif you sent one. Fields you omitted at ingest must stay omitted. If your reconstruction differs by a single byte the hash will not match — when in doubt, fetch the canonical bytes from /raw (level 3) instead of rebuilding them.Fetch the byte-identical canonical JSON that was stored at ingest and hash it yourself. This is the source of truth for recomputing payload_hash: the comparison happens on your machine, so a wrong answer from the API cannot fake a match.
/v1/ai/attestations/{attestation_id}/rawRaw canonical JSONReturns the exact bytes hashed and anchored at ingest, served verbatim from object storage (cached server-side for 5 minutes). Hashing the response body must reproduce payload_hash; if it does not, the record does not match what was anchored.
curl https://api.invoance.com/v1/ai/attestations/9549c332-a52b-4b12-8d3f-0f6c2e7a1b90/raw \
-H "Authorization: Bearer invoance_live_xxx" \
-o attestation.json
# Hash the file exactly as served, byte for byte:
shasum -a 256 attestation.json
# -> must equal payload_hash (and attestation_hash)client.attestations.get_raw / getRawraw = await client.attestations.get_raw("9549c332-a52b-4b12-8d3f-0f6c2e7a1b90")
# Round-trip through the payload verifier: the SDK re-serializes the
# dict compactly and compares the SHA-256 against the anchored hash.
result = await client.attestations.verify_payload(
"9549c332-a52b-4b12-8d3f-0f6c2e7a1b90",
payload=raw,
)
print(result.match_result) # TrueCheck the Ed25519 signature over the signed record. The signature covers a server-built canonical statement: the attestation id, tenant id, type, all three hashes, the model identity, and the created_at timestamp. A valid signature proves none of those fields changed since signing.
client.attestations.verify_signature / verifySignature# 1. Fetch the attestation
ATT=$(curl -s https://api.invoance.com/v1/ai/attestations/9549c332-a52b-4b12-8d3f-0f6c2e7a1b90 \
-H "Authorization: Bearer invoance_live_xxx")
# 2. Extract hex fields
SIG=$(echo $ATT | jq -r .signature)
PAYLOAD=$(echo $ATT | jq -r .signed_payload)
PUBKEY=$(echo $ATT | jq -r .public_key)
# 3. Verify with openssl (Ed25519)
echo -n "$PAYLOAD" | xxd -r -p > /tmp/payload.bin
echo -n "$SIG" | xxd -r -p > /tmp/sig.bin
echo -n "302a300506032b6570032100$PUBKEY" | xxd -r -p > /tmp/pub.der
openssl pkeyutl -verify -pubin -inkey /tmp/pub.der \
-keyform DER -in /tmp/payload.bin -sigfile /tmp/sig.binpublic_key embedded in that same response. That proves the record is internally consistent — the signature genuinely covers the signed bytes — but the key itself came from the server. A record forged and re-signed end to end with a different key would still pass. To close that gap, pin the key: fetch the tenant's registered signing key from the public key-discovery endpoint below and require the record's key to match it before you accept the signature./keys/{domain}Pinned tenant keyPublic key discovery for a verified domain: no auth, rate limited to 5 requests per second and 200 per minute per IP. Note the path is root-level (https://api.invoance.com/keys/{domain}, not under /v1) and the key is base64url without padding, while attestation records carry the same 32 bytes hex-encoded.
curl https://api.invoance.com/keys/acme.com# Compare the record's hex public_key against the pinned base64url key:
python3 -c "import base64,sys; print(base64.urlsafe_b64encode(bytes.fromhex(sys.argv[1])).rstrip(b'=').decode())" \
d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a
# -> must equal public_key from /keys/{domain}; only then trust a valid signatureEvery attestation has a public proof page: no login, no Invoance account, no API key. Share the link with an auditor, a regulator, or a counterparty and they can read the record and run a verification themselves. This is the strongest server-mediated answer, because the server's own signature check is pinned to the write-once tenant key.
https://invoance.com/proof/ai/{attestation_id}The hosted page is backed by two public API endpoints under /v1/proof/ai/*, IP rate limited to 2 requests per second and 10 per minute. You can call them directly from your own verification tooling.
/v1/proof/ai/{attestation_id}Public proof dataReturns the issuing organization and the full proof record: type, all three hashes, model identity, subject ids, signature, and the tenant public key. The public_key served here comes from the tenant's write-once key registry, not from the stored row.
curl https://api.invoance.com/v1/proof/ai/9549c332-a52b-4b12-8d3f-0f6c2e7a1b90/v1/proof/ai/{attestation_id}/verifyPublic verifySubmit a 64-hex SHA-256 with no auth. The server compares it against payload_hash, input_hash, and output_hash, and independently verifies the Ed25519 signature over the signed record, reporting both results. It does not count against any quota.
curl -X POST https://api.invoance.com/v1/proof/ai/9549c332-a52b-4b12-8d3f-0f6c2e7a1b90/verify \
-H "Content-Type: application/json" \
-d '{ "content_hash": "8c74176675eed4e2ff88bc0182af0f5f3a70b7e2c11d94a6b0d8f3e5c2a19b47" }'signature_valid alongside the hash match.Sealed records: verification outlives retention
When a record's retention window ends it is sealed, not deleted: the row stays in the append-only ledger but drops out of the read endpoints. Read access returns 410 retention_expired; verification keeps working, so a proof link you shared years ago still resolves. Upgrading your plan unseals records that fall inside the new retention window.
| Surface | Active record | Sealed (past retention) |
|---|---|---|
| GET /v1/ai/attestations/{id} | 200 | 410 retention_expired |
| GET /v1/ai/attestations (list) | Listed | Hidden |
| GET /v1/ai/attestations/{id}/raw | 200 | 410 retention_expired |
| POST /v1/ai/attestations/{id}/verify | Works | Works |
| GET/POST /v1/proof/ai/* | Works | Works |
Good to know
- Authenticated hash verify draws from your plan's
api_verifications_per_monthquota and returns429 quota_exceededwhen it runs out. Public proof verification is unmetered; it is IP rate limited instead (2/s, 10/min; the keys endpoint 5/s, 200/min). content_hashmust be exactly 64 hex characters. Anything else is400 bad_requeston the authenticated endpoint and400 invalid_hashon the public one.- Unknown ids:
404 attestation_not_found(authenticated) and404 ai_attestation_not_found(public). - Ingestion is asynchronous: a 201 from ingest means durably queued, not yet queryable. A verify issued in the same instant can briefly 404 until the writer persists the row.
attestation_hashequalspayload_hash, and the authenticated verify compares in the order attestation_hash, input_hash, output_hash, payload_hash — so a canonical-payload hash reportsmatched_fieldof "attestation_hash". The public verify compares payload_hash, input_hash, output_hash.