Skip to content

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.

VerdictRuleWhat to do with it
verifiedEvery 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.
unsupportedThe claim has anchors, and at least one is carried by no passage.Source it or remove it. Nothing in the passages says this.
contradictedA 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.
unverifiableThe 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 itWhat the report attests
providedYou 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.
exhaustiveYou send collectionIds and the collections hold at most 1,500 passages.Checked against the whole content of those collections, read from the documents themselves.
retrievedYou 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.

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

Gives 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

  • textstringrequired
    The text to check. Only the first 60,000 characters are analysed (the report then has coverage.truncated: true), and at most 150 claims. A text longer than 240,000 characters is refused with 413.
  • titlestring
    Up 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 if passages is 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.

Example response
{
"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

  • 400text_requiredtext is missing or blank.
  • 400invalid_passagespassages is empty, not an array, has more than 1,500 entries, an entry without source or content, a duplicate id, or exceeds the size limits.
  • 400ambiguous_scopeBoth passages and collectionIds were sent.
  • 401Missing, invalid, revoked or expired key.
  • 403scope_deniedThe key does not carry the proof scope.
  • 413text_too_largeThe text exceeds 240,000 characters.
  • 429rate_limitedMore than 60 requests per minute from this key, or the key's own requests-per-minute limit. Honour retry-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.

API keyproof

Returns summaries only (no text, no claims). Fetch one report to get the verdicts.

Query parameters

  • pageintegerdefault 1
    Page number, starting at 1.
  • pageSizeintegerdefault 20
    Between 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.

Example response
{
"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.
  • 403scope_deniedThe key does not carry the proof scope.

GET/v1/verify/usage

Controls used this calendar month (UTC).

API keyproof

The 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.

Example response
{ "month": "2026-10", "controls": 128 }

Errors

  • 403scope_deniedThe key does not carry the proof scope.

GET/v1/verify/:id

Get one report with its human reviews.

API keyproof

Returns 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

  • idstringrequired
    The report id returned by POST /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.

Example response
{ "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.

API keyproof

Returns 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

  • idstringrequired
    The 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.

Example response
{
"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.

API keyproof

The 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

  • idstringrequired
    The report id.

Request examples

curl https://api.subsidia.protypa.fr/v1/verify/$REPORT_ID/export \
-H "Authorization: Bearer $SUBSIDIA_API_KEY" -o controle.zip

Responses

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:

  1. In a browser, no account. Paste the certificate on the verification page (the certificate_check_url of the response). The browser recomputes the fingerprint and checks the signature; nothing is stored.
  2. Over the API. GET /v1/verify/:id/certificate returns the certificate with verification, including the chain link.
  3. Fully offline. An auditor needs the certificate and the public key from GET /v1/gateway/public-key (no credentials required). The signature covers payload_hash, the SHA-256 of the canonical JSON of payload; the code is in Gateway, verify offline. Reports and Gateway certificates share one chain, so the same code checks both.

Limits

LimitValueBehaviour
Text analysed60,000 charactersThe rest is ignored and coverage.truncated is true.
Text accepted240,000 charactersAbove that, 413 text_too_large.
Claims per report150The rest is ignored and coverage.truncated is true.
Passages per request1,500400 invalid_passages.
One passage20,000 characters (3,000,000 in total)400 invalid_passages.
Request body4 MB413.
Rate60 requests per minute per key on POST /v1/verify429 rate_limited.
Exhaustive Cortex pool1,500 passagesLarger 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.

Related