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.
- Your call
- Hash request
- Mask + hash egress
- Model answers
- Hash response
- Sign + chain
- Stored
- x-pulse-proof
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 field | Type | Meaning |
|---|---|---|
id | string | The proof id, same as the x-pulse-proof header. |
chain_index | integer | Position in the chain, from 0. Also inside the signed payload as chainIndex. |
prev_hash | string | payload_hash of the previous certificate, or GENESIS for index 0. Also inside the payload as prevHash. |
payload | object | The signed document, described below. |
payload_hash | string | Lower-case hex SHA-256 of the canonical JSON of payload. |
signature | string | Base64 Ed25519 signature over the UTF-8 bytes of the payload_hash hex string. |
algorithm | string | Always Ed25519. |
hash | string | The literal description sha256(canonical-json(payload)). |
created_at | string | ISO 8601 time the row was stored. Not signed; use payload.issuedAt for the signed time. |
| Payload field | Type | Meaning |
|---|---|---|
v | integer | Certificate format version, currently 1. |
id | string | The proof id. |
issuer | string | Always subsidia-gateway. |
issuedAt | string | ISO 8601 time of issue, from the Gateway clock. |
chainIndex, prevHash | integer, string | The position and previous link, inside the signed bytes so a certificate cannot be replayed elsewhere in the chain. |
request.model | string | The exact model string you sent, flags included. See Model addressing. |
request.sha256 | string | SHA-256 of the canonical JSON of your request (see below for what exactly is hashed). |
address | object | The 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.model | string | Who answered: for example ollama and a local model, or an external provider and its model. |
egress.sha256 | string | SHA-256 of the system prompt and messages as they leave toward the provider, after masking. |
egress.pii.masked | integer | How many personal values were masked. |
egress.pii.categories | string[] | Which categories (EMAIL, PHONE, IBAN...). Never the values. |
response.sha256 | string | SHA-256 of the final answer text delivered to you. |
response.finishReason | string | OpenAI dialect: stop, length or tool_calls. Anthropic dialect: end_turn or tool_use. |
response.usage | object | promptTokens, completionTokens, totalTokens. |
routing.taskType | string | The task Synapse resolved (simple, complex, agent, sensitive...). |
routing.reason | string | A human-readable explanation of the routing decision. See Synapse. |
trace | object | Present 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. |
{ "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
| Hash | Input | Detail |
|---|---|---|
request.sha256, plain calls | Canonical 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: calls | Canonical 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.sha256 | Canonical JSON of { "systemPrompt", "messages": [{ "role", "content" }] } after masking | Computed by the Gateway with the active shield mode. See Synapse for what is masked. |
response.sha256 | The answer text as UTF-8, personal data restored | What you received. |
grounding[].sha256 | The text of each retrieved passage | Lets you check the passage against the source document. |
payload_hash | Canonical JSON of the whole payload | What the signature covers. |
Canonical JSON
Verifying anything requires reproducing the exact bytes that were hashed. The rule is short:
- Strings, numbers, booleans and
nullare serialised with standard JSON (JSON.stringifysemantics). - Arrays keep their order:
[a,b,c]with no spaces. - Objects have their keys sorted in ascending order, recursively, and any key whose value is
undefinedis dropped:{"a":1,"b":2}with no spaces. - 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.
engineReturns 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
idstringrequiredThe value of thex-pulse-proofheader: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.jsonResponses
The certificate envelope described above.
{ "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.
- 403
scope_deniedThe key does not carry theenginescope. - 404
proof_not_foundUnknown id or another workspace.
GET/v1/proofs/:id/verify
Verify a certificate on the server.
engineRecomputes 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
idstringrequiredCertificate 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.
{ "id": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10", "signature_valid": true, "chain_valid": true, "valid": true, "reason": null}Errors
- 401No key, wrong key, revoked or expired key.
- 403
scope_deniedThe key does not carry theenginescope. - 404
proof_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.
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-keyResponses
The key in SPKI PEM format.
{ "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.
- 1Collect the two files
Save the certificate from
GET /v1/proofs/:idascertificate.jsonand the key fromGET /v1/gateway/public-keyaspublic-key.pem(thepublic_keyfield). The second call needs no credentials.bashcurl -s https://api.subsidia.protypa.fr/v1/proofs/$PROOF_ID \-H "Authorization: Bearer $SUBSIDIA_API_KEY" -o certificate.jsoncurl -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 - 2Recompute the payload hash
Serialise
payloadas canonical JSON (keys sorted, no spaces), SHA-256 it, and compare withpayload_hash. A mismatch means the document was altered after signing. - 3Verify 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. - 4Optionally bind it to your own data
If you kept the request you sent, recompute
request.sha256and the response hash and compare them with the payload. That proves the certificate is about your exchange and not another.
// node verify.mjs certificate.json public-key.pemimport 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.sha256Two proof levels
| Level | When | What it adds |
|---|---|---|
| 1. Pipeline | Every call | Request, masked egress, provider and model, response, usage and routing reason. |
| 2. Grounding | agent: calls and calls with +cortex | A grounding array committing to the exact knowledge passages the answer drew on. |
| 2+. Trace | agent: turns that ran tools | A 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.
groundingshows which passages were supplied to the model, not that the model quoted them accurately. - Not a trusted timestamp.
issuedAtcomes 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.