Verifying attestations
Four ways to check a record, from a public link to an offline signature check.
Every attestation carries three SHA-256 hashes and an Ed25519 signature over a short signed payload. Each check below compares something you hold with one of those.
A hash match says the text or bytes you hold are the ones recorded. A signature check says the record was signed by the tenant's key. Pick the check your question needs.
| Public proof page | Anyone with the link. Hashes compared in the browser; the registered key and signature shown. |
|---|---|
| Verify endpoint | Your API key. The server compares a hash you computed with the stored hashes. |
| Raw payload | Your API key. The exact stored bytes, so you can recompute payload_hash yourself. |
| Offline signature | Ed25519 over the signed bytes with a key you fetched by the issuer's domain. |
- Opens the proof linkNo account, no dashboard access
- Recompute the hashSubmitted payload against anchored hash
- match_result: truePlus issuer domain and signing time
https://www.invoance.com/proof/ai/{attestation_id} opens without an account. It shows the issuer, the type, created_at, the three hashes, attestation_hash, signature_alg, the public key and the signature. The input and output are not shown.
The public key on the page is the tenant's registered key from the key table, not the key stored on the row.
The visitor pastes or drops a copy. Every check runs in the browser; nothing is uploaded.
- Text: SHA-256 of the pasted text, compared with payload_hash, then output_hash, then input_hash.
- Payload: SHA-256 of the exact bytes pasted or chosen as a file, compared with payload_hash.
- Bundle: the file exported from the dashboard, checked six ways. See below.
The signature is checked only in bundle mode. Until then the page reports the hashes alone.
/v1/proof/ai/{attestation_id}Read an AI attestation's public proofPOST/v1/proof/ai/{attestation_id}/verifyVerify an AI content hash publiclyGET/keys/{domain}Fetch an organization's public keyThe public verify endpoint compares a hash with payload_hash, input_hash and output_hash. It also reports signature_valid, checked against the registered key. No quota is used.
Compute the SHA-256 of the input, the output or the raw payload bytes and post it as content_hash. The reply names the stored hash it matched. One call uses one API verification from the monthly quota. The signature is not checked.
GET /v1/ai/attestations/{attestation_id}/raw returns the stored canonical bytes, the ones hashed at ingest. Their SHA-256 is attestation_hash.
It exists so you can recompute payload_hash without rebuilding the serialization. Hash the body as returned; a pretty printed or reordered copy hashes differently.
The bytes are cached for 24 hours per attestation. A sealed record returns 410. The field order and rules are on the schema page.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const raw = await client.attestations.getRaw("7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4");
console.log(raw.type, raw.context);
The dashboard exports one attestation as ai-attestation-{attestation_id}.json. The proof page's Bundle mode reads that file.
signed_payload in the bundle is the dashboard's summary of the record, not the hex bytes the get endpoint returns. signature and public_key are standard base64 here and hex on the API.
| schema | Always invoance.ai.attestation.bundle.v1. |
|---|---|
attestation_id | The record's id. |
signed_payload | An object: v, attestation_type, attestation_hash, input_hash, output_hash, model, model_provider, model_version, signer_type, signer_label. |
payload_hash | The record's payload_hash, hex. |
| signature | The record's Ed25519 signature, standard base64. |
public_key | The key stored on the record's row, standard base64. |
signature_alg | ed25519. |
- attestation_id equals the id in the proof page's URL.
- SHA-256 of signed_payload, written as compact JSON with keys sorted, equals the bundle's payload_hash.
- The Ed25519 signature verifies over those bytes with the bundle's public_key.
- public_key equals the key on the proof page.
- signature_alg equals the algorithm on the proof page.
- payload_hash equals the payload_hash on the proof page.
All six must pass for the page to report a match.
{
"schema": "invoance.ai.attestation.bundle.v1",
"attestation_id": "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4",
"signed_payload": {
"v": 1,
"attestation_type": "output",
"attestation_hash": "c4efe15781214a84046ad7e0592977c634a5cf45f4c1e06160daf760a295a8df",
"input_hash": "a46f0ad5dfb45fcbe2c303dcb34292af2f63fd79c6ff45b52dc40703a1cc5a70",
"output_hash": "a11ffee6d6ba93b478f557318a2b1a823f6e2816e0bd5d8178cc889a30e40fe5",
"model": "gpt-4.1",
"model_provider": "openai",
"model_version": "2026-04-14",
"signer_type": "tenant_ed25519",
"signer_label": "tenant:3b9d6f10-52c4-4a7e-9e1b-8d0c2f4a6e71"
},
"payload_hash": "c4efe15781214a84046ad7e0592977c634a5cf45f4c1e06160daf760a295a8df",
"signature": "2Y+SdKjEDHC2WeFt6Fk3pOoksQKqtmwUNTCjNWSgp13InR1GwBfQzPSLYifVx75UoZJ7smmFIZuTvILaYuvxCg==",
"public_key": "GjBF6LSRpE3ca5yKhoAQSUXGXa/1HnfpfRmtbpy6iR8=",
"signature_alg": "ed25519"
}
Two fetches, then no more calls. The server signed the exact bytes it returns, so there is no canonicalization step on your side.
Fetch the record
GET /v1/ai/attestations/{attestation_id}with a read key. Keep signed_payload, signature and public_key. All three are hex.Fetch the issuer's key by domain
GET https://api.invoance.com/keys/{domain}, no key needed. public_key is base64url without padding; decode it to 32 bytes. It must equal the hex decoded public_key from step 1.Take the signed bytes as returned
Hex decode signed_payload. Do not parse it and write it again; the signature covers these bytes and no others.
Verify Ed25519
Check the 64-byte signature over the decoded bytes with the 32-byte key. Plain Ed25519, no prehash.
Tie the content to the signature
Parse the decoded bytes and read payload_hash, input_hash and output_hash. Compare them with the SHA-256 of the raw payload, the input text or the output text you hold.
The Node and Python SDK helpers do steps 1, 3 and 4 with the public_key in the same response. Step 2 is what ties the record to the issuer; the samples add it.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
// Fetches the record, then checks the Ed25519 signature over
// signed_payload with the public_key in that same response.
const result = await client.attestations.verifySignature("7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4");
console.log(result.valid, result.reason);
console.log(result.signedData?.payload_hash);
// Pin the key: the issuer's registered key, fetched by domain.
const res = await fetch("https://api.invoance.com/keys/northwindlegal.example");
if (!res.ok) throw new Error("key lookup failed: " + res.status);
const key = await res.json();
const pinned = Buffer.from(key.public_key, "base64url");
const sameKey = pinned.equals(Buffer.from(result.attestation.public_key, "hex"));
console.log(result.valid && sameKey);
When the plan's retention window ends, the row is sealed, not deleted. Reads stop; verification keeps working.
A plan upgrade can unseal a record.
GET /v1/ai/attestations/{attestation_id} | 410 retention_expired |
|---|---|
GET /v1/ai/attestations/{attestation_id}/raw | 410 retention_expired |
GET /v1/ai/attestations | Left out of the rows and the total |
POST /v1/ai/attestations/{attestation_id}/verify | Works. access_tier is not checked. |
| /proof/ai/{attestation_id} page and endpoints | Work. The public handlers do not check access_tier. |
Does the verify endpoint check the signature?
No. It compares hashes only. The public verify endpoint also reports signature_valid, and the SDK helpers check the signature from the get response.
Which key should I trust?
The one GET /keys/{domain}returns for the issuer's verified domain. The proof page shows that same registered key; the get endpoint returns the key stored on the row. Compare them before trusting a signature.
Does a public verification use my quota?
No. The public endpoints have no quota check. Each call is recorded as a verification event with source public_ai_url, and each proof page load as a view.
Why does get return 404 right after ingest?
The 201 means the record is on the write queue. A worker signs it and writes the row a moment later; until then get, raw and verify cannot find it.