Le Vérificateur
Check every claim of a text against passages you send or documents in Cortex, with zero model calls, and get a report signed in the Subsidia proof chain.
Le Vérificateur takes a text from anywhere (an answer your own RAG produced, a note written by a chatbot, a draft letter) and gives every claim in it a verdict against a set of passages. The result is a report, signed in the same hash chain as Gateway certificates, that an auditor can recheck without trusting Subsidia.
You do not need Cortex. If you already run your own retrieval (Qdrant, pgvector, Elasticsearch, anything), send the text and the passages your retrieval returned. If the documents live in Subsidia, name the collections instead.
The four verdicts
A text is cut into claims (one sentence per non-structural line). A claim is judged on its anchors: the values a person copies and a model invents, meaning numbers, amounts, dates, references and proper names. Each anchor gets a verdict; the claim takes the worst of its anchors.
| Verdict | Rule | What to do with it |
|---|---|---|
verified | Every anchor of the claim was found in a passage that talks about the same subject. The evidence gives the source, the page, and an exact quote around the value. | Nothing. Show the source next to the claim. |
unsupported | The claim has anchors, and at least one is carried by no passage. | Source it or remove it. Nothing in the passages says this. |
contradicted | A passage on the same subject carries a different value with the same unit, and does not also carry the announced one. The anchor result shows the value found in found. | Handle before the text goes out. |
unverifiable | The claim has no anchor: reasoning, opinion, a question, a courtesy phrase. | Out of scope. This is information, not a defect. |
Zero model calls, and why
Every verdict is computed deterministically; no AI model is called at any point. A provenance guarantee produced by a model is one you would have to believe. This one gives the same result on two runs, costs no tokens, runs on a machine with no model loaded, and a third party who holds the text and the passages recomputes exactly your verdicts.
It follows that Le Vérificateur:
- never rewrites the text and never suggests a correction (suggesting one is generating);
- never judges substance: it says where a value comes from, not whether the clause is wise;
- never gives a percentage or a global score: you get counts (12 verified, 3 unsupported, 1 contradicted), because a percentage invites nobody to read the detail.
Where the passages come from
| `coverage.mode` | How you get it | What the report attests |
|---|---|---|
provided | You send passages. | This text, checked against these passages, gave these verdicts. It does not attest that the passages come from your client's documents: Subsidia never saw those documents, and the report says so. The full set of passages you sent is committed in the certificate (pool.sha256), so nobody can claim afterwards that a passage was missing. |
exhaustive | You send collectionIds and the collections hold at most 1,500 passages. | Checked against the whole content of those collections, read from the documents themselves. |
retrieved | You send collectionIds that are too large, or none (the whole workspace). | Checked against what a search returned for each claim (top 4 passages per claim). The report states this: a check whose scope is unknown is not opposable. |
Controls, not questions
A check calls no model, so it is not billed as a question. One report is one control, wherever it comes from (the app or the API), counted per month and shown separately. A key's monthly question ceiling does not count controls. GET /v1/verify/usage returns this month's count. For integrators, controls appear on each client statement (Partner API).
Authentication and scope
All routes below need a developer API key (Authorization: Bearer sk_live_... or x-api-key: sk_live_...) carrying the proof scope. Keys created before scopes existed keep reaching them. A read-only key may call POST /v1/verify, since a check changes nothing in your data. See Authentication.
CORS is open on these routes, so an integrator's own web application can call them from the browser (never ship a live key to a browser you do not control).
Endpoints
POST/v1/verify
Check a text against passages or Cortex collections; returns a signed report.
proofGives every claim of text a verdict. Send passages (what your own retrieval returned) or collectionIds (Cortex collections), not both. With neither, the check runs against the whole workspace in retrieved mode.
The report is saved, signed and returned with its certificate inline, so one call is enough to get the verdicts and the proof. Limited to 60 requests per minute per key.
Request body
textstringrequiredThe text to check. Only the first 60,000 characters are analysed (the report then hascoverage.truncated: true), and at most 150 claims. A text longer than 240,000 characters is refused with 413.titlestringUp to 160 characters, shown in the app and in the dossier. Defaults to the first line of the text. It is never written into the certificate.passagesobject[]What to check against: 1 to 1,500 passages, each up to 20,000 characters, 3,000,000 characters in total.collectionIdsstring[]Cortex mode: the collections to check against. Ignored ifpassagesis sent without it; sending both is a 400.
Request examples
curl -X POST https://api.subsidia.protypa.fr/v1/verify \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Synthese bail commercial", "text": "Le loyer mensuel est de 1 250 €. Le préavis du preneur est de 30 jours.", "passages": [ { "id": "qdrant-17", "source": "Bail commercial.pdf", "page": 3, "content": "Le loyer mensuel du lot est de 1 250 €. Le préavis du preneur est de 90 jours." } ] }'Responses
The saved report and its certificate. certificate is null only if signing failed, in which case report.proofId is null too: you still get the verdicts, announced as unsigned.
{ "report": { "id": "cm2k9x3a40001abcd1234efgh", "title": "Synthese bail commercial", "createdAt": "2026-10-09T08:14:03.221Z", "counts": { "claims": 2, "verified": 1, "unsupported": 0, "contradicted": 1, "unverifiable": 0 }, "claims": [ { "index": 1, "line": 1, "text": "Le loyer mensuel est de 1 250 €.", "verdict": "verified", "anchors": [ { "text": "1 250 €", "verdict": "verified", "evidence": { "sourceId": "Bail commercial.pdf", "sourceName": "Bail commercial.pdf", "section": null, "page": 3, "chunkId": "qdrant-17", "quote": "Le loyer mensuel du lot est de 1 250 €. Le préavis du preneur est de 90 jours." } } ] }, { "index": 2, "line": 1, "text": "Le préavis du preneur est de 30 jours.", "verdict": "contradicted", "anchors": [ { "text": "30 jours", "verdict": "contradicted", "found": "90 jours", "evidence": { "sourceId": "Bail commercial.pdf", "sourceName": "Bail commercial.pdf", "section": null, "page": 3, "chunkId": "qdrant-17", "quote": "Le loyer mensuel du lot est de 1 250 €. Le préavis du preneur est de 90 jours." } } ] } ], "coverage": { "mode": "provided", "chunks": 1, "sources": 1, "collectionIds": [], "truncated": false }, "proofId": "vr_cm2k9x3a40001abcd1234efgh", "payloadHash": "9f2c41d0a7..." }, "certificate": { "id": "vr_cm2k9x3a40001abcd1234efgh", "chain_index": 41, "prev_hash": "5be1c07d33...", "payload": { "kind": "verification", "issuer": "subsidia-verifier" }, "payload_hash": "9f2c41d0a7...", "signature": "base64...", "algorithm": "Ed25519", "hash": "sha256(canonical-json(payload))", "created_at": "2026-10-09T08:14:03.240Z" }, "certificate_check_url": "https://subsidia.protypa.fr/certificat"}Errors
- 400
text_requiredtextis missing or blank. - 400
invalid_passagespassagesis empty, not an array, has more than 1,500 entries, an entry withoutsourceorcontent, a duplicateid, or exceeds the size limits. - 400
ambiguous_scopeBothpassagesandcollectionIdswere sent. - 401Missing, invalid, revoked or expired key.
- 403
scope_deniedThe key does not carry theproofscope. - 413
text_too_largeThe text exceeds 240,000 characters. - 429
rate_limitedMore than 60 requests per minute from this key, or the key's own requests-per-minute limit. Honourretry-after.
Notes
The call is also written to the workspace access journal (action light.verify, with via: api). Only the verdict counts and the proof id are logged, not the text.
GET/v1/verify
List the reports of the workspace, newest first.
proofReturns summaries only (no text, no claims). Fetch one report to get the verdicts.
Query parameters
pageintegerdefault1Page number, starting at 1.pageSizeintegerdefault20Between 1 and 100.
Request examples
curl "https://api.subsidia.protypa.fr/v1/verify?page=1&pageSize=20" -H "Authorization: Bearer $SUBSIDIA_API_KEY"Responses
A page of report summaries.
{ "reports": [ { "id": "cm2k9x3a40001abcd1234efgh", "title": "Synthese bail commercial", "counts": { "claims": 2, "verified": 1, "unsupported": 0, "contradicted": 1, "unverifiable": 0 }, "coverage": { "mode": "provided", "chunks": 1, "sources": 1, "collectionIds": [], "truncated": false }, "proofId": "vr_cm2k9x3a40001abcd1234efgh", "payloadHash": "9f2c41d0a7...", "createdAt": "2026-10-09T08:14:03.221Z" } ], "total": 1, "page": 1, "pageSize": 20}Errors
- 401Missing or invalid key.
- 403
scope_deniedThe key does not carry theproofscope.
GET/v1/verify/usage
Controls used this calendar month (UTC).
proofThe number of reports created by the workspace since the first of the month, UTC. This is what an integrator will see on the client statement.
Request examples
curl https://api.subsidia.protypa.fr/v1/verify/usage -H "Authorization: Bearer $SUBSIDIA_API_KEY"Responses
The month and its control count.
{ "month": "2026-10", "controls": 128 }Errors
- 403
scope_deniedThe key does not carry theproofscope.
GET/v1/verify/:id
Get one report with its human reviews.
proofReturns the stored report (text, verdicts, counts, coverage, proof id) and the human reviews recorded against it, oldest first. Reviews are the fifth mention of the AI register: who read the report, and what they decided.
Path parameters
idstringrequiredThe reportidreturned byPOST /v1/verify.
Request examples
curl https://api.subsidia.protypa.fr/v1/verify/$REPORT_ID -H "Authorization: Bearer $SUBSIDIA_API_KEY"Responses
The report and its reviews. A review has decision (accepted, modified or rejected), reviewerEmail, note and createdAt.
{ "report": { "id": "cm2k9x3a40001abcd1234efgh", "title": "Synthese bail commercial", "counts": { "claims": 2, "verified": 1, "unsupported": 0, "contradicted": 1, "unverifiable": 0 }, "proofId": "vr_cm2k9x3a40001abcd1234efgh" }, "reviews": [] }GET/v1/verify/:id/certificate
A report's certificate, and its server-side verification.
proofReturns the signed certificate and the result of checking it: the Ed25519 signature, and the link to the previous entry of the chain. For a check that does not rely on this server at all, see "Check a certificate" below.
Path parameters
idstringrequiredThe report id (not the proof id).
Request examples
curl https://api.subsidia.protypa.fr/v1/verify/$REPORT_ID/certificate -H "Authorization: Bearer $SUBSIDIA_API_KEY"Responses
valid is true only when both the signature and the chain link hold.
{ "certificate": { "id": "vr_cm2k9x3a40001abcd1234efgh", "chain_index": 41, "prev_hash": "5be1c07d33...", "payload": { "v": 1, "kind": "verification", "issuer": "subsidia-verifier", "subject": { "sha256": "c41f...", "chars": 71 }, "counts": { "claims": 2, "verified": 1, "unsupported": 0, "contradicted": 1, "unverifiable": 0 }, "coverage": { "mode": "provided", "chunks": 1, "sources": 1, "collectionIds": [], "truncated": false }, "claimsSha256": "2a77...", "evidence": [{ "sourceId": "Bail commercial.pdf", "chunkId": "qdrant-17", "sha256": "d90b..." }], "pool": { "passages": 1, "sha256": "8e13..." } }, "payload_hash": "9f2c41d0a7...", "signature": "base64...", "algorithm": "Ed25519", "hash": "sha256(canonical-json(payload))", "created_at": "2026-10-09T08:14:03.240Z" }, "verification": { "id": "vr_cm2k9x3a40001abcd1234efgh", "signature_valid": true, "chain_valid": true, "valid": true, "reason": null }, "certificate_check_url": "https://subsidia.protypa.fr/certificat"}GET/v1/verify/:id/export
The control dossier, as a zip.
proofThe file you hand to a client, a supervisor or an insurer. It contains rapport.md (the report in Markdown, written without any model), texte-controle.txt (the exact text that was checked), verdicts.json, certificat.json (when signed) and relectures.json (when human reviews exist). The export is written to the access journal.
Path parameters
idstringrequiredThe report id.
Request examples
curl https://api.subsidia.protypa.fr/v1/verify/$REPORT_ID/export \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" -o controle.zipResponses
application/zip, with Content-Disposition: attachment; filename="controle-<id>.zip".
What the certificate attests
The certificate commits, by SHA-256, to the fingerprint of the text, to the full list of verdicts (claimsSha256), to every passage a verdict cites (evidence[].sha256), to the counts and to the coverage. With provided passages it also commits to the whole set you sent (pool.sha256). Changing a verdict afterwards, or claiming a passage was missing, breaks the signature.
It holds fingerprints only: no text, no passage, no title. You can hand it to an auditor as it is, and the auditor learns nothing about the content until you also give them the dossier.
It does not attest that provided passages come from your client's documents, nor that they are complete. That is stated by coverage.mode: "provided" in the report and the certificate.
Check a certificate
Three ways, from easiest to most independent:
- In a browser, no account. Paste the certificate on the verification page (the
certificate_check_urlof the response). The browser recomputes the fingerprint and checks the signature; nothing is stored. - Over the API.
GET /v1/verify/:id/certificatereturns the certificate withverification, including the chain link. - Fully offline. An auditor needs the certificate and the public key from
GET /v1/gateway/public-key(no credentials required). The signature coverspayload_hash, the SHA-256 of the canonical JSON ofpayload; the code is in Gateway, verify offline. Reports and Gateway certificates share one chain, so the same code checks both.
Limits
| Limit | Value | Behaviour |
|---|---|---|
| Text analysed | 60,000 characters | The rest is ignored and coverage.truncated is true. |
| Text accepted | 240,000 characters | Above that, 413 text_too_large. |
| Claims per report | 150 | The rest is ignored and coverage.truncated is true. |
| Passages per request | 1,500 | 400 invalid_passages. |
| One passage | 20,000 characters (3,000,000 in total) | 400 invalid_passages. |
| Request body | 4 MB | 413. |
| Rate | 60 requests per minute per key on POST /v1/verify | 429 rate_limited. |
| Exhaustive Cortex pool | 1,500 passages | Larger scopes fall back to retrieved mode, stated in the report. |
Frequently asked
Does it catch a wrong statement that contains no number or name?
No. It checks values (numbers, amounts, dates, references, proper names). A sentence with none of these is unverifiable. The limit is by design: anything beyond it would require a model, and then the verdict would be something to believe rather than something to recompute.
Can I run it on my own RAG without sending Subsidia my documents?
You send the passages your retrieval returned, because the check needs them. They are used for the check and are not stored. The report keeps the text you checked, the verdicts and the evidence quotes; the certificate keeps fingerprints of the passages.
Is the result the same if I run it twice?
Yes, for the same text and the same passages. In retrieved mode the passages depend on a search, so the pool can change if the documents change; exhaustive and provided modes do not have this variance.
Where do I record that someone read the report?
In the app, at the bottom of the report. The review (accepted, modified, rejected, plus a note) is returned by GET /v1/verify/:id and counted in the AI register.