Skip to content

Proof certificates

Every call returns an Ed25519-signed, hash-chained certificate that commits to the request, what left toward the model and the response, without containing any content.

A proof certificate is the receipt of one AI call. It answers, for an auditor who was not there: what was asked, what actually left toward the model provider after personal data was masked, what came back, which provider and model answered, and in what order this happened relative to every other certificate.

Three properties make it worth keeping next to the business record it supports:

  • It holds no content. Prompts and answers are committed by SHA-256 only, so a certificate can be sent to a client, an insurer or a regulator as is.
  • It cannot be forged or edited. The document is signed with an Ed25519 key held by the Gateway. Changing one character changes the hash and breaks the signature.
  • It cannot be silently removed. Each certificate contains the hash of the previous one in a single chain, so a deleted certificate leaves a hole that every verifier can see.
  1. Your call
  2. Hash request
  3. Mask + hash egress
  4. Model answers
  5. Hash response
  6. Sign + chain
  7. Stored
  8. x-pulse-proof
Lifecycle of a certificate. It is written to storage before the response completes, so the id you receive can be fetched immediately.

Getting the proof id

Every successful call on POST /v1/chat/completions and POST /v1/messages carries the id in the x-pulse-proof response header. It has the form pf_ followed by 32 hex characters. On the OpenAI dialect the same id also appears in the completion id (chatcmpl-pf_...) and in the pulse.proof field of the body.

On a streamed response the header is sent with the first byte, before any token, so you have the id even if the stream is interrupted. The certificate itself is persisted before the final [DONE] event (OpenAI dialect), so it is fetchable the moment your stream iterator returns.

A proof failure never fails a completion that was already produced. In the rare case where the certificate could not be written, the header is present but the fetch returns 404; treat that as an incident to report rather than as a client error.

What a certificate contains

GET /v1/proofs/:id returns the envelope below. The payload is the signed document; the other fields are the signature material and bookkeeping.

Envelope fieldTypeMeaning
idstringThe proof id, same as the x-pulse-proof header.
chain_indexintegerPosition in the chain, from 0. Also inside the signed payload as chainIndex.
prev_hashstringpayload_hash of the previous certificate, or GENESIS for index 0. Also inside the payload as prevHash.
payloadobjectThe signed document, described below.
payload_hashstringLower-case hex SHA-256 of the canonical JSON of payload.
signaturestringBase64 Ed25519 signature over the UTF-8 bytes of the payload_hash hex string.
algorithmstringAlways Ed25519.
hashstringThe literal description sha256(canonical-json(payload)).
created_atstringISO 8601 time the row was stored. Not signed; use payload.issuedAt for the signed time.
Payload fieldTypeMeaning
vintegerCertificate format version, currently 1.
idstringThe proof id.
issuerstringAlways subsidia-gateway.
issuedAtstringISO 8601 time of issue, from the Gateway clock.
chainIndex, prevHashinteger, stringThe position and previous link, inside the signed bytes so a certificate cannot be replayed elsewhere in the chain.
request.modelstringThe exact model string you sent, flags included. See Model addressing.
request.sha256stringSHA-256 of the canonical JSON of your request (see below for what exactly is hashed).
addressobjectThe parsed address: kind (auto, agent), agent, cortex, and knowledge: false / memory: false when +nocortex / +nomemory switched them off.
grounding[]object[]Present only when knowledge passages were used (agent turns and +cortex). Each item: source, chunkId, page (or null), sha256 of the passage text.
egress.provider, egress.modelstringWho answered: for example ollama and a local model, or an external provider and its model.
egress.sha256stringSHA-256 of the system prompt and messages as they leave toward the provider, after masking.
egress.pii.maskedintegerHow many personal values were masked.
egress.pii.categoriesstring[]Which categories (EMAIL, PHONE, IBAN...). Never the values.
response.sha256stringSHA-256 of the final answer text delivered to you.
response.finishReasonstringOpenAI dialect: stop, length or tool_calls. Anthropic dialect: end_turn or tool_use.
response.usageobjectpromptTokens, completionTokens, totalTokens.
routing.taskTypestringThe task Synapse resolved (simple, complex, agent, sensitive...).
routing.reasonstringA human-readable explanation of the routing decision. See Synapse.
traceobjectPresent only on agent turns that ran tools: every step committed by hash (tool name, hash of arguments, hash of result), plus the hash of the whole list. Absent otherwise.
A real-shaped certificate (hashes shortened)
{
"id": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10",
"chain_index": 4182,
"prev_hash": "90a8ecd956f1...",
"payload": {
"v": 1,
"id": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10",
"issuer": "subsidia-gateway",
"issuedAt": "2026-10-09T08:12:44.210Z",
"chainIndex": 4182,
"prevHash": "90a8ecd956f1...",
"request": { "model": "pulse-auto+cortex", "sha256": "b2205bba91c4..." },
"address": { "kind": "auto", "cortex": true },
"grounding": [
{ "source": "lease-martin.pdf", "chunkId": "ck_8f31", "page": 4, "sha256": "11d10642ac07..." }
],
"egress": {
"provider": "ollama",
"model": "qwen2.5:7b",
"sha256": "876be0679d3e...",
"pii": { "masked": 0, "categories": [] }
},
"response": {
"sha256": "ae792e49c5b2...",
"finishReason": "stop",
"usage": { "promptTokens": 912, "completionTokens": 64, "totalTokens": 976 }
},
"routing": { "taskType": "complex", "reason": "task=complex -> configured model (ollama/qwen2.5:7b)" }
},
"payload_hash": "5d41a0c3f7e2...",
"signature": "MEUCIQDx...==",
"algorithm": "Ed25519",
"hash": "sha256(canonical-json(payload))",
"created_at": "2026-10-09T08:12:44.233Z"
}

How the commitments are built

HashInputDetail
request.sha256, plain callsCanonical JSON of { "model": <your model string>, "messages": <your messages array> }Exactly the array you sent, before any masking. Anyone holding your original request can recompute it (see the walkthrough).
request.sha256, agent: callsCanonical JSON of { "agent": <agent id>, "message": <last user message> }The agent pipeline binds the certificate to the message the agent answered, not to the whole array.
egress.sha256Canonical JSON of { "systemPrompt", "messages": [{ "role", "content" }] } after maskingComputed by the Gateway with the active shield mode. See Synapse for what is masked.
response.sha256The answer text as UTF-8, personal data restoredWhat you received.
grounding[].sha256The text of each retrieved passageLets you check the passage against the source document.
payload_hashCanonical JSON of the whole payloadWhat the signature covers.

Canonical JSON

Verifying anything requires reproducing the exact bytes that were hashed. The rule is short:

  1. Strings, numbers, booleans and null are serialised with standard JSON (JSON.stringify semantics).
  2. Arrays keep their order: [a,b,c] with no spaces.
  3. Objects have their keys sorted in ascending order, recursively, and any key whose value is undefined is dropped: {"a":1,"b":2} with no spaces.
  4. The result is encoded as UTF-8 before hashing.

In Python, json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False) produces the same bytes for the integers, strings and nulls that certificates contain. ensure_ascii=False matters as soon as an agent name or a source file name has an accent.

Signature and chain

Signature. The Gateway signs the 64-character hex payload_hash string (as UTF-8 bytes, not the raw digest) with an Ed25519 private key generated on first start and stored with the installation. The matching public key is served openly at GET /v1/gateway/public-key in SPKI PEM form.

Chain. Every attestation the engine issues, a completion, a verification report, a register export, is appended to one sequence. Each document includes its chainIndex and the payload_hash of the previous one as prevHash. Two consequences worth knowing:

  • The chain is global to the installation, not per workspace. Your own certificates are interleaved with others', so consecutive ids of yours do not have consecutive indexes. You can only rebuild a link offline when you hold both neighbouring certificates.
  • A certificate you are not allowed to read (another workspace's) is indistinguishable from a missing one. The server-side verification below checks the link for you, because it can see the whole chain.

Endpoints

GET/v1/proofs/:id

Fetch a proof certificate.

API key (Bearer or x-api-key)engine

Returns the signed certificate for a proof id. Certificates are scoped to the workspace of the key: another workspace's proof returns the same 404 as an unknown id, so ids cannot be probed.

Store the certificate next to the record the answer fed. The server keeps it, but a copy in your own archive is what lets an auditor verify without any access to your account.

Path parameters

  • idstringrequired
    The value of the x-pulse-proof header: pf_ followed by 32 hex characters.

Request examples

curl https://api.subsidia.protypa.fr/v1/proofs/pf_a059713a5f1c4e0b8d7a3c21e9b64f10 \
-H "Authorization: Bearer $SUBSIDIA_API_KEY" \
-o certificate.json

Responses

The certificate envelope described above.

Example response
{
"id": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10",
"chain_index": 4182,
"prev_hash": "90a8ecd956f1...",
"payload": { "v": 1, "issuer": "subsidia-gateway", "...": "..." },
"payload_hash": "5d41a0c3f7e2...",
"signature": "MEUCIQDx...==",
"algorithm": "Ed25519",
"hash": "sha256(canonical-json(payload))",
"created_at": "2026-10-09T08:12:44.233Z"
}

Errors

  • 401No key, wrong key, revoked or expired key.
  • 403scope_deniedThe key does not carry the engine scope.
  • 404proof_not_foundUnknown id or another workspace.

GET/v1/proofs/:id/verify

Verify a certificate on the server.

API key (Bearer or x-api-key)engine

Recomputes the payload hash, checks the Ed25519 signature against the Gateway's key, and checks that the previous record in the chain still exists with the hash this certificate points to. It is the quick check. Because it runs on the server it asks you to trust the server; for an independent check use the offline walkthrough below.

Path parameters

  • idstringrequired
    Certificate id.

Request examples

curl https://api.subsidia.protypa.fr/v1/proofs/$PROOF_ID/verify \
-H "Authorization: Bearer $SUBSIDIA_API_KEY"

Responses

valid is true only when both the signature and the chain link hold. reason is null when valid, otherwise a short explanation.

Example response
{
"id": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10",
"signature_valid": true,
"chain_valid": true,
"valid": true,
"reason": null
}

Errors

  • 401No key, wrong key, revoked or expired key.
  • 403scope_deniedThe key does not carry the engine scope.
  • 404proof_not_foundUnknown id or another workspace.

Notes

Reasons you may see: payload hash mismatch — document was altered, signature does not verify against the gateway public key, previous chain record missing or altered. A certificate with chain_index 0 has no previous record to check.

GET/v1/gateway/public-key

Public key for offline verification.

None

Open route, no credentials required, on purpose: an auditor holding only a certificate and this key can validate the certificate with no access to your account. Fetch it once and archive it with the certificates, so verification keeps working with no network access.

Request examples

curl https://api.subsidia.protypa.fr/v1/gateway/public-key

Responses

The key in SPKI PEM format.

Example response
{ "algorithm": "Ed25519", "format": "spki-pem", "public_key": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" }

Verify a certificate offline

This is the check an auditor runs. It needs two files and no network, no account and no trust in the server: the certificate JSON and the public key PEM. It proves that the payload is byte-for-byte what the Gateway signed.

  1. 1
    Collect the two files

    Save the certificate from GET /v1/proofs/:id as certificate.json and the key from GET /v1/gateway/public-key as public-key.pem (the public_key field). The second call needs no credentials.

    bash
    curl -s https://api.subsidia.protypa.fr/v1/proofs/$PROOF_ID \
    -H "Authorization: Bearer $SUBSIDIA_API_KEY" -o certificate.json
    curl -s https://api.subsidia.protypa.fr/v1/gateway/public-key \
    | python3 -c "import sys, json; sys.stdout.write(json.load(sys.stdin)['public_key'])" > public-key.pem
  2. 2
    Recompute the payload hash

    Serialise payload as canonical JSON (keys sorted, no spaces), SHA-256 it, and compare with payload_hash. A mismatch means the document was altered after signing.

  3. 3
    Verify the signature

    Verify the Ed25519 signature over the hex string payload_hash (its UTF-8 bytes, not the 32 raw bytes) with the public key. Reject the certificate if either step 2 or step 3 fails.

  4. 4
    Optionally bind it to your own data

    If you kept the request you sent, recompute request.sha256 and the response hash and compare them with the payload. That proves the certificate is about your exchange and not another.

Offline verifier
// node verify.mjs certificate.json public-key.pem
import fs from 'node:fs'
import crypto from 'node:crypto'
const [certPath, keyPath] = process.argv.slice(2)
const cert = JSON.parse(fs.readFileSync(certPath, 'utf8'))
const publicKeyPem = fs.readFileSync(keyPath, 'utf8')
// Keys sorted recursively, undefined dropped, no whitespace.
function canonicalJson(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'
const entries = Object.entries(value)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
return '{' + entries.map(([k, v]) => JSON.stringify(k) + ':' + canonicalJson(v)).join(',') + '}'
}
const sha256 = (text) => crypto.createHash('sha256').update(text, 'utf8').digest('hex')
// 1. The payload must hash to payload_hash.
const hashOk = sha256(canonicalJson(cert.payload)) === cert.payload_hash
// 2. The signature covers the hex string payload_hash.
const sigOk =
hashOk &&
crypto.verify(
null, // Ed25519 takes no separate digest algorithm
Buffer.from(cert.payload_hash, 'utf8'),
crypto.createPublicKey(publicKeyPem),
Buffer.from(cert.signature, 'base64'),
)
// 3. The envelope fields must agree with the signed payload.
const envelopeOk =
cert.payload.id === cert.id &&
cert.payload.chainIndex === cert.chain_index &&
cert.payload.prevHash === cert.prev_hash
console.log('payload hash :', hashOk ? 'match' : 'MISMATCH (document altered)')
console.log('signature :', sigOk ? 'valid' : 'INVALID')
console.log('envelope :', envelopeOk ? 'consistent' : 'INCONSISTENT')
console.log('egress :', cert.payload.egress.provider + '/' + cert.payload.egress.model, '| masked:', cert.payload.egress.pii.masked)
process.exit(hashOk && sigOk && envelopeOk ? 0 : 1)
// Optional 4. Bind to the request you sent (plain calls, not agent: calls):
// sha256(canonicalJson({ model: cert.payload.request.model, messages: yourMessagesArray }))
// must equal cert.payload.request.sha256

Two proof levels

LevelWhenWhat it adds
1. PipelineEvery callRequest, masked egress, provider and model, response, usage and routing reason.
2. Groundingagent: calls and calls with +cortexA grounding array committing to the exact knowledge passages the answer drew on.
2+. Traceagent: turns that ran toolsA trace committing to every tool step by hash.

What a proof does not prove

A certificate is strong evidence about the pipeline, and says nothing beyond it. State the limits when you show one to someone:

  • Not that the answer is correct. It proves what was answered, not that it is true. To check an answer against your documents, use Le Vérificateur.
  • Not the content. The certificate holds hashes. Whoever holds the original text can confirm a match; whoever holds only the certificate learns the shape of the exchange, not its substance.
  • Not independent of the Gateway. The signing key belongs to the Gateway. The certificate proves the document was issued by this installation and not edited since; it cannot prove that the installation recorded the truth, for example that the provider received exactly the masked text hashed in egress.sha256. That hash is computed by the Gateway, which is the party being audited.
  • Not the provider's behaviour. Retention, training use and logging on the provider side are governed by your contract with the provider. The certificate tells you which provider and model answered, and whether the call stayed local.
  • Not that the answer used the grounding faithfully. grounding shows which passages were supplied to the model, not that the model quoted them accurately.
  • Not a trusted timestamp. issuedAt comes from the Gateway clock and is signed, but it is not countersigned by an independent time authority. The chain proves order, not wall-clock time.
  • Not completeness of your own records. A call that was never made through the Gateway has no certificate. The chain exposes deleted certificates, not calls made elsewhere.
  • Not cost. Billing in questions is not part of the document. Usage tokens are included for transparency.

Frequently asked

Is the header present on errors?

No. A call rejected before any model work (a 400, 402, 403, 404, 429) produces no certificate and no x-pulse-proof. Mid-stream failures after the headers went out can leave a proof id with no stored certificate, because the certificate is written only after the answer completes.

How long are certificates kept?

They are stored with the installation and fetchable by id for your workspace. Archive the JSON next to your own record if you need a retention period you control.

Why sign the hex string and not the raw digest?

It is how the Gateway signs: the Ed25519 message is the 64 ASCII characters of payload_hash. If you verify with a library that expects the raw digest you will get a false negative. Pass the string's UTF-8 bytes, as in the examples.

My canonical JSON does not match in Python.

Check three things: sort_keys=True, separators=(',', ':') and ensure_ascii=False. Parse the certificate and re-serialise payload; never hash the raw text of the file, whose spacing is not the canonical form.

Can I share a certificate publicly?

It contains no prompt or answer text, but it does reveal the model string you used, the names of knowledge sources in grounding, the provider, and timing. Review those before publishing.

Do agent turns and Gateway calls produce the same certificate format?

Yes, one format and one chain. Agent turns add trace when tools ran, and record the agent identifier the pipeline holds internally. See Model addressing.

Related