Attestation schema
Every attestation you send to POST /v1/ai/attestationsuses one strict envelope: what went into the model, what came out, and which model produced it. From that request the server derives three SHA-256 hashes, stores a byte-identical canonical copy, and signs a compact statement over the hashes with your tenant's Ed25519 key. This page walks through each layer: the request you send, the three hashes, the signed statement, the stored record, and what happens when you send the same thing twice.
https://api.invoance.com/v1·Auth Authorization: Bearer invoance_live_... or X-API-Key (write scope to ingest)The request envelope
Exactly what you send, with every field populated: the payload pair, the model identity, an optional subject (who or what the output was about), and an optional trace_id to file the attestation into an open trace. The Idempotency-Key header is optional but recommended for safe retries.
POST /v1/ai/attestationscurl -X POST https://api.invoance.com/v1/ai/attestations \
-H "Authorization: Bearer invoance_live_xxx" \
-H "Idempotency-Key: 9c2f6a1e-8f4b-4c3a-b1d2-7e5a9c0d4f6b" \
-H "Content-Type: application/json" \
-d '{
"type": "output",
"payload": {
"input": "Summarize the attached contract for a non-lawyer.",
"output": "This agreement covers a 12-month SaaS subscription..."
},
"context": {
"model_provider": "openai",
"model_name": "gpt-4o",
"model_version": "2024-11-20"
},
"subject": {
"user_id": "user_42",
"session_id": "sess_9d1c",
"ticket": "SUP-1042"
},
"trace_id": "018f6b2a-7c4d-7e9a-b3f1-2a5c8d9e0f11"
}'The 201 response returns the id and all three hashes so you can store them alongside your own records:
{
"attestation_id": "018f6b2a-9a3e-7c21-8d4f-5b6a7c8d9e0f",
"created_at": "2026-07-04T12:00:00Z",
"input_hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
"output_hash": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
"payload_hash": "fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9",
"status": "accepted"
}| Field | Type | Required | Notes |
|---|---|---|---|
| type | string enum | Yes | One of output | decision | approval. Anything else is rejected 400 invalid_attestation_type. |
| payload | object | Yes | The exact input/output pair being attested. |
| payload.input | string | Yes | What went into the model, verbatim. Non-empty (400 empty_payload). Hashed as input_hash. |
| payload.output | string | Yes | What the model produced, verbatim. Non-empty (400 empty_payload). Hashed as output_hash. |
| context | object | Yes | Model identity at the moment of the call. It becomes part of the signed statement. |
| context.model_provider | string | Yes | e.g. openai, anthropic, self-hosted. Non-empty (400 invalid_context). |
| context.model_name | string | Yes | e.g. gpt-4o. Non-empty (400 invalid_context). |
| context.model_version | string | Yes | e.g. 2024-11-20. Non-empty (400 invalid_context). |
| subject | object | No | Who or what the output was about. Whole block ≤ 8 KB serialized (400 subject_too_large). |
| subject.user_id | string | No | Your end-user id. Surfaced on the public proof page. |
| subject.session_id | string | No | Your session/conversation id. Surfaced on the public proof page. |
| subject.* (extras) | JSON values | No | Up to 20 extra keys beyond user_id/session_id (400 subject_too_many_keys). Stored and hashed in alphabetical key order. |
| trace_id | string (UUID) | No | Files the attestation into one of your traces. The trace must exist (404 trace_not_found) and be open (409 trace_not_open once sealing has started). |
- The whole request body must serialize to ≤ 1 MB (413 payload_too_large). Attest large payloads by sending the exact text you want provable, not attachments.
- The
Idempotency-Keyheader is optional; when present, retries with the same key and body replay the original response (see the last section). - Ingestion is asynchronous: a 201 means the attestation is durably queued and its hashes are final, but a GET immediately afterwards can 404 for a moment until the writer persists the row.
The three hashes
Every attestation carries three SHA-256 digests, computed synchronously at ingest and returned in the 201 response. Anyone holding the original text can recompute them and compare, no Invoance tooling required.
| Hash | SHA-256 over | Notes |
|---|---|---|
| input_hash | The UTF-8 bytes of payload.input | Recompute locally: sha256 of the exact input string must match. |
| output_hash | The UTF-8 bytes of payload.output | Same recipe: sha256 of the exact output string. |
| payload_hash | The canonical request bytes (below) | The root hash. Also stored as attestation_hash, unique per tenant, and used as the deduplication key. |
Canonical bytes are not your raw wire bytes. The server parses your request, then re-serializes it in a fixed struct field order: type, payload (input, output), context (model_provider, model_name, model_version), subject (user_id and session_id when present, then extra keys alphabetically), trace_id. Absent optional fields are omitted entirely, whitespace is compacted, and key order is normalized, so the same content always produces the same bytes regardless of how you formatted the request:
{"type":"output","payload":{"input":"Summarize the attached contract for a non-lawyer.","output":"This agreement covers a 12-month SaaS subscription..."},"context":{"model_provider":"openai","model_name":"gpt-4o","model_version":"2024-11-20"},"subject":{"user_id":"user_42","session_id":"sess_9d1c","ticket":"SUP-1042"},"trace_id":"018f6b2a-7c4d-7e9a-b3f1-2a5c8d9e0f11"}A byte-identical copy of these canonical bytes is stored in object storage and served back verbatim, so hashing exactly what the raw endpoint returns reproduces payload_hash:
/v1/ai/attestations/{attestation_id}/rawReturns the exact canonical JSON bytes that were hashed at ingest.The signed statement
What gets signed is not your payload itself but a compact, server-built canonical statement that binds the three hashes to your tenant, the model identity, and the accept time. It is serialized in exactly this field order:
{
"v": 1,
"attestation_id": "018f6b2a-9a3e-7c21-8d4f-5b6a7c8d9e0f",
"tenant_id": "0189a7c2-4f6e-7b3d-9a1c-8e2f5d6a7b4c",
"attestation_type": "output",
"input_hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
"output_hash": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
"payload_hash": "fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9",
"model_provider": "openai",
"model_name": "gpt-4o",
"model_version": "2024-11-20",
"created_at": "2026-07-04T12:00:00Z"
}These exact bytes are stored as signed_payloadand signed directly with your tenant's per-tenant Ed25519 key (plain Ed25519, no pre-hash). The 64-byte signature and the verifying public_key are returned on GET. Because the statement contains payload_hash, the signature transitively covers every byte of your original request.
One honesty note: the SDKs' verify_signature() / verifySignature() fetch the record from the API and check the signature against the public_keyembedded in that same response. That proves the record is internally consistent, but it trusts the server it fetched from — it is not an offline check. For a check that does not take the record's word for the key, compare against the key Invoance publishes for your verified domain:
/keys/{domain}Public, unauthenticated tenant signing key (base64url Ed25519), served from the write-once key registry. Note: root level, no /v1 prefix.The stored record
The persisted attestation, as returned by GET /v1/ai/attestations/{attestation_id}. Rows live in an append-only ledger: database triggers reject DELETE, and retention is handled by sealing, never by removal.
| Field | Type | Notes |
|---|---|---|
| attestation_id | UUID | Minted at ingest; the id used in every endpoint path and public proof URL. |
| tenant_id | UUID | Your tenant. Also inside the signed statement. |
| attestation_type | string | output | decision | approval. |
| attestation_hash | hex SHA-256 | The root hash. Equal to payload_hash; unique per tenant and used as the dedup key. |
| input_hash | hex SHA-256 | Hash of payload.input. |
| output_hash | hex SHA-256 | Hash of payload.output. |
| signed_payload | hex bytes | The exact canonical statement bytes that were signed (previous section). |
| signature | hex (64 bytes) | Ed25519 signature over signed_payload. |
| signature_alg | string | Always ed25519. |
| public_key | hex (32 bytes) | Verifying key snapshot stored on the row. The public proof and verify endpoints deliberately serve the write-once registered tenant key instead; cross-check against GET /keys/{domain}. |
| model_provider / model_name / model_version | string | null | Copied from context at ingest. |
| subject_user_id / subject_session_id | string | null | From subject; shown on the public proof page. |
| trace_id | UUID | null | Set when the attestation was ingested into an open trace; its hash is included in that trace's composite seal hash. |
| retention_policy | string | short | standard | extended | regulatory | indefinite, derived from your plan's retention window at write time. |
| expires_at | RFC3339 | null | created_at + retention window; null when retention is indefinite. |
| access_tier | string | active | sealed. See below. |
| created_at | RFC3339 | Server accept time; this is the timestamp inside the signed statement. |
| organization | object | Issuer identity attached on GET: name, issuer_name, primary_domain, domain_verified (+ verified date and logo when set). |
Sealed rows. When the retention window ends, the retention worker flips access_tier to sealed instead of deleting. Sealed attestations disappear from the list endpoint and return 410 retention_expired on GET and on /raw, but they remain verifiable: the verify endpoints and the public proof page keep working, and a plan upgrade unseals them.
Deduplication and idempotency
Two independent layers keep retries safe. Content-level dedup keys on payload_hash per tenant and needs no header; the Idempotency-Key header adds exact response replay on top.
| You send | Response | What it means |
|---|---|---|
| The same body again (any or no Idempotency-Key) | 200 status: "duplicate" | Content dedup: identical canonical bytes → identical payload_hash → the original attestation_id is returned. One record, ever. |
| The same Idempotency-Key + the same body | original response, replayed | The completed response is cached and replayed verbatim for 24 hours. |
| The same Idempotency-Key + a different body | 409 idempotency_key_reuse_mismatch | Key reuse with changed content is rejected; nothing new is stored. |
| A retry while the first request is still in flight | 202 status: "processing" | The original holds a reservation (self-clears within 120 s). Back off and retry; you will then hit the replay or dedup path. |
Because dedup keys on content alone, two genuinely identical attestations collapse into one record. If you need distinct records for identical input/output pairs (say, the same answer served to two requests), add a distinguishing subject key such as a request id — it changes the canonical bytes and therefore the payload_hash.