# Subsidia API: complete documentation > Subsidia API documentation. One base URL change: every AI call is masked, routed, metered and signed with a hash-chained proof certificate. OpenAI and Anthropic compatible Gateway, Cortex knowledge, Vérificateur, Registre, agents, webhooks. Base URL: https://api.subsidia.protypa.fr. Source: https://dev.subsidia.protypa.fr/docs. --- # Overview > What the Subsidia API is: a Gateway compatible with OpenAI and Anthropic, plus platform APIs for knowledge, agents and proof, all behind one API key. Subsidia is an AI engine for organisations that cannot let their data leave the building, accounting and legal firms first. The API gives your software the same engine the Subsidia app runs on, in two ways. - The **Gateway** speaks the OpenAI and Anthropic wire protocols. You change a base URL, and every call your existing client makes is masked, routed, metered and signed. - The **platform APIs** under `/v1` expose the engine's parts directly: the knowledge base (Cortex), agents, the Vérificateur, the Registre, webhooks. Both are authenticated by the same developer API key, and both aim at the same outcome: something you can show an auditor, not only something you have to believe. ## The mental model **The path of a Gateway call. The certificate is stored before the response completes, so the proof id returned with the answer can always be fetched.** Flow: Your application -> API key + scope check -> PII shield -> Routing (Synapse) -> Model, local or external -> Restore + sign -> Answer + proof id | Layer | What it is | Use it when | | --- | --- | --- | | **Gateway** (`/v1/chat/completions`, `/v1/messages`, `/v1/models`) | Drop-in OpenAI and Anthropic endpoints. The `model` field is an [address](https://dev.subsidia.protypa.fr/docs/model-addressing.md) that can reach your agents and your knowledge base. | You already have code, a framework or a tool (Claude Code, Cursor, n8n, LangChain) that talks to a model. | | **Knowledge** (`/v1/knowledge-sources`, `/v1/knowledge/*`) | Cortex: ingestion, hybrid retrieval, extracted facts, contradictions and health of your documents. | You want answers grounded in the client's own documents, or you want to build your own retrieval on top. | | **Agents and conversations** (`/v1/agents`, `/v1/tools`, `/v1/conversations`) | Persona agents with memory, documents and tools, one-to-one chat and multi-agent debates. | You want a persistent assistant rather than a stateless completion. | | **Proof** (`/v1/proofs`, `/v1/verify`, `/v1/registre`) | Signed, hash-chained certificates for every Gateway call, a deterministic claim checker, and the AI-usage register. | You must demonstrate what the AI read, said and was checked against. | | **Platform** (`/v1/webhooks`, `/v1/postes/:key/run`, Reflex, partner) | Events pushed to you, job-description agents, automations, and the integrator space. | You integrate Subsidia into a product or resell it to your own clients. | > **INFO: Capabilities are opt-in** > With no Subsidia prefix or flag in the `model` field, the Gateway is a faithful passthrough: masking, routing and a certificate, nothing else. Knowledge grounding, agents and local-only processing are things you ask for through the address, never things the Gateway imposes. ## Base URLs | Purpose | URL | | --- | --- | | API (Gateway and platform) | `https://api.subsidia.protypa.fr` | | OpenAI-style clients | `https://api.subsidia.protypa.fr/v1` (the SDK appends `/chat/completions`) | | Anthropic-style clients | `https://api.subsidia.protypa.fr` (the SDK appends `/v1/messages`) | | Create an API key | `https://subsidia.protypa.fr/console/gateway` | | These docs, for people and machines | `https://dev.subsidia.protypa.fr/docs`, plus `/llms.txt`. See [Use these docs with an AI agent](https://dev.subsidia.protypa.fr/docs/llm-ready.md). | > **INFO: On-premise installations** > A Subsidia desktop or Box installation serves the same routes on its own host. Replace the base URL with that machine's address; paths, headers and error shapes are the same. ## Conventions | Topic | Convention | | --- | --- | | Format | Requests and responses are JSON (`Content-Type: application/json`). The exceptions are file upload (`multipart/form-data`) and streaming (Server-Sent Events). | | Authentication | A developer key `sk_live_...`, as `Authorization: Bearer` or `x-api-key`. See [Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md). | | Workspace scoping | A key belongs to exactly one workspace and every read and write is scoped to it. There is no header that switches workspace for a key. Another workspace's object is indistinguishable from a missing one (404). | | Ids | Opaque strings. Gateway certificates are `pf_` followed by 32 hex characters. Treat ids as opaque and never parse them. | | Dates | ISO 8601 in UTC, for example `2026-10-09T08:30:00.000Z`. A key's monthly question ceiling resets on the first day of the calendar month, UTC. | | Errors | A non-2xx status with a JSON body. Native routes carry a human `error` message and, for anything you should branch on, a machine `code`. The OpenAI and Anthropic routes use each vendor's envelope. See [Errors](https://dev.subsidia.protypa.fr/docs/errors.md). | | Pagination | There is no global scheme. Routes that paginate say so: `GET /v1/verify` takes `page` and `pageSize` (default 20, maximum 100) and returns `total`; `GET /v1/ai/extracted-data` takes `limit` (default 100, maximum 500). Other lists return everything for the workspace. | | Unknown fields | The OpenAI and Anthropic routes accept and ignore unknown fields so SDK upgrades do not break. Do not rely on this for platform routes. | | Quotas | Customers buy **questions**. See [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md). | ## Where to go next - **[Quickstart](https://dev.subsidia.protypa.fr/docs/quickstart.md)**: From an empty account to a signed answer and a knowledge query. - **[Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md)**: OpenAI and Anthropic compatible endpoints, Claude Code, streaming. - **[Model addressing](https://dev.subsidia.protypa.fr/docs/model-addressing.md)**: Reach agents and Cortex through the model name: agent:slug, +cortex, +sensitive. - **[Proof certificates](https://dev.subsidia.protypa.fr/docs/proofs.md)**: What a certificate commits to and how to verify it offline. - **[Synapse](https://dev.subsidia.protypa.fr/docs/synapse.md)**: Routing, PII shielding and metering behind every call. - **[Knowledge (Cortex)](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md)**: Upload documents, query them, inspect facts and contradictions. - **[Agents](https://dev.subsidia.protypa.fr/docs/agents.md)**: Persona agents with memory, documents and tools. - **[Le Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md)**: Give every claim of a text a verdict against your pieces. Zero model calls. - **[Le Registre](https://dev.subsidia.protypa.fr/docs/registre.md)**: The AI usage register, signed, exportable for an audit. - **[Webhooks](https://dev.subsidia.protypa.fr/docs/webhooks.md)**: Be told when an ingestion ends or a contradiction appears. - **[Reflex](https://dev.subsidia.protypa.fr/docs/reflex.md)**: Automations that run on their own, with approval before anything goes out. - **[Integrators](https://dev.subsidia.protypa.fr/docs/integrators.md)**: Resell Subsidia: client workspaces, keys with ceilings, monthly statements. - **[Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md)**: Key formats, scopes, read-only keys, ceilings and rotation. - **[Errors](https://dev.subsidia.protypa.fr/docs/errors.md)**: Every status and code, and what to do about it. - **[SDKs](https://dev.subsidia.protypa.fr/docs/sdk.md)**: The TypeScript SDK, and the official OpenAI and Anthropic SDKs. - **[Docs for AI agents](https://dev.subsidia.protypa.fr/docs/llm-ready.md)**: llms.txt, Markdown pages and a system prompt to paste into your agent. ## Frequently asked **Do I need to rewrite my application?** No. If it uses an OpenAI or Anthropic SDK, set the base URL and the key. Everything beyond that (agents, grounding, proof retrieval) is added by choice. **Where does my data go?** To the provider that serves the call, after masking when that provider is external. With `pulse-sensitive` or the `+sensitive` flag the call stays on a local model and nothing leaves the machine. Each certificate records the provider that answered. **Why do some identifiers still say pulse?** The product was renamed Subsidia, but wire-level identifiers such as `pulse-auto`, the `x-pulse-proof` header and the `X-PulseLabs-*` webhook headers are kept so that existing integrations keep working. --- # Quickstart > Create a key, make your first attested Gateway call, upload a document, query it, and read the proof certificate. Four short steps take you from nothing to a signed answer grounded in your own document. Every example works as written once `SUBSIDIA_API_KEY` is set. You need a Subsidia account with an active plan or trial; the questions you ask here count against it (see [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md)). ## 1. Create an API key Open the Gateway page of the Subsidia console at `https://subsidia.protypa.fr/console/gateway` and create a key. For this guide give it the `engine` scope (Gateway and proofs) and the `knowledge` scope (documents and queries). The full key, starting with `sk_live_`, is shown **once**: copy it immediately. A key that carries any of these capability scopes can reach only the route families it names, which is what you want for anything that leaves your laptop. Scopes, ceilings and read-only keys are covered in [Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md). _bash_ ```bash export SUBSIDIA_API_KEY="sk_live_..." ``` _PowerShell_ ```powershell $env:SUBSIDIA_API_KEY = "sk_live_..." ``` > **WARNING: Keep the key out of source control** > Subsidia stores only a hash of the key, so a lost key cannot be recovered, only replaced. Load it from an environment variable or a secret manager, never from a committed file. ## 2. Make your first Gateway call The Gateway is OpenAI compatible, so the simplest call is a chat completion. `pulse-auto` lets Synapse choose the model for the task. The response carries an `x-pulse-proof` header: the id of the signed certificate for this exact exchange. Keep it. _curl_ ```bash curl -i https://api.subsidia.protypa.fr/v1/chat/completions \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "pulse-auto", "messages": [ {"role": "user", "content": "In one sentence, what is a hash chain?"} ] }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/chat/completions', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'pulse-auto', messages: [{ role: 'user', content: 'In one sentence, what is a hash chain?' }], }), }) const proofId = res.headers.get('x-pulse-proof') const completion = await res.json() console.log(completion.choices[0].message.content) console.log('proof:', proofId) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/chat/completions", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "model": "pulse-auto", "messages": [{"role": "user", "content": "In one sentence, what is a hash chain?"}], }, ) proof_id = res.headers["x-pulse-proof"] print(res.json()["choices"][0]["message"]["content"]) print("proof:", proof_id) ``` > **TIP: Already using an SDK?** > You do not need `fetch`. Point the official OpenAI or Anthropic SDK at the Gateway and keep the rest of your code: see [SDKs](https://dev.subsidia.protypa.fr/docs/sdk.md) and [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md). ## 3. Upload a document and query it Cortex is the knowledge side of the engine. Upload a file (PDF, DOCX, XLSX, PPTX, CSV, EML, or an image for OCR, up to 25 MB) as `multipart/form-data`. Ingestion is asynchronous: the upload returns a source `id` and a `status`, and the document is searchable once the status is `ready`. Uploading the same content twice returns `duplicate` instead of ingesting it again. Details in [Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md). **Upload** _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/knowledge-sources/upload \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -F "file=@./bail-commercial.pdf" \ -F "name=Bail commercial 2024" ``` _TypeScript_ ```typescript import { readFile } from 'node:fs/promises' const form = new FormData() form.append('file', new Blob([await readFile('./bail-commercial.pdf')]), 'bail-commercial.pdf') form.append('name', 'Bail commercial 2024') const res = await fetch('https://api.subsidia.protypa.fr/v1/knowledge-sources/upload', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, body: form, // do not set Content-Type yourself: fetch adds the multipart boundary }) const source = await res.json() // { id, status } console.log(source) ``` _Python_ ```python import os, requests with open("bail-commercial.pdf", "rb") as f: res = requests.post( "https://api.subsidia.protypa.fr/v1/knowledge-sources/upload", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, files={"file": ("bail-commercial.pdf", f)}, data={"name": "Bail commercial 2024"}, ) source = res.json() # {"id": "...", "status": "..."} print(source) ``` **Wait until the source is ready** _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/knowledge-sources/$SOURCE_ID \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" # repeat every few seconds until "status" is "ready" ("failed" means ingestion did not complete) ``` _TypeScript_ ```typescript async function waitReady(id: string) { for (let i = 0; i < 120; i++) { const res = await fetch('https://api.subsidia.protypa.fr/v1/knowledge-sources/' + id, { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const body = await res.json() const status = body.status ?? body.source?.status if (status === 'ready') return if (status === 'failed') throw new Error('ingestion failed') await new Promise((r) => setTimeout(r, 3000)) } throw new Error('still ingesting') } ``` _Python_ ```python import os, time, requests def wait_ready(source_id): for _ in range(120): body = requests.get( "https://api.subsidia.protypa.fr/v1/knowledge-sources/" + source_id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json() status = body.get("status") or body.get("source", {}).get("status") if status == "ready": return if status == "failed": raise RuntimeError("ingestion failed") time.sleep(3) raise RuntimeError("still ingesting") ``` Now ask a question. `POST /v1/knowledge/query` runs the full retrieval pipeline and returns `matches` (the passages) and a `trace` that explains why each one matched. It does not generate prose: it is the retrieval half, for you to feed into your own prompt or to display as citations. See [Knowledge query](https://dev.subsidia.protypa.fr/docs/knowledge-query.md). **Query** _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/knowledge/query \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"query": "What is the notice period to terminate the lease?", "topK": 5}' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/knowledge/query', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ query: 'What is the notice period to terminate the lease?', topK: 5 }), }) const { matches, trace } = await res.json() console.log(matches.length, 'passages') ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/knowledge/query", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={"query": "What is the notice period to terminate the lease?", "topK": 5}, ) data = res.json() print(len(data["matches"]), "passages") ``` To get a **generated, grounded answer with a certificate** instead of raw passages, add `+cortex` to the model address. The Gateway retrieves the passages, writes the answer, and hashes the exact passages used into the certificate. **A grounded, attested answer** _curl_ ```bash curl -i https://api.subsidia.protypa.fr/v1/chat/completions \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "pulse-auto+cortex", "messages": [{"role": "user", "content": "What is the notice period to terminate the lease?"}] }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/chat/completions', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'pulse-auto+cortex', messages: [{ role: 'user', content: 'What is the notice period to terminate the lease?' }], }), }) const proofId = res.headers.get('x-pulse-proof') const completion = await res.json() console.log(completion.choices[0].message.content, proofId) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/chat/completions", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "model": "pulse-auto+cortex", "messages": [{"role": "user", "content": "What is the notice period to terminate the lease?"}], }, ) proof_id = res.headers["x-pulse-proof"] print(res.json()["choices"][0]["message"]["content"], proof_id) ``` ## 4. Read and verify the proof The certificate commits, by SHA-256, to your request, to what actually left toward the model provider after masking, and to the response. For a `+cortex` call it also lists the passages the answer drew on, in a `grounding` array. It never contains the content itself. Fetch it with the id from `x-pulse-proof`, then ask the server to verify the signature and the chain link. _curl_ ```bash # the certificate curl https://api.subsidia.protypa.fr/v1/proofs/$PROOF_ID \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" # server-side verification: signature_valid, chain_valid, valid curl https://api.subsidia.protypa.fr/v1/proofs/$PROOF_ID/verify \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const base = 'https://api.subsidia.protypa.fr/v1/proofs/' + proofId const headers = { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY } const certificate = await fetch(base, { headers }).then((r) => r.json()) const check = await fetch(base + '/verify', { headers }).then((r) => r.json()) console.log(check.valid, certificate.payload.grounding) ``` _Python_ ```python import os, requests base = "https://api.subsidia.protypa.fr/v1/proofs/" + proof_id headers = {"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]} certificate = requests.get(base, headers=headers).json() check = requests.get(base + "/verify", headers=headers).json() print(check["valid"], certificate["payload"].get("grounding")) ``` > **INFO: Verify without trusting Subsidia** > `GET /v1/gateway/public-key` needs no credentials. Hand an auditor the certificate and that key, and they can check the Ed25519 signature offline. The code is in [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md#verify-offline) and [Proof certificates](https://dev.subsidia.protypa.fr/docs/proofs.md). ## What next - **[Use Claude Code or Cursor through Subsidia](https://dev.subsidia.protypa.fr/docs/gateway.md)**: Three environment variables put every turn of your coding agent behind the Gateway. - **[Reach your own agents by model name](https://dev.subsidia.protypa.fr/docs/model-addressing.md)**: agent:slug, +cortex and +sensitive turn the model field into a capability address. - **[Check a text against your pieces](https://dev.subsidia.protypa.fr/docs/verify.md)**: POST /v1/verify gives every claim a verdict, with no model call. - **[Lock your key down](https://dev.subsidia.protypa.fr/docs/authentication.md)**: Scopes, monthly ceilings, per-minute limits, expiry and read-only keys. - **[Know what can go wrong](https://dev.subsidia.protypa.fr/docs/errors.md)**: Every status code and what to do about it. --- # Authentication > Developer API keys: formats, the Bearer and x-api-key headers, scopes per route family, read-only keys, ceilings, rotation and hygiene. Every API call is authenticated by a **developer API key**. A key belongs to one workspace, acts as the user who created it, and can be narrowed to the route families it needs, capped in questions per month and in requests per minute, made read-only, and given an expiry date. Narrowing is the point: the key you give to a client's application should be able to call the engine and nothing else. ## Key format A key looks like `sk_live_` followed by 48 hexadecimal characters. Subsidia stores only a SHA-256 hash of it and a 16-character prefix (`sk_live_xxxxxxxx`) that the console shows to help you recognise it. The full key is returned **once**, at creation. There is no way to display it again; if it is lost, create a new key and revoke the old one. ## Sending the key | Header | Used by | Notes | | --- | --- | --- | | `Authorization: Bearer sk_live_...` | OpenAI SDKs, curl, most HTTP clients | The value must start with `sk_live_` or `sk_test_`. Any other Bearer value is not treated as an API key. | | `x-api-key: sk_live_...` | Anthropic SDKs, the Subsidia SDK | Takes precedence over `Authorization` when both are present. | > **INFO: Precedence, precisely** > If `x-api-key` is present it is the key, and an invalid value is a 401: the server does not fall back to the `Authorization` header. Without it, the `Authorization` header is used only if it starts with `Bearer sk_live_` or `Bearer sk_test_`. On the few routes that also accept an interactive session (Reflex and Light), any other Bearer value is read as a session token instead. **Same key, both styles** _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/models -H "Authorization: Bearer $SUBSIDIA_API_KEY" curl https://api.subsidia.protypa.fr/v1/models -H "x-api-key: $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/models', { headers: { 'x-api-key': process.env.SUBSIDIA_API_KEY! }, }) if (res.status === 401) throw new Error('Key missing, revoked or expired') ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr/v1/models", headers={"x-api-key": os.environ["SUBSIDIA_API_KEY"]}, ) res.raise_for_status() ``` ## Creating and managing keys The simplest way is the Gateway page of the console (`https://subsidia.protypa.fr/console/gateway`). Keys are also managed through `/developer/api-keys`, which is authenticated by a **signed-in user session** and not by an API key: a key can never create, list, change or revoke keys, including itself. | Action | Request | Notes | |---|---|---| | Create | `POST /developer/api-keys` | Body: `name`, `scopes`, `monthlyQuestionLimit`, `ratePerMinute`, `expiresAt` (ISO date). Returns `201` with the full `key` once. | | List | `GET /developer/api-keys` | Your active keys: `id`, `name`, `prefix`, `scopes`, ceilings, `usageCount`, `lastUsedAt`, `createdAt`, `expiresAt`. Never the secret. | | Change | `PATCH /developer/api-keys/:id` | Any of `name`, `scopes`, `monthlyQuestionLimit`, `ratePerMinute`. `null` removes a limit. The secret and the expiry cannot be changed. | | Revoke | `DELETE /developer/api-keys/:id` | `204`. The key stops working immediately and cannot be reactivated. | | Usage | `GET /developer/usage` | Request count and last use per key. | Limits must be positive whole numbers up to 10 000 000. A bad `scopes` list is a `400` that names the accepted values. > **DANGER: Always pass scopes when you create a key** > If `scopes` is omitted, the key receives the six legacy module scopes and behaves as an **unrestricted legacy key**: it can reach every route family. A key is only restricted once it holds at least one capability scope from the table below. Never give an unrestricted key to a third party. **Create a restricted key** _curl_ ```bash # $SESSION_TOKEN is the signed-in user's session token, not an API key curl https://api.subsidia.protypa.fr/developer/api-keys \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H "x-workspace-id: $WORKSPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "name": "Client app - production", "scopes": ["engine", "knowledge"], "monthlyQuestionLimit": 2000, "ratePerMinute": 60, "expiresAt": "2027-06-30T00:00:00Z" }' ``` _Response_ ```json { "key": "sk_live_3f9c0b7e5a1d4c28b6e0f1a29d7c4e5830ab12cd45ef6789", "prefix": "sk_live_3f9c0b7e", "name": "Client app - production", "scopes": ["engine", "knowledge"], "monthlyQuestionLimit": 2000, "ratePerMinute": 60, "note": "Store this key now — it will not be shown again." } ``` ## Scopes A scope grants a family of routes. Combine as many as the key needs. A restricted key that calls a route outside its families gets `403 scope_denied`; a route that maps to no scope at all is refused for restricted keys too. | Scope | Route families | Typical holder | | --- | --- | --- | | `engine` | `/v1/chat/completions`, `/v1/messages`, `/v1/messages/count_tokens`, `/v1/models`, `/v1/ai/*`, `/v1/proofs/*`, `/v1/gateway/*` | Any application that calls a model. See [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md), [Completions](https://dev.subsidia.protypa.fr/docs/completions.md), [Proof certificates](https://dev.subsidia.protypa.fr/docs/proofs.md). | | `knowledge` | `/v1/knowledge-sources*`, `/v1/knowledge/*` | Anything that uploads or queries documents. See [Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md). | | `proof` | `/v1/verify*`, `/v1/registre*` | A checker or an audit tool. See [Le Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md) and [Le Registre](https://dev.subsidia.protypa.fr/docs/registre.md). Exempt from the monthly question ceiling. | | `agents` | `/v1/agents*`, `/v1/tools*` | Applications that manage or chat with agents. See [Agents](https://dev.subsidia.protypa.fr/docs/agents.md). | | `conversations` | `/v1/conversations*` | Multi-agent debates. See [Conversations](https://dev.subsidia.protypa.fr/docs/conversations.md). | | `webhooks` | `/v1/webhooks*` | Event subscriptions. See [Webhooks](https://dev.subsidia.protypa.fr/docs/webhooks.md). | | `reflex` | `/engine/*` | Automations. See [Reflex](https://dev.subsidia.protypa.fr/docs/reflex.md). | | `light` | `/light/*` | The Light assistant. See [Light](https://dev.subsidia.protypa.fr/docs/light.md). | | `partner` | `/partner/*` | Integrators managing client workspaces. See [Integrators](https://dev.subsidia.protypa.fr/docs/integrators.md). | | `readonly` | not a route family | A restriction, described below. | > **WARNING: Routes with no scope** > Routes such as `POST /v1/postes/:key/run` are not attached to a capability scope. A restricted key cannot call them (`403 scope_denied`); use an unrestricted key for those until a scope is assigned. The `/devices/*` routes used by the desktop app are exempt from scope checks. ## Read-only keys Add `readonly` to the scopes and the key may only read. Any `POST`, `PUT`, `PATCH` or `DELETE` is refused with `403 read_only_key`, with one deliberate exception: a few `POST` routes only ask a question and change nothing, so they stay open. These are `/v1/chat/completions`, `/v1/messages`, `/v1/ai/complete`, `/v1/knowledge/query`, `/v1/verify`, `/light/ask` and `/light/web`. `readonly` is combined with capability scopes: `["engine", "readonly"]` is a key that can ask the Gateway but cannot create anything. Inside the app a read-only key acts with the `viewer` role; every other key acts as the workspace `owner`. ## Ceilings, limits and expiry | Setting | Effect | Failure | | --- | --- | --- | | `monthlyQuestionLimit` | Caps the questions this key may consume per calendar month (UTC). The count can lag a burst by a few seconds, so treat it as a guard rail against a runaway integration, not as a meter. An email alert is sent as the key approaches its ceiling. | `402 API_KEY_BUDGET_EXCEEDED` | | `ratePerMinute` | Caps requests per minute for this key, counted in the database so it holds across servers. Keys created without it have no per-key limit, but route-level limits still apply, see [Rate limits](https://dev.subsidia.protypa.fr/docs/rate-limits.md). | `429 rate_limited` with `retry-after` | | `expiresAt` | The key stops working at that instant. Set one for any key handed to a project with an end date. | `401 API key expired` | ## Rotating a key 1. **Create the replacement** Create a new key with the same scopes and limits. Both keys are valid at the same time, so nothing breaks. 2. **Deploy it** Roll the new value out to every place that uses the old one, through your secret manager. 3. **Check the old key is idle** In the console or with `GET /developer/usage`, watch the old key's `lastUsedAt` stop moving. 4. **Revoke the old key** Delete it. The next call made with it returns `401 Invalid or revoked API key`. ## Key hygiene - One key per application and per environment, named after both, so a leak points at its source. - Give each key the fewest scopes that work, a monthly ceiling, and an expiry when the project has an end. - Keep keys in server-side code only. A key shipped to a browser or a mobile app is public; put your own backend in front of the Gateway instead. - Never log the `Authorization` or `x-api-key` header. Log the key prefix if you need to tell keys apart. - Rotate on staff changes and after any suspected exposure; revoke first, investigate after. - The proof certificates and the usage journal record which workspace acted, so a revoked key leaves a clear trail. ## Authentication errors | Status | Code or message | Meaning | What to do | | --- | --- | --- | --- | | 401 | `API key required. Pass x-api-key header or Authorization: Bearer sk_live_xxx` | No usable key was sent. | Check the header name and that the value starts with `sk_live_`. | | 401 | `Invalid or revoked API key` | The key is unknown or was revoked. | Create a new key. A typo and a revoked key look the same on purpose. | | 401 | `API key expired` | `expiresAt` has passed. | Create a replacement key. | | 403 | `scope_denied` | The key lacks the scope for this route, or the route has no scope. | Add the scope named in the message, or use another key. | | 403 | `read_only_key` | A read-only key tried to change something. | Use a key without `readonly` for writes. | | 403 | `client_suspended` | The workspace was suspended by its integrator. | Contact your provider. Keys are kept and work again when access is restored. | | 403 | `provider_unpaid` | The integrator that hosts this client workspace has an overdue invoice. | Contact your provider. Nothing is deleted. | | 402 | `API_KEY_BUDGET_EXCEEDED` | The key reached its monthly question ceiling. | Raise the ceiling with `PATCH /developer/api-keys/:id`, or wait for the next month. | | 429 | `rate_limited` | More requests per minute than the key allows. | Wait for `retry-after` seconds. | The full catalogue, including the OpenAI and Anthropic error envelopes, is in [Errors](https://dev.subsidia.protypa.fr/docs/errors.md). --- # Errors > The error body shapes of the native, OpenAI and Anthropic routes, every status and code, and what to do about each one. Errors are ordinary HTTP statuses with a JSON body. Branch on the **status**, then on the **`code`** when there is one, and show the human `message` to people but never parse it: messages are written for readers and may be reworded. Which body shape you get depends on the route family, so this page starts there. ## Error body shapes | Routes | Shape | Example | | --- | --- | --- | | Native `/v1/*`, `/developer/*`, `/light/*`, `/engine/*` | `{ "error": string, "code"?: string, ...details }` | `{ "error": "This API key does not have the \"knowledge\" scope.", "code": "scope_denied" }` | | OpenAI dialect: `/v1/chat/completions`, `/v1/proofs/*` | `{ "error": { "message", "type", "code", "param" } }` | `{ "error": { "message": "Insufficient balance", "type": "insufficient_quota", "code": "insufficient_quota", "param": null } }` | | Anthropic dialect: `/v1/messages` | `{ "type": "error", "error": { "type", "message" } }` | `{ "type": "error", "error": { "type": "rate_limit_error", "message": "..." } }` | > **INFO: Authentication errors are always native** > A failed key check happens before any route runs, so a 401, a `scope_denied` 403, a key-level 429 or an `API_KEY_BUDGET_EXCEEDED` 402 uses the native shape `{ "error": "...", "code": "..." }` even on `/v1/chat/completions` and `/v1/messages`. Handle both shapes in a shared error parser: read `body.error` as a string, or `body.error.message` as an object. **A parser that handles all three shapes** _TypeScript_ ```typescript type ApiError = { status: number; code?: string; message: string; retryAfter?: number } async function parseError(res: Response): Promise { const body: any = await res.json().catch(() => ({})) const err = body?.error const message = typeof err === 'string' ? err : err?.message ?? res.statusText const code = body?.code ?? (typeof err === 'object' ? err?.code ?? err?.type : undefined) const retry = Number(res.headers.get('retry-after')) return { status: res.status, code, message, retryAfter: retry > 0 ? retry : undefined } } ``` _Python_ ```python def parse_error(res): try: body = res.json() except ValueError: body = {} err = body.get("error") if isinstance(err, dict): message = err.get("message", res.reason) code = body.get("code") or err.get("code") or err.get("type") else: message = err or res.reason code = body.get("code") retry = res.headers.get("retry-after") return {"status": res.status_code, "code": code, "message": message, "retry_after": int(retry) if retry and retry.isdigit() else None} ``` ## Catalogue ### 400 and 413: the request is wrong | Status | Code | Meaning | What to do | | --- | --- | --- | --- | | 400 | `invalid_request_error` (OpenAI and Anthropic) | `messages` missing or empty, or no `user` message. | Fix the payload. Retrying unchanged will fail again. | | 400 | none | A native route rejected the body: a required field is missing, a value has the wrong type, `scopes` is not a list of known scopes, a limit is not a positive whole number, `Collection not found`, `file is required (multipart/form-data)`. | Read `error`; it names the field. | | 400 | `text_required` | `POST /v1/verify` was sent an empty `text`. | Send the text to check. | | 400 | `invalid_passages` | `passages` on `/v1/verify` is malformed (each needs `source` and `content`). | See [Le Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md). | | 400 | `ambiguous_scope` | `/v1/verify` received both `passages` and `collectionIds`. | Send one or the other. | | 413 | `text_too_large` | The text sent to `/v1/verify` exceeds the maximum. | Split the text and verify the parts. | | 413 | none | An uploaded file is over 25 MB. | Split or compress the file. | ### 401 and 403: who you are, what you may do | Status | Code | Meaning | What to do | | --- | --- | --- | --- | | 401 | none | `API key required...`, `Invalid or revoked API key`, `API key expired`, or `User not found`. | Do not retry. Check the key, create a new one if needed. See [Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md). | | 403 | `scope_denied` | The key does not carry the scope the route needs, or the route has no scope. | Add the scope. The message names it. | | 403 | `read_only_key` | A read-only key tried to change something. | Use a key without `readonly` for writes. | | 403 | `client_suspended` | The workspace is suspended by the integrator that manages it. | Contact that integrator. Keys keep working once access is restored. | | 403 | `provider_unpaid` | The integrator behind this client workspace has an overdue invoice. | Contact your provider. | ### 402: a limit on what you have bought A 402 never means "retry". It means a quota, a plan or a ceiling must change first. Nothing is consumed when a call is refused with a 402. See [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md) for what each limit measures. | Status | Code | Extra fields | Meaning | What to do | | --- | --- | --- | --- | --- | | 402 | `insufficient_quota` | none | OpenAI dialect only: the workspace has no questions left for this call. | Add a question pack or move to a larger plan. | | 402 | `QUESTION_LIMIT` | `required`, `available` | Native routes: fewer questions left than the request needs. `required` and `available` are counted in questions. | Add a pack or upgrade. | | 402 | `SUBSCRIPTION_INACTIVE` | none | The trial is over or the subscription is no longer active. | Choose a plan in the console. | | 402 | `PLAN_UPGRADE_REQUIRED` | `feature`, `requiredPlan` | The route belongs to a plan feature your plan does not include (for example the Registre needs the proof journal). | Upgrade to `requiredPlan`. | | 402 | `DOCUMENT_LIMIT` | `limit`, `used` | The workspace reached its document volume. | Delete documents, add users, or upgrade. | | 402 | `SEAT_LIMIT` | `seats` | The subscription covers fewer users than you tried to add. | Add a seat to the subscription. | | 402 | `API_KEY_BUDGET_EXCEEDED` | none | This key reached its own monthly question ceiling, set below the workspace allowance. | Raise the key ceiling with `PATCH /developer/api-keys/:id`, or wait for the next month. | ### 404, 429, 5xx | Status | Code | Meaning | What to do | | --- | --- | --- | --- | | 404 | none | The object does not exist in this workspace. Another workspace's object looks identical. | Check the id and the key's workspace. | | 404 | `model_not_found` (OpenAI), `not_found_error` (Anthropic) | The `agent:` address matches no agent. | List addresses with `GET /v1/models`. | | 404 | `conversation_not_found` | `x-pulse-conversation` names an unknown conversation. | Omit the header to start a new one. | | 404 | `proof_not_found` | Unknown certificate id, or one from another workspace. | Use the id exactly as returned in `x-pulse-proof`. | | 429 | `rate_limited` | The key exceeded its requests per minute. Carries `retry-after`. | Wait that many seconds, then retry. | | 429 | `rate_limit_error` (Anthropic) | On `/v1/messages` a spent question allowance is a 429, not a 402: the Anthropic dialect has no payment status. | Treat as a quota error, not a transient one: retrying will not help until questions are added. | | 429 | none | A route-level limit (for example 60 per minute on the Gateway and Cortex query routes, 30 on file upload). Produced by the rate limiter, so its body is not one of the shapes above. | Branch on the status and `retry-after`, not on the body. | | 500 | `server_error` (OpenAI), `api_error` (Anthropic) | The call failed inside Subsidia or at the model provider. | Retry with backoff, at most three times. Persisting? Report the `x-pulse-proof` id if one was returned. | | 501 | `address_not_supported` (OpenAI), `invalid_request_error` (Anthropic) | A reserved `debate:` address was used on the Gateway. | Use the conversations API for debates. | ## Retrying safely | Status | Retry? | How | | --- | --- | --- | | 400, 401, 403, 404, 413, 501 | No | The request or the key must change first. | | 402 | No | Not until a quota, plan or ceiling changes. Surface it to a human. | | 429 with `retry-after` | Yes | Sleep for `retry-after` seconds (at most 60: the window is one minute), then resend. | | 429 on `/v1/messages`, no `retry-after` | No | Probably a spent allowance. Check [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md). | | 500, network error | Yes | Exponential backoff with jitter (1 s, 2 s, 4 s), three attempts, then give up. | **Retry loop** _TypeScript_ ```typescript async function call(url: string, init: RequestInit, attempts = 3): Promise { for (let i = 0; i < attempts; i++) { const res = await fetch(url, init) if (res.ok) return res if (res.status === 429 && res.headers.get('retry-after')) { await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after')) * 1000)) continue } if (res.status >= 500 && i < attempts - 1) { await new Promise((r) => setTimeout(r, 2 ** i * 1000 + Math.random() * 250)) continue } return res // 4xx: the caller decides } throw new Error('unreachable') } ``` _Python_ ```python import random, time, requests def call(method, url, attempts=3, **kwargs): for i in range(attempts): res = requests.request(method, url, **kwargs) if res.ok: return res retry = res.headers.get("retry-after") if res.status_code == 429 and retry: time.sleep(int(retry)) continue if res.status_code >= 500 and i < attempts - 1: time.sleep(2 ** i + random.random() * 0.25) continue return res # 4xx: the caller decides return res ``` > **DANGER: Streaming errors arrive in-band** > Once a Server-Sent Events stream has started, the HTTP status is already 200. A failure afterwards is sent as an event: `data: { "error": { ... } }` followed by `[DONE]` on the OpenAI dialect, and an `event: error` with `{ "type": "error", "error": { ... } }` on the Anthropic dialect. Always look for an `error` object in the events, not only at the status code. A quota error mid-stream on `/v1/messages` has type `rate_limit_error`. ## OpenAI and Anthropic dialects side by side | Situation | `/v1/chat/completions` | `/v1/messages` | | --- | --- | --- | | Empty or missing `messages` | 400 `invalid_request_error` | 400 `invalid_request_error` | | Question allowance spent | 402 `insufficient_quota` | 429 `rate_limit_error` | | Unknown `agent:` address | 404 `model_not_found` | 404 `not_found_error` | | Reserved `debate:` address | 501 `address_not_supported` | 501 `invalid_request_error` | | Internal failure | 500 `server_error` | 500 `api_error` | | Bad or missing key | 401, native shape | 401, native shape | **Does a failed call cost me a question?** A refusal before the model runs (400, 401, 402, 403, 404) consumes nothing. For a 500, report it with the proof id if one came back. **How do I tell a transient 429 from a spent allowance?** A transient 429 carries a `retry-after` header and, from a key limit, `code: "rate_limited"`. A spent allowance on `/v1/messages` has `error.type: "rate_limit_error"` and no `retry-after`. --- # Rate limits and quotas > Request limits per key and per route, how questions are counted and which plans include how many, controls counted apart, and the 402 codes. Three different mechanisms can stop a call, and they fail with different statuses, so it helps to tell them apart: 1. **Request rate**: too many requests in a minute. A `429`. Transient: wait and retry. 2. **Questions**: the workspace allowance, or the key's own monthly ceiling, is spent. A `402` (a `429` on the Anthropic dialect). Not transient: it needs a human decision. 3. **Plan features and volumes**: documents, seats, features your plan does not include. A `402` with a specific `code`. Customers buy **questions**. A question is the unit on every price page, every invoice and every limit message. ## Request rate Two layers apply. **Per key.** A key created with `ratePerMinute` is limited to that many requests per calendar minute, counted in the database so the limit holds however many servers answer. Past it, the call is refused with `429` and `code: "rate_limited"`, and a `retry-after` header says how many seconds remain in the current minute (1 to 60). A key without `ratePerMinute` has no per-key limit. **Per route.** Independently of the key, the routes below carry a ceiling per minute. | Route | Limit per minute | | --- | --- | | `POST /v1/chat/completions`, `POST /v1/messages` | 60 | | `POST /v1/knowledge/query` | 60 | | `POST /v1/verify` | 60 | | `POST /v1/knowledge-sources/upload` | 30 | > **INFO: Treat these numbers as starting points** > The route ceilings are set from current traffic and are revised as real usage comes in; they are not a contract. Design for a `429` by honouring `retry-after` rather than by hard-coding a rate. If you need a higher ceiling for a production integration, ask for it. **Honour retry-after** _TypeScript_ ```typescript const res = await fetch(url, init) if (res.status === 429) { const wait = Number(res.headers.get('retry-after') ?? 1) await new Promise((r) => setTimeout(r, wait * 1000)) // then resend once } ``` _Python_ ```python res = requests.post(url, **kwargs) if res.status_code == 429: time.sleep(int(res.headers.get("retry-after", "1"))) # then resend once ``` ## Questions Every answer that calls a model consumes questions from the workspace allowance. **One question is one answer, up to 8 000 tokens of reading and writing combined.** An ordinary answer is exactly one question: measured on production usage, a typical document answer is about 1 500 tokens and a chat turn about 1 100. A very long exchange, such as a multi-round debate, counts as several. The response `usage` object reports tokens (`prompt_tokens`, `completion_tokens`, `total_tokens`, or `input_tokens` and `output_tokens` on the Anthropic dialect). That is the real consumption by the model, for your own cost tracking. It is not the number of questions charged: questions are derived from it, weighted by the model that answered. | Model class | Counts as | Examples | | --- | --- | --- | | Light | 1x | Local models, Mistral Small, GPT-4o mini, Llama, Qwen, Gemma, DeepSeek | | Standard | 3x | Claude Haiku, Mistral Medium, GPT-4.1 mini | | Advanced | 6x | Claude Sonnet, Mistral Large, GPT-4o, GPT-4.1 | | Premium | 12x | Claude Opus | | Frontier | 30x | The largest frontier models | The multiplier applies to the tokens before they are converted to questions, so a larger model uses the allowance faster. With `pulse-auto`, Synapse picks a model that fits the task, which keeps simple calls in the cheapest class. With `pulse-sensitive` or `+sensitive` the call runs on a local model (Light class). The class that served a call is the one that was billed, and the certificate records the provider. Background work, such as ingesting a document into Cortex or the night watch, consumes no questions. On a local installation (desktop or Box), nothing is metered per question at all. ## Plans Plans are per user, per month, excluding VAT; annual billing offers two months. Allowances are pooled across the workspace: three users on Pro share 1 200 questions per month. The monthly window is anchored on the subscription start day. | | Essentiel | Pro | Cabinet | | --- | --- | --- | --- | | Price per user per month | 9.99 EUR | 19 EUR | 29 EUR | | Questions per user per month | 150 | 400 | 1 000 | | Documents per user | 300 | 2 000 | 10 000 | | Light (answers from your documents) | Yes | Yes | Yes | | Chat (reasoning with memory) | No | Yes | Yes | | Connectors (Drive, mailbox, watched folders) | No | Yes | Yes | | Proof journal (the Registre, certificate export) | No | No | Yes | | Business modules | No | No | Yes | | Item | Detail | | --- | --- | | Trial | 14 days, no card, 30 questions, 50 documents, with the features of Pro. When it ends, calls fail with `402 SUBSCRIPTION_INACTIVE`. | | Question packs | One-time purchases on top of any paid plan: 100 questions for 5 EUR, 500 for 19 EUR, 2 000 for 59 EUR, excluding VAT. They do not expire while the subscription is active. The monthly allowance is consumed first, then packs. | | Integrators | Client workspaces are metered per client, with a wholesale grid and a monthly statement. See [Integrators](https://dev.subsidia.protypa.fr/docs/integrators.md) and [Partner API](https://dev.subsidia.protypa.fr/docs/partner.md). | ## A per-key ceiling A key can carry `monthlyQuestionLimit`, a ceiling lower than the workspace allowance, in questions per calendar month (UTC). It exists so that one runaway integration cannot spend the allowance of everyone else. Past it, every call that consumes questions fails with `402` and `code: "API_KEY_BUDGET_EXCEEDED"` until the next month or until the ceiling is raised. The usage behind the check is read from the usage ledger and cached for about 15 seconds, so a burst can overshoot by a few calls; use it as a guard rail, not as an exact meter. Set it when you create the key or later, see [Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md). ## Controls: counted apart A report from [Le Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md) calls **no model**, so counting it in questions, which are derived from model tokens, would price it as something it is not. It is counted in its own unit, a **control**: one control is one Vérificateur report, whether it came from the app or from `POST /v1/verify`. Consequences: - A control consumes no questions and is not stopped by a key's `monthlyQuestionLimit`. Keys that only hold the `proof` scope are never blocked by it. - `GET /v1/verify/usage` returns the controls used by your workspace this calendar month: `{ "month": "2026-10", "controls": 42 }`. - Controls appear on an integrator's monthly statement, per client. For now no volume is included and no price is charged beyond the subscription: they are counted and shown, not billed. - The Registre needs the proof journal feature (Cabinet plan). Without it, `/v1/registre` routes answer `402 PLAN_UPGRADE_REQUIRED`. **Controls used this month** _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/verify/usage \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const usage = await fetch('https://api.subsidia.protypa.fr/v1/verify/usage', { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }).then((r) => r.json()) console.log(usage.month, usage.controls) ``` _Python_ ```python import os, requests usage = requests.get( "https://api.subsidia.protypa.fr/v1/verify/usage", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json() print(usage["month"], usage["controls"]) ``` ## 402 codes | Code | Fields | Cause | Resolution | | --- | --- | --- | --- | | `insufficient_quota` | (OpenAI envelope) | No questions left, on `/v1/chat/completions`. | Add a pack or upgrade. | | `QUESTION_LIMIT` | `required`, `available` | Fewer questions left than the request needs, on native routes. | Add a pack or upgrade. | | `API_KEY_BUDGET_EXCEEDED` | none | The key's own monthly ceiling is reached. | Raise the ceiling or wait for the next month. | | `SUBSCRIPTION_INACTIVE` | none | Trial finished or subscription lapsed. | Choose a plan. | | `PLAN_UPGRADE_REQUIRED` | `feature`, `requiredPlan` | The feature is not part of the current plan. | Move to `requiredPlan`. | | `DOCUMENT_LIMIT` | `limit`, `used` | The document volume of the plan is reached. | Delete documents or upgrade. | | `SEAT_LIMIT` | `seats` | The subscription covers fewer users than requested. | Add a seat. | On `/v1/messages` a spent allowance is a `429` with `error.type: "rate_limit_error"` and no `retry-after`, because the Anthropic dialect has no payment status. Retrying does not help. See [Errors](https://dev.subsidia.protypa.fr/docs/errors.md). **Which headers tell me how much I have left?** Responses do not report the remaining allowance. A key-level 429 carries `retry-after`; do not build on any other rate-limit header. Read your remaining allowance in the console, and handle the first 402. **Does a refused call cost a question?** A call refused before the model runs (a 4xx) consumes nothing, including the 402 that tells you the allowance is spent. **Why did one call cost more than one question?** Either the exchange was long (more than 8 000 weighted tokens), or a larger model answered. With `pulse-auto` the cheapest adequate class is chosen; naming a specific large model, or sending `tools`, can move the call to a heavier class. --- # SDKs > The subsidia-sdk TypeScript client for the platform APIs, and how to use the official OpenAI and Anthropic SDKs, or plain fetch, for everything else. There is no single Subsidia client for everything, and that is deliberate. The part of the API that most developers use, the Gateway, is OpenAI and Anthropic compatible, so the **official OpenAI and Anthropic SDKs are the SDKs** for it, in every language they support. A small TypeScript client, `subsidia-sdk`, covers the platform APIs (knowledge, agents, conversations, completions, webhooks). Everything else is plain HTTP. | You want to | Use | Language | | --- | --- | --- | | Call a model, stream, use tools | The official **OpenAI** or **Anthropic** SDK pointed at the Gateway | Any language those SDKs support | | Upload documents, query Cortex, read facts and health | `subsidia-sdk`, or `fetch` / `requests` | TypeScript, or any HTTP client | | Manage agents, memory, conversations, webhooks | `subsidia-sdk`, or HTTP | TypeScript, or any HTTP client | | Fetch and verify proof certificates, run the Vérificateur, read the Registre | HTTP (not in `subsidia-sdk`) | Any | ## Gateway: the OpenAI and Anthropic SDKs Change the base URL and the key, keep everything else. To read the proof id, use the SDK's raw-response helper (`withResponse()` in TypeScript, `with_raw_response` in Python) and take the `x-pulse-proof` header. More on streaming, tools and Claude Code in [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md). _OpenAI (TypeScript)_ ```typescript import OpenAI from 'openai' const client = new OpenAI({ baseURL: 'https://api.subsidia.protypa.fr/v1', apiKey: process.env.SUBSIDIA_API_KEY, // sk_live_... }) const { data, response } = await client.chat.completions .create({ model: 'pulse-auto', messages: [{ role: 'user', content: 'Hello' }] }) .withResponse() console.log(data.choices[0].message.content, response.headers.get('x-pulse-proof')) ``` _OpenAI (Python)_ ```python import os from openai import OpenAI client = OpenAI( base_url="https://api.subsidia.protypa.fr/v1", api_key=os.environ["SUBSIDIA_API_KEY"], ) raw = client.chat.completions.with_raw_response.create( model="pulse-auto", messages=[{"role": "user", "content": "Hello"}], ) completion = raw.parse() print(completion.choices[0].message.content, raw.headers.get("x-pulse-proof")) ``` _Anthropic (TypeScript)_ ```typescript import Anthropic from '@anthropic-ai/sdk' const client = new Anthropic({ baseURL: 'https://api.subsidia.protypa.fr', // host only: the SDK adds /v1/messages apiKey: process.env.SUBSIDIA_API_KEY, }) const { data, response } = await client.messages .create({ model: 'pulse-auto', max_tokens: 1024, messages: [{ role: 'user', content: 'Hello' }] }) .withResponse() console.log(data.content, response.headers.get('x-pulse-proof')) ``` _Anthropic (Python)_ ```python import os from anthropic import Anthropic client = Anthropic( base_url="https://api.subsidia.protypa.fr", api_key=os.environ["SUBSIDIA_API_KEY"], ) raw = client.messages.with_raw_response.create( model="pulse-auto", max_tokens=1024, messages=[{"role": "user", "content": "Hello"}], ) message = raw.parse() print(message.content, raw.headers.get("x-pulse-proof")) ``` > **TIP: Frameworks follow the same rule** > LangChain, LlamaIndex, the Vercel AI SDK, n8n and similar tools all accept a base URL on their OpenAI or Anthropic provider. Set it to `https://api.subsidia.protypa.fr/v1` (OpenAI style) or `https://api.subsidia.protypa.fr` (Anthropic style) and use `pulse-auto`, `agent:` or any [address](https://dev.subsidia.protypa.fr/docs/model-addressing.md) as the model name. Your agents appear in model pickers through `GET /v1/models`. ## The TypeScript client: subsidia-sdk `subsidia-sdk` is a thin, zero-dependency TypeScript client built on the global `fetch` (Node 18 or later, Deno, Bun, modern browsers). It lives in the `sdk/` folder of the Subsidia repository, version `0.1.0`, and is installed with `npm install subsidia-sdk`. If your registry does not resolve it yet, build it from the `sdk/` folder (`npm install && npm run build`) and `npm link` it. It sends the key in the `x-api-key` header. > **WARNING: Coverage** > The client is hand-written and covers the namespaces below only. It does **not** wrap the Gateway, the proof routes, `/v1/verify`, the Registre, `/v1/tools` or Reflex; call those with the official SDKs or HTTP. Its `workspaceId` option sends an `x-workspace-id` header that API-key authentication ignores: a key already belongs to one workspace. **Configure and use** ```typescript import { SubsidiaSDK, SubsidiaSDKError } from 'subsidia-sdk' const subsidia = new SubsidiaSDK({ apiKey: process.env.SUBSIDIA_API_KEY!, // required // baseUrl: 'https://api.subsidia.protypa.fr', // default; override for an on-premise host }) // Ingest text, wait until it is ready, then query it const { id } = await subsidia.knowledge.ingest({ name: 'Lease summary', content: 'The tenant may terminate the lease with six months written notice.', }) let status = 'processing' while (status !== 'ready' && status !== 'failed') { await new Promise((r) => setTimeout(r, 3000)) status = (await subsidia.knowledge.getSource(id)).source.status } const { matches, trace } = await subsidia.knowledge.query({ query: 'Notice period to terminate?', topK: 5 }) try { await subsidia.agents.list() } catch (err) { if (err instanceof SubsidiaSDKError) console.error(err.status, err.body) else throw err } ``` | Namespace | Methods | Routes | | --- | --- | --- | | `knowledge` | `ingest`, `listSources`, `getSource`, `deleteSource`, `query`, `listFacts`, `listContradictions`, `resolveContradiction`, `health`, `replayHealth` | `/v1/knowledge-sources*`, `/v1/knowledge/*`. See [Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md), [Knowledge query](https://dev.subsidia.protypa.fr/docs/knowledge-query.md), [Knowledge insights](https://dev.subsidia.protypa.fr/docs/knowledge-insights.md). | | `agents`, `agents.memory` | `list`, `create`, `update`, `delete`; memory `list`, `create`, `delete` | `/v1/agents*`. See [Agents](https://dev.subsidia.protypa.fr/docs/agents.md) and [Agent memory](https://dev.subsidia.protypa.fr/docs/agent-memory.md). | | `conversations` | `list`, `create`, `get`, `sendMessage`, `delete` | `/v1/conversations*`. See [Conversations](https://dev.subsidia.protypa.fr/docs/conversations.md). | | `ai` | `complete`, `listExtractedData`, `getExtractedData`, `deleteExtractedData` | `/v1/ai/*`. See [Completions](https://dev.subsidia.protypa.fr/docs/completions.md). | | `webhooks` | `list`, `create`, `get`, `update`, `delete`, `deliveries`, `test` | `/v1/webhooks*`. See [Webhooks](https://dev.subsidia.protypa.fr/docs/webhooks.md). | **Errors.** Every non-2xx response throws `SubsidiaSDKError`, with `status` (the HTTP status) and `body` (the parsed JSON body, or `null`). The `body` keeps the `code` field, so you can branch on `err.body.code` exactly as described in [Errors](https://dev.subsidia.protypa.fr/docs/errors.md). The client does not retry; add a retry policy around 429 and 5xx yourself. ## Plain HTTP The API is ordinary JSON over HTTPS, so a few lines of `fetch` or `requests` are enough for any route, and for any language without an SDK. A minimal typed wrapper that works for every route, including the ones the SDK does not cover: _TypeScript_ ```typescript const BASE = 'https://api.subsidia.protypa.fr' export async function subsidia(method: string, path: string, body?: unknown): Promise<{ data: T; headers: Headers }> { const res = await fetch(BASE + path, { method, headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, ...(body ? { 'Content-Type': 'application/json' } : {}), }, body: body ? JSON.stringify(body) : undefined, }) if (!res.ok) throw Object.assign(new Error('Subsidia ' + res.status), { status: res.status, body: await res.json().catch(() => null) }) return { data: (await res.json()) as T, headers: res.headers } } // e.g. verify a certificate const { data } = await subsidia<{ valid: boolean }>('GET', '/v1/proofs/' + proofId + '/verify') ``` _Python_ ```python import os, requests BASE = "https://api.subsidia.protypa.fr" session = requests.Session() session.headers["Authorization"] = "Bearer " + os.environ["SUBSIDIA_API_KEY"] def subsidia(method, path, **kwargs): res = session.request(method, BASE + path, **kwargs) res.raise_for_status() return res # e.g. verify a certificate print(subsidia("GET", "/v1/proofs/" + proof_id + "/verify").json()["valid"]) ``` ## OpenAPI A machine-readable snapshot of the routes is served at `https://dev.subsidia.protypa.fr/openapi.json`. It is generated from the server and can be fed to a code generator for languages the SDK does not cover. Treat the written pages as the reference for behaviour and error handling, since a schema does not capture those. **Is there a Python or Go SDK?** Not from Subsidia. For the Gateway use the official OpenAI or Anthropic SDK in your language; for the platform APIs use `requests` or `net/http`, optionally with a client generated from the OpenAPI snapshot. **Will the OpenAI SDK break on the extra fields in responses?** No. Responses carry an additional `pulse` block next to the standard fields, and the SDKs ignore fields they do not know. The block holds the proof id, the provider and the number of values masked. --- # Changelog > Dated changes to the public API: new routes, scopes, error codes and behaviour, newest first. Only changes visible to an API client are listed: routes, scopes, headers, error codes, limits and billing behaviour. Internal fixes and the desktop app's own release notes are not. Entries are grouped by day, newest first. The API is not versioned by date or header; additive changes ship continuously, and anything that could break a client is marked **Breaking** or **Behaviour change**. ## 2026-10-09 **Added: the Vérificateur and the Registre over the API.** - `POST /v1/verify` gives every claim of a text a verdict (verified, unsupported, contradicted, not checkable) against passages you send, typically what your own retrieval returned, or against Cortex collections. No model is called. The report is signed in the same hash chain as Gateway certificates. With passages you supply, the certificate commits to the whole pool and states that it does not attest where the passages came from. - `GET /v1/verify` (paginated), `GET /v1/verify/:id`, `GET /v1/verify/:id/certificate`, `GET /v1/verify/:id/export` and `GET /v1/verify/usage`. - `GET /v1/registre`, `GET /v1/registre/summary` and `GET /v1/registre/export` (signed archive). Same plan requirement as in the app: without the proof journal feature they answer `402 PLAN_UPGRADE_REQUIRED`. - New key scope **`proof`** for these two families. **Billing: controls.** A Vérificateur report is counted as a *control*, a unit separate from questions. A control consumes no question and is not stopped by a key's `monthlyQuestionLimit`. Controls are shown on integrator statements; no price is charged yet. See [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md). Docs: [Le Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md), [Le Registre](https://dev.subsidia.protypa.fr/docs/registre.md). ## 2026-10-08 **Behaviour change: the documented surface is the infrastructure.** The developer portal, the SDK and the app now cover the engine only: Gateway, Cortex, agents, conversations, tools, webhooks, proof, Reflex, Light and the integrator space. The optional modules Life Copilot, SimEngine, Market Analysis and Risk Evaluator are no longer documented, and the SDK no longer has namespaces for them. Do not build new integrations on them. **Fixed: security findings of the 08/10 audit.** Several routes were tightened following the audit, in line with the changes of 4 October below. ## 2026-10-06 **Added: `403 provider_unpaid`.** For client workspaces managed by an integrator: when the integrator's own invoice is overdue past the grace period, the keys of its clients stop with this code. The integrator's own keys keep working so it can pay, and nothing is deleted. See [Integrators](https://dev.subsidia.protypa.fr/docs/integrators.md). ## 2026-10-04 **Fixed: security hardening.** A server-side request forgery on a public route, unauthenticated routes and cross-tenant reads were closed. Webhook target URLs are validated when you create a webhook (`400` with the reason when a URL is not allowed). Changing the platform-wide AI model and PII mode is restricted to platform administrators. ## 2026-10-03 **Billing: model weights.** Questions are now weighted by the class of the model that answered: light models count 1x, standard 3x, advanced 6x, premium 12x, frontier 30x. The token counts in `usage` are unchanged. See [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md). ## 2026-10-02 **Added: `403 client_suspended`.** An integrator can suspend a client workspace with one switch. Its keys are kept and work again when access is restored; no key is reissued. **Added: budget alert.** An email is sent when a key approaches its monthly question ceiling (`monthlyQuestionLimit`). **Added: API sandbox** for integrators, a ready-made environment to try the partner API and the new routes. ## 2026-09-27 **Added: Cortex write and original-document access** (the app's "open the original" and "ask me" loop) and **poste runs** through `POST /v1/postes/:key/run`. **Performance:** several scalability bottlenecks found in the 27/09 audit were fixed; no contract change. ## 2026-09-25 **Added: poste runs** through `POST /v1/postes/:key/run`, and Light provenance and proofs exposed to the engine. See [Postes](https://dev.subsidia.protypa.fr/docs/postes.md). ## 2026-09-20 **Added: API keys with real scopes.** Keys can be restricted to route families (`engine`, `knowledge`, `reflex`, `light`, `webhooks`, `partner`; `proof` followed on 9 October), made read-only with `readonly`, capped with `monthlyQuestionLimit` (questions per calendar month) and `ratePerMinute`, and given an expiry. New errors: `403 scope_denied`, `403 read_only_key`, `429 rate_limited` with `retry-after`, `402 API_KEY_BUDGET_EXCEEDED`. **Compatibility:** a key that holds only the six legacy module scopes remains unrestricted, exactly as before. Restrictions apply as soon as a key carries a capability scope. See [Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md). **Added: partner program** for integrators: gated access, a partner API, client consent, resale. See [Partner API](https://dev.subsidia.protypa.fr/docs/partner.md). ## 2026-09-16 **Billing: customers buy questions.** Plans Essentiel, Pro and Cabinet per user, question packs and a 14-day trial replace token-based licences. One question is one answer up to 8 000 tokens. Quota errors carry a `code` (`QUESTION_LIMIT`, `SUBSCRIPTION_INACTIVE`, `PLAN_UPGRADE_REQUIRED`, `DOCUMENT_LIMIT`, `SEAT_LIMIT`). See [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md) and [Errors](https://dev.subsidia.protypa.fr/docs/errors.md). ## 2026-07-26 and 2026-07-27 **Added: the Gateway.** `POST /v1/chat/completions` (OpenAI dialect) with a signed, hash-chained proof certificate per call, returned in the `x-pulse-proof` header; `GET /v1/proofs/:id`, `GET /v1/proofs/:id/verify` and `GET /v1/gateway/public-key`. The certificate is persisted before the response completes. **Added: the Anthropic dialect.** `POST /v1/messages` and `POST /v1/messages/count_tokens`, so Claude Code can point at the Gateway. Agent-grade routing followed on 27 July: tool-using clients are routed to the full model. See [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md). --- # Use these docs with an AI agent > llms.txt, llms-full.txt, a Markdown twin of every page, copy and open-in-Claude buttons, and a system prompt to paste into an agent that integrates Subsidia. More and more integrations are written with an AI coding agent beside the developer. An agent is only as good as the context it is given, and a rendered web page is a poor context: navigation, scripts and layout around a few kilobytes of useful text. Every page of this site therefore has a plain Markdown twin, and the whole site is available as one file, so you can give an agent exactly the documentation it needs and nothing else. The Markdown is generated from the same source as the pages you read, so it is never out of date with them, and it carries the same information: tables, parameters, examples and error codes are all present as text. ## The machine-readable entry points | URL | What it is | Use it when | | --- | --- | --- | | `https://dev.subsidia.protypa.fr/llms.txt` | An index following the llms.txt convention: the base URL, how to authenticate, where to create a key, then every page with a one-line summary and a link to its Markdown. | The agent should find the right page itself and fetch only that. | | `https://dev.subsidia.protypa.fr/llms-full.txt` | The complete documentation in one Markdown file, pages separated by rules and each preceded by its source URL. | You can afford the context, or want the agent to reason across pages (for example errors, scopes and quotas together). | | `https://dev.subsidia.protypa.fr/docs/.md` | One page as Markdown, for example `/docs/gateway.md`. The overview is `/docs.md`. | You are working on one feature. | | `https://dev.subsidia.protypa.fr/openapi.json` | A generated OpenAPI snapshot of the routes. | You want to generate a client or a tool schema. It lists routes and shapes, not behaviour: the pages remain the reference for errors and quotas. | | `https://dev.subsidia.protypa.fr/sitemap.xml` | The sitemap. | A crawler. | All of these are public and answer with permissive CORS, so an agent, a browser extension or a script can read them without an account. Responses are cached for a few minutes. **Fetching docs from a terminal** _curl_ ```bash # the index curl -s https://dev.subsidia.protypa.fr/llms.txt # one page curl -s https://dev.subsidia.protypa.fr/docs/gateway.md # everything, saved for reuse curl -s https://dev.subsidia.protypa.fr/llms-full.txt -o subsidia-docs.md ``` _PowerShell_ ```powershell Invoke-WebRequest https://dev.subsidia.protypa.fr/docs/gateway.md -OutFile gateway.md Invoke-WebRequest https://dev.subsidia.protypa.fr/llms-full.txt -OutFile subsidia-docs.md ``` ## The buttons on every page At the top of each documentation page: - **Copy page** copies the page as Markdown to your clipboard. Paste it into any chat or into your agent's context. - The arrow next to it opens a menu with **View as Markdown** (the `.md` twin), **Open in Claude** and **Open in ChatGPT**. The last two open a new conversation with a prompt that asks the assistant to read that page's Markdown URL and help you with it. The assistants fetch the URL themselves, so use them with a page you are reading right now. For a longer session in your own tools, prefer the next section. ## Claude Code, Cursor and other coding agents Give the agent the index and tell it to fetch pages on demand. This keeps the context small and the answers current. - **Claude Code.** Put the system prompt below in your project's `CLAUDE.md`, or paste a page URL in the conversation and ask it to read it. You can also save `llms-full.txt` in the repository (for example `docs/subsidia.md`) and reference it from `CLAUDE.md`. - **Cursor and similar editors.** Add `https://dev.subsidia.protypa.fr/llms-full.txt` (or a single `.md` page) as a documentation source in the editor's docs or context settings, or reference the file you saved. - **Any agent with web access.** Give it `https://dev.subsidia.protypa.fr/llms.txt` as its starting point. Running Claude Code *through* Subsidia, rather than only reading its docs, is a separate topic: see [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md#use-it-with-claude-code). > **TIP: Give the agent a sandbox key, not your production key** > An agent that integrates Subsidia will run the code it writes. Create a key for it with only the scopes the task needs, a small `monthlyQuestionLimit` and an `expiresAt` a few days away. If it leaks into a log or a commit, the damage is bounded. See [Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md). ## A system prompt to paste This prompt gives an agent the facts it most often gets wrong, and tells it where to look for the rest. Replace nothing; it points at the public documentation. Keep it short on purpose: the agent should read pages, not memorise them. **System prompt for an agent integrating Subsidia** _Prompt_ ```markdown You are helping integrate software with the Subsidia API. Documentation (read it before writing code, do not guess): - Index: https://dev.subsidia.protypa.fr/llms.txt - Any page as Markdown: https://dev.subsidia.protypa.fr/docs/.md (for example gateway, authentication, errors, rate-limits, knowledge-query, verify) - Everything in one file: https://dev.subsidia.protypa.fr/llms-full.txt Facts you must respect: - Base URL: https://api.subsidia.protypa.fr . OpenAI-style clients use https://api.subsidia.protypa.fr/v1 as base URL; Anthropic-style clients use the host without /v1. - Authenticate with a developer key sk_live_... read from the SUBSIDIA_API_KEY environment variable, as "Authorization: Bearer " or "x-api-key: ". Never hard-code or print a key. - The Gateway is OpenAI and Anthropic compatible. Prefer the official OpenAI or Anthropic SDK with the base URL changed over hand-written HTTP. The model field is an address: use "pulse-auto" unless told otherwise; "agent:" calls a workspace agent; the flags +cortex (ground in the knowledge base) and +sensitive (local processing only) can be appended. Do not invent other model names or flags. - Every Gateway response carries an x-pulse-proof header: the id of a signed proof certificate. Keep it with the business record the answer feeds. Fetch it with GET /v1/proofs/{id}. - A key may be restricted by scope (engine, knowledge, proof, agents, conversations, webhooks, reflex, light, partner, readonly). A 403 scope_denied means the key lacks the scope named in the message; do not retry, report it. - Errors: branch on the HTTP status, then on the "code" field. 401 and 403 and 4xx: do not retry. 402 means a quota or plan limit: stop and tell the user, never loop. 429 with a retry-after header: wait that many seconds and retry. 5xx: retry at most three times with backoff. The body is {"error": string, "code"?: string} on native routes, {"error": {"message","type","code"}} on /v1/chat/completions, and {"type":"error","error":{"type","message"}} on /v1/messages. In streams, errors arrive as an event after the 200 status. - Customers buy questions, not tokens. Do not describe limits to end users in tokens. - File upload is multipart/form-data to POST /v1/knowledge-sources/upload (field "file", 25 MB max); ingestion is asynchronous, poll GET /v1/knowledge-sources/{id} until status is "ready". - POST /v1/verify checks a text against passages and calls no model; it is counted as a control, not as questions. - Only document and use endpoints that appear in the documentation. If the documentation does not mention a route, field or error code, say so instead of inventing it. When unsure, fetch the relevant page and quote the line you relied on. ``` ## For documentation tooling - Pages are plain Markdown with GitHub-style tables and fenced code blocks, headings for sections, and absolute links back to the site. - Each endpoint is rendered as a section with method and path, authentication and scopes, parameters, examples, responses and errors, so an agent can lift it directly. - The Ctrl+K search on the site uses the same content as these files. - If you build a retrieval index over `llms-full.txt`, split on the horizontal rules and keep the source URL comment that precedes each page as the citation. **Is there an MCP server for these docs?** No. The Markdown endpoints are the supported way to give an agent the documentation. Any agent that can fetch a URL can use them. **How fresh is the Markdown?** It is generated from the same source as the web pages on every request and cached for a few minutes, so it matches the site. For behaviour changes see the [Changelog](https://dev.subsidia.protypa.fr/docs/changelog.md). **Can an agent call the API on its own to explore it?** Yes, with a key. `GET /v1/models` lists the model addresses available to the key, including your agents. Prefer a restricted, short-lived key for this, as described above. --- # Gateway > OpenAI and Anthropic compatible endpoints: change one base URL and every call is masked, routed, metered and signed with a proof certificate. The Gateway is the fastest way to put Subsidia in front of existing software. It speaks the two wire protocols the AI ecosystem already uses, OpenAI `chat/completions` and Anthropic `messages`, so any client that lets you set a base URL (the official SDKs, Claude Code, Cursor, n8n, LangChain) works unchanged. What you get in exchange for that one line: personal data is masked before it leaves your perimeter, each call is routed to a model that fits the task, usage is metered against your workspace, and every completion comes back with a **signed, hash-chained proof certificate** that you can hand to an auditor. **What happens to a call. The certificate is stored before the response completes, so the id in the header is always fetchable.** Flow: Your client -> Address parsing -> PII shield -> Routing -> Model -> Restore + sign -> Response + x-pulse-proof ## Base URL and authentication Use `https://api.subsidia.protypa.fr` as the host. OpenAI-style clients append `/v1` themselves or expect it in the base URL; Anthropic-style clients expect the host only and add `/v1/messages`. Authenticate with a developer API key (`sk_live_...`, created in the Subsidia console). Both header styles are accepted, so each official SDK works with its native convention: | Header | Sent by | Notes | | --- | --- | --- | | `Authorization: Bearer sk_live_...` | OpenAI SDKs, curl | The key must start with `sk_live_` or `sk_test_`; any other Bearer value is ignored and the call is a 401. | | `x-api-key: sk_live_...` | Anthropic SDKs | Takes precedence when both headers are present. | > **WARNING: Scopes** > A key restricted to capability scopes can only reach the route families it names. The Gateway routes (`/v1/chat/completions`, `/v1/messages`, `/v1/models`, `/v1/proofs`, `/v1/gateway/*`) need the `engine` scope. A missing scope is a `403 scope_denied`. See [Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md). ## First call 1. **Create a key** Open the Subsidia console, Gateway page, and create a key with the `engine` scope. It is displayed once; store it in an environment variable, never in source control. 2. **Point your client at the Gateway** Set the base URL to `https://api.subsidia.protypa.fr/v1` and keep your usual code. Use `pulse-auto` as the model so Synapse picks the model per call. ```bash export SUBSIDIA_API_KEY="sk_live_..." ``` 3. **Send a request and keep the proof id** Every response carries an `x-pulse-proof` header. Keep it next to the business record the answer feeds: it is the handle to the certificate. ```bash curl -i https://api.subsidia.protypa.fr/v1/chat/completions \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"pulse-auto","messages":[{"role":"user","content":"Hello"}]}' ``` ## Use the official SDKs **Same SDK, one changed line** _OpenAI (TypeScript)_ ```typescript import OpenAI from 'openai' const client = new OpenAI({ baseURL: 'https://api.subsidia.protypa.fr/v1', // the one changed line apiKey: process.env.SUBSIDIA_API_KEY, // sk_live_... }) const { data, response } = await client.chat.completions .create({ model: 'pulse-auto', messages: [{ role: 'user', content: 'Summarise this contract clause.' }], }) .withResponse() console.log(data.choices[0].message.content) // answer, personal data restored console.log(response.headers.get('x-pulse-proof')) // certificate id ``` _OpenAI (Python)_ ```python import os from openai import OpenAI client = OpenAI( base_url="https://api.subsidia.protypa.fr/v1", api_key=os.environ["SUBSIDIA_API_KEY"], ) raw = client.chat.completions.with_raw_response.create( model="pulse-auto", messages=[{"role": "user", "content": "Summarise this contract clause."}], ) completion = raw.parse() proof_id = raw.headers.get("x-pulse-proof") ``` _Anthropic (TypeScript)_ ```typescript import Anthropic from '@anthropic-ai/sdk' const client = new Anthropic({ baseURL: 'https://api.subsidia.protypa.fr', // host only: the SDK adds /v1/messages apiKey: process.env.SUBSIDIA_API_KEY, }) const { data, response } = await client.messages .create({ model: 'pulse-auto', max_tokens: 1024, messages: [{ role: 'user', content: 'Summarise this contract clause.' }], }) .withResponse() console.log(data.content) // Anthropic content blocks console.log(response.headers.get('x-pulse-proof')) ``` ## Use it with Claude Code Claude Code speaks the Anthropic protocol, so it can run entirely through the Gateway: every turn of the agent loop is masked, routed and attested. The variables below are session-scoped; closing the terminal returns Claude Code to its normal login. _bash_ ```bash export ANTHROPIC_BASE_URL="https://api.subsidia.protypa.fr" export ANTHROPIC_AUTH_TOKEN="sk_live_..." # AUTH_TOKEN, not API_KEY export ANTHROPIC_MODEL="pulse-auto" export ANTHROPIC_SMALL_FAST_MODEL="pulse-auto" claude ``` _PowerShell_ ```powershell $env:ANTHROPIC_BASE_URL = "https://api.subsidia.protypa.fr" $env:ANTHROPIC_AUTH_TOKEN = "sk_live_..." # AUTH_TOKEN, not API_KEY $env:ANTHROPIC_MODEL = "pulse-auto" $env:ANTHROPIC_SMALL_FAST_MODEL = "pulse-auto" claude ``` > **TIP: ANTHROPIC_AUTH_TOKEN, not ANTHROPIC_API_KEY** > Claude Code only accepts Anthropic-issued key formats (`sk-ant-...`) in `ANTHROPIC_API_KEY` and reports "Not logged in" otherwise. `ANTHROPIC_AUTH_TOKEN` sends the key as a Bearer header, which the Gateway accepts. ## The model field is an address Third-party tools expose one setting you can always change: the model name. The Gateway reads it as an **address** that selects which Subsidia capability serves the call, so your agents and your knowledge base are reachable from tools that know nothing about them. With no Subsidia prefix or flag the Gateway is a faithful passthrough: capabilities are opt-in through the address, never imposed. The full grammar is in [Model addressing](https://dev.subsidia.protypa.fr/docs/model-addressing.md). - **pulse-auto**: Synapse picks the model per call: small for simple asks, full for complex work, local for detected-sensitive content. - **[agent:](https://dev.subsidia.protypa.fr/docs/agents.md)**: Served by one of your workspace agents, with its prompt, memory, documents and tools. - **[+cortex](https://dev.subsidia.protypa.fr/docs/knowledge-query.md)**: Grounds the call in your knowledge base; the retrieved passages are hashed into the certificate. - **[+sensitive](https://dev.subsidia.protypa.fr/docs/synapse.md)**: Forces local processing on an installation that has a local model; on a hosted cloud without one it only guarantees masking. The certificate shows which provider answered. ## Endpoints ### POST /v1/chat/completions OpenAI-compatible chat completion, attested. Drop-in for the OpenAI chat-completions endpoint. The request passes through the Synapse pipeline (PII shield, routing, metering) and a certificate is signed for the exchange. Unknown fields are accepted and ignored, so SDK upgrades do not break. The answer is returned with the personal data restored, so your application sees what it would have seen without the Gateway. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `engine` - **Streaming:** yes, Server-Sent Events when `stream: true` #### Headers | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `x-pulse-conversation` | `string` | no | | Continue an existing agent conversation (`agent:` addresses only). Omit to start a new one. An unknown id is a 404 `conversation_not_found`. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | `string` | no | `pulse-auto` | A model address. See [Model addressing](https://dev.subsidia.protypa.fr/docs/model-addressing.md). Any other name is accepted, recorded in the certificate, and still routed. | | `messages` | `object[]` | yes | | The conversation. Must be a non-empty array containing at least one `user` message. | | `messages.role` | `string` | yes | | Author of the message. One of: `system`, `user`, `assistant`, `tool`. | | `messages.content` | `string | object[] | null` | yes | | Text, or an array of `{ type: "text", text }` parts. `null` is allowed on assistant messages that carry `tool_calls`. | | `messages.tool_calls` | `object[]` | no | | Tool calls made by an assistant message, in the OpenAI shape. | | `messages.tool_call_id` | `string` | no | | On `tool` messages: the call this message answers. | | `stream` | `boolean` | no | `false` | Return Server-Sent Events. The proof id is in the response headers from the first byte. | | `temperature` | `number` | no | | Sampling temperature, forwarded to the model. | | `max_tokens` | `integer` | no | | Output ceiling. Values above the Gateway cap (16000 by default) are clamped instead of rejected, so agent loops do not fail. `max_completion_tokens` is accepted as an alias. | | `tools` | `object[]` | no | | OpenAI function definitions. Their presence marks the call as agentic and routes it to the full model. Ignored on `agent:` addresses, which run the agent tools server-side. | | `response_format` | `object` | no | | Set `{ "type": "json_object" }` for JSON mode. Ignored on `agent:` addresses. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/chat/completions \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "pulse-auto", "messages": [ {"role": "system", "content": "You are a careful legal assistant."}, {"role": "user", "content": "Summarise the termination clause for Jean Dupont."} ] }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/chat/completions', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'pulse-auto', messages: [ { role: 'system', content: 'You are a careful legal assistant.' }, { role: 'user', content: 'Summarise the termination clause for Jean Dupont.' }, ], }), }) const proofId = res.headers.get('x-pulse-proof') const completion = await res.json() console.log(completion.choices[0].message.content, proofId) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/chat/completions", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "model": "pulse-auto", "messages": [ {"role": "system", "content": "You are a careful legal assistant."}, {"role": "user", "content": "Summarise the termination clause for Jean Dupont."}, ], }, ) proof_id = res.headers["x-pulse-proof"] print(res.json()["choices"][0]["message"]["content"], proof_id) ``` #### Responses **200**: A `chat.completion` object plus a `pulse` block. Headers: `x-pulse-proof` (certificate id), `x-pulse-provider` (`ollama`, `openai`...), `x-pulse-pii-masked` (number of values masked), and `x-pulse-conversation` for `agent:` calls. With `stream: true` the body is `text/event-stream` of `chat.completion.chunk` events ending with `data: [DONE]`; only `x-pulse-proof` is sent as a header, and the final chunk carries `usage` plus a `pulse` block (`proof`, `conversation`) for `agent:` calls only. The certificate is persisted before `[DONE]`. ```json { "id": "chatcmpl-pf_a059713a5f1c4e0b8d7a3c21e9b64f10", "object": "chat.completion", "created": 1790000000, "model": "gpt-4o-mini", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "Either party may terminate with 30 days written notice..." }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 53, "completion_tokens": 38, "total_tokens": 91 }, "pulse": { "proof": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10", "provider": "openai", "task": "chat", "pii_masked": 1 } } ``` **400**: Missing or empty `messages`, or no `user` message. ```json { "error": { "message": "'messages' is a required property and must be a non-empty array", "type": "invalid_request_error", "code": null, "param": null } } ``` **402**: The workspace has no questions left. Returned before any model is called, so nothing is consumed. ```json { "error": { "message": "Insufficient balance", "type": "insufficient_quota", "code": "insufficient_quota", "param": null } } ``` **404**: The `agent:` address matches no agent in this workspace (`model_not_found`), or `x-pulse-conversation` is unknown (`conversation_not_found`). **501**: `debate:` addresses are reserved and not served here (`address_not_supported`). Use the conversations API. #### Errors | Status | Code | When | | --- | --- | --- | | 401 | | No key, wrong key, revoked or expired key. Body: `{ "error": "..." }`. | | 402 | `insufficient_quota` | The workspace allowance is exhausted. | | 402 | `API_KEY_BUDGET_EXCEEDED` | The key reached its own monthly question ceiling. | | 403 | `scope_denied` | The key does not carry the `engine` scope. | | 429 | `rate_limited` | The key exceeded its requests-per-minute limit. Honour `retry-after`. | #### Notes Tool-call requests cannot stream token by token: with `stream: true` and `tools` present, the full answer is produced first and emitted as a single chunk. Calls to `agent:` addresses run the agent tools on the server and always stream plain text. ### POST /v1/messages Anthropic-compatible messages endpoint, attested. Drop-in for the Anthropic Messages API, with the same pipeline, address grammar and certificate registry as the OpenAI dialect. Errors use the Anthropic envelope (`{ "type": "error", "error": { "type", "message" } }`). - **Authentication:** API key (x-api-key or Bearer) - **Scopes:** `engine` - **Streaming:** yes, Server-Sent Events when `stream: true` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | `string` | no | `pulse-auto` | A model address. | | `messages` | `object[]` | yes | | Anthropic messages. Content may be a string or an array of content blocks. | | `system` | `string | object[]` | no | | System prompt, as a string or text blocks. | | `max_tokens` | `integer` | no | | Output ceiling, clamped to the Gateway cap. | | `temperature` | `number` | no | | Sampling temperature. | | `tools` | `object[]` | no | | Anthropic tool definitions (`name`, `description`, `input_schema`). | | `stream` | `boolean` | no | `false` | Return Server-Sent Events. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/messages \ -H "x-api-key: $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "pulse-auto", "max_tokens": 1024, "messages": [{"role": "user", "content": "Summarise the termination clause."}] }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/messages', { method: 'POST', headers: { 'x-api-key': process.env.SUBSIDIA_API_KEY!, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'pulse-auto', max_tokens: 1024, messages: [{ role: 'user', content: 'Summarise the termination clause.' }], }), }) const message = await res.json() const proofId = res.headers.get('x-pulse-proof') ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/messages", headers={"x-api-key": os.environ["SUBSIDIA_API_KEY"]}, json={ "model": "pulse-auto", "max_tokens": 1024, "messages": [{"role": "user", "content": "Summarise the termination clause."}], }, ) message = res.json() proof_id = res.headers["x-pulse-proof"] ``` #### Responses **200**: A `message` object with Anthropic content blocks (`text`, `tool_use`). The certificate id is in the `x-pulse-proof` header. **400**: `messages` missing or empty (`invalid_request_error`). **429**: Workspace allowance exhausted (`rate_limit_error`), or the key rate limit. In the Anthropic dialect the quota error is a 429, not a 402. #### Errors | Status | Code | When | | --- | --- | --- | | 401 | | Missing, invalid or revoked key. | | 404 | `not_found_error` | The `agent:` address matches no agent. | | 501 | `invalid_request_error` | `debate:` addresses are not served here. | #### Notes Differences from the OpenAI dialect: the certificate address records only `kind`, `agent` and `cortex` (not `+nocortex` / `+nomemory`); the response has no `pulse` body block, so read `x-pulse-provider` and `x-pulse-pii-masked` from the headers; `stop_reason` is `end_turn` or `tool_use`. ### POST /v1/messages/count_tokens Estimate the input size of a Messages request. Clients such as Claude Code call this to check that a prompt fits the context window. The result is a characters-divided-by-four estimate, the same heuristic Synapse uses for routing, not a tokenizer count. - **Authentication:** API key - **Scopes:** `engine` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `messages` | `object[]` | yes | | Same shape as `POST /v1/messages`. | | `system` | `string | object[]` | no | | System prompt. | | `tools` | `object[]` | no | | Tool definitions, counted as serialised JSON. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/messages/count_tokens \ -H "x-api-key: $SUBSIDIA_API_KEY" -H "Content-Type: application/json" \ -d '{"messages":[{"role":"user","content":"How long is this?"}]}' ``` #### Responses **200**: The estimate. ```json { "input_tokens": 6 } ``` ### GET /v1/models List model aliases and your agents as addresses. Returns the Gateway aliases, the currently configured full model, and one `agent:` entry per agent of the workspace. Because model pickers are populated from this route, your own agents appear as selectable models in tools such as Cursor, Continue or n8n without any integration work. - **Authentication:** API key - **Scopes:** `engine` #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/models -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _Python_ ```python import os, requests models = requests.get( "https://api.subsidia.protypa.fr/v1/models", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json()["data"] print([m["id"] for m in models]) ``` #### Responses **200**: An OpenAI-style list. ```json { "object": "list", "data": [ { "id": "pulse-auto", "object": "model", "created": 1790000000, "owned_by": "pulse-gateway" }, { "id": "pulse-sensitive", "object": "model", "created": 1790000000, "owned_by": "pulse-gateway" }, { "id": "pulse-agent", "object": "model", "created": 1790000000, "owned_by": "pulse-gateway" }, { "id": "agent:demo-cfo", "object": "model", "created": 1790000000, "owned_by": "pulse-agents" } ] } ``` ## Proof certificates Every completion returns an `x-pulse-proof` header. The certificate commits, by SHA-256, to your request, to what actually left toward the model provider (after masking), and to the response. It never contains the content itself, so it can be shared with a client or a regulator as is. Each certificate embeds the hash of the previous one (`chainIndex`, `prevHash`): removing one leaves a visible hole for every verifier. Calls served by an agent or carrying `+cortex` add a second level, a `grounding` array that commits to the exact knowledge passages the answer drew on. **Certificate payload (abridged)** ```json { "id": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10", "chain_index": 12, "prev_hash": "90a8ecd956...", "payload": { "request": { "model": "pulse-auto+cortex", "sha256": "b2205bba..." }, "address": { "kind": "auto", "cortex": true }, "grounding": [{ "source": "etat-des-lieux.md", "chunkId": "ck_...", "page": null, "sha256": "11d10642..." }], "egress": { "provider": "ollama", "model": "llama3.1:8b", "sha256": "876be067...", "pii": { "masked": 2, "categories": ["EMAIL", "NAME"] } }, "response": { "sha256": "ae792e49...", "finishReason": "stop" } }, "payload_hash": "sha256(canonical-json(payload))", "signature": "base64 Ed25519 over payload_hash", "algorithm": "Ed25519" } ``` ### GET /v1/proofs/:id Fetch a proof certificate. Returns the signed certificate. Certificates are scoped to the workspace of the key: another workspace's proof is indistinguishable from a missing one. - **Authentication:** API key - **Scopes:** `engine` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The value of the `x-pulse-proof` header, `pf_` followed by 32 hex characters. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/proofs/pf_a059713a5f1c4e0b8d7a3c21e9b64f10 \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const cert = await fetch('https://api.subsidia.protypa.fr/v1/proofs/' + proofId, { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }).then((r) => r.json()) ``` #### Responses **200**: The certificate: `id`, `chain_index`, `prev_hash`, `payload`, `payload_hash`, `signature`, `algorithm`, `hash`, `created_at`. **404**: Unknown id, or the proof belongs to another workspace (`proof_not_found`). ### GET /v1/proofs/:id/verify Verify a certificate on the server. Recomputes the payload hash, checks the Ed25519 signature, and checks that the previous record in the chain still matches `prev_hash`. Use the public key route below if you would rather not trust the server for this. - **Authentication:** API key - **Scopes:** `engine` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | Certificate id. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/proofs/$PROOF_ID/verify -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` #### Responses **200**: `valid` is true only when both the signature and the chain link hold. ```json { "id": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10", "signature_valid": true, "chain_valid": true, "valid": true, "reason": null } ``` **404**: Unknown id (`proof_not_found`). ### GET /v1/gateway/public-key Public key for offline verification. Open route, no key required, on purpose: an auditor holding only a certificate and this key can validate it without any access to your account. - **Authentication:** None #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/gateway/public-key ``` #### Responses **200**: The key in SPKI PEM format. ```json { "algorithm": "Ed25519", "format": "spki-pem", "public_key": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" } ``` ### Verify offline The signature covers `payload_hash`, which is the SHA-256 of the canonical JSON of `payload` (keys sorted, `undefined` dropped). Verification takes about fifteen lines in any language with an Ed25519 implementation. _Node.js_ ```typescript import crypto from 'node:crypto' function canonicalJson(v: unknown): string { if (v === null || typeof v !== 'object') return JSON.stringify(v) if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']' const entries = Object.entries(v as Record) .filter(([, x]) => x !== undefined) .sort(([a], [b]) => (a < b ? -1 : 1)) return '{' + entries.map(([k, x]) => JSON.stringify(k) + ':' + canonicalJson(x)).join(',') + '}' } export function isAuthentic(cert: any, publicKeyPem: string): boolean { const recomputed = crypto.createHash('sha256').update(canonicalJson(cert.payload), 'utf8').digest('hex') return ( recomputed === cert.payload_hash && crypto.verify(null, Buffer.from(cert.payload_hash, 'utf8'), crypto.createPublicKey(publicKeyPem), Buffer.from(cert.signature, 'base64')) ) } ``` > **INFO: Two proof levels** > Level 1 (every call) attests the pipeline: request, masking, model, response. Level 2 (agent and `+cortex` calls) also commits to the sources the answer was grounded in. Details in [Proof certificates](https://dev.subsidia.protypa.fr/docs/proofs.md). ## Errors | Status | OpenAI dialect | Anthropic dialect | Meaning | | --- | --- | --- | --- | | 400 | `invalid_request_error` | `invalid_request_error` | Malformed body: no `messages`, no user message. | | 401 | `{ "error": "..." }` | `{ "error": "..." }` | Authentication failed. The same plain envelope in both dialects. | | 402 | `insufficient_quota` | not used | Workspace allowance exhausted, or key budget (`API_KEY_BUDGET_EXCEEDED`). | | 403 | `scope_denied`, `read_only_key` | same | The key may not call this route, or may not write. | | 404 | `model_not_found` | `not_found_error` | No such `agent:` address. | | 429 | `rate_limited` | `rate_limit_error` | Per-key request limit. In the Anthropic dialect a spent allowance is also a 429. | | 501 | `address_not_supported` | `invalid_request_error` | Reserved `debate:` addresses. | > **DANGER: Streaming errors arrive in-band** > Once an SSE stream has started, the HTTP status is already 200. A failure mid-stream is delivered as a final `data: { "error": { ... } }` event followed by `[DONE]`. Always check for an `error` object in the events, not only the status code. ## Frequently asked **Does the Gateway change my prompts?** Not unless you opt in. Without a Subsidia prefix or flag the Gateway is a passthrough with masking, routing and a certificate. `+cortex` adds knowledge passages to the prompt, and `agent:` hands the call to an agent; both are recorded in the certificate. **Is my data sent to a third party?** Only to the provider that serves the call. Masking applies to **external** providers; with `pulse-sensitive` or `+sensitive` the call stays on a local model on installations that have one (local and on-premise), and nothing is redacted because nothing leaves. On a hosted cloud without a local model, masking is what protects the data. The certificate records the provider that answered. **How are calls billed?** Against the workspace allowance of questions, charged on the model that actually answered. A larger model counts for more. See [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md). **Can I verify a certificate without calling Subsidia?** Yes. Fetch the certificate once, fetch `GET /v1/gateway/public-key` once, and run the offline check above. The public key route needs no credentials. --- # Model addressing > The model field is an address: prefixes pick who answers (Synapse, a full model, a local model, one of your agents) and +flags add or remove capabilities. Every AI client lets you change one thing: the model name. Subsidia uses that single field as an **address**. The text before the first `+` selects which capability serves the call; each `+flag` after it adds or removes something. Your agents, your knowledge base and local-only processing therefore become reachable from tools that know nothing about Subsidia: Cursor, Continue, n8n, LangChain, Claude Code, or a script you wrote against the OpenAI SDK. The address is parsed identically on `POST /v1/chat/completions` and `POST /v1/messages` (see [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md)), and the original string is recorded verbatim in the [proof certificate](https://dev.subsidia.protypa.fr/docs/proofs.md). > **INFO: The red line: no prefix, no flag, no change** > A model name that carries no Subsidia prefix and no flag (`gpt-4o`, `claude-sonnet-4-6`, anything) is a **faithful passthrough**: the call is still masked, routed, metered and signed, but nothing is added to your prompt and no agent or knowledge base is involved. Capabilities are opt-in through the address, never imposed. An empty or missing `model` is read as `pulse-auto`. ## Grammar ``` [+flag][+flag]... base = pulse-auto | pulse-agent | pulse-sensitive | agent: | debate: | any other string flag = cortex | sensitive | agent | nocortex | noknowledge | nomemory ``` Parsing rules, all taken from the parser: - The string is split on `+`. The first part is the base, the rest are flags. - Whitespace around each part is trimmed. **Flags are case-insensitive** (`+Cortex` works); the **base is case-sensitive** (`Pulse-Auto` is just an unknown name, so a passthrough). - **Flag order does not matter** and flags compose freely. - An **unknown flag is silently ignored**, with no error. Check spelling: `pulse-auto+cortx` is a plain `pulse-auto` call. - A base that starts with `agent:` or `debate:` is followed by a slug. The slug is normalised the same way agent names are: accents removed, lowercased, every run of non-alphanumeric characters turned into one dash, edge dashes trimmed. `agent:Demo CFO` and `agent:demo-cfo` are the same address. ## Prefixes | Base | Who answers | Notes | | --- | --- | --- | | `pulse-auto` | Synapse routes the call by task complexity and sensitivity. | The default, and what any unknown or missing model name behaves like. See [Synapse](https://dev.subsidia.protypa.fr/docs/synapse.md). | | `pulse-agent` | The configured full-quality model, always. | Marks the call as agentic so it is never handed to the small model. For harnesses and tool loops. The Gateway also detects agentic traffic on its own (see below). | | `pulse-sensitive` | Local processing, always. | Same as `pulse-auto+sensitive`. Where a local model exists the request never leaves the machine and the certificate shows the provider. See the caveat in [Sensitive](#flag-sensitive). | | `agent:` | One of your workspace agents. | Its prompt, memory, documents and tools. Unknown slug is a 404 `model_not_found`. See [Agents](https://dev.subsidia.protypa.fr/docs/agents.md). | | `debate:` | Reserved. | Parsed but not served: `501 address_not_supported` (OpenAI dialect) or `501 invalid_request_error` (Anthropic dialect). Use the [Conversations API](https://dev.subsidia.protypa.fr/docs/conversations.md). | | Any other name | Passthrough, routed like `pulse-auto`. | `gpt-4o`, `claude-sonnet-4-6`, `my-model`. Accepted, recorded in the certificate as `request.model`, and routed by Synapse regardless of the name. | ## Flags | Flag | Applies to | Effect | | --- | --- | --- | | `+cortex` | Non-agent addresses | Retrieves the five most relevant passages of your knowledge base for the last user message, adds them to the prompt as cited context, and commits their hashes to the certificate (`grounding`). If nothing matches, the call proceeds ungrounded. | | `+sensitive` | Non-agent addresses | Forces local routing. Equivalent to the `pulse-sensitive` base, and composable with any other base. | | `+agent` | Non-agent addresses | Forces the full-quality model. Equivalent to the `pulse-agent` base. | | `+nocortex` | `agent:` only | The agent answers from its persona alone, without searching the knowledge base. Recorded in the certificate as `address.knowledge: false` (OpenAI dialect). | | `+noknowledge` | `agent:` only | Alias of `+nocortex`. | | `+nomemory` | `agent:` only | The agent neither recalls nor writes persistent memory. The call inherits nothing and leaves nothing behind. Recorded as `address.memory: false` (OpenAI dialect). | The negative flags exist because an address that could only add capability gave callers running an isolated experiment no way to stop an agent from inheriting, and leaving, state across calls. `agent:cfo+nocortex+nomemory` is a clean-room run of the persona. **Which flags are read where.** The parser accepts every flag on every address, but the Gateway only acts on the ones that make sense for the kind of call. `+nocortex`, `+noknowledge` and `+nomemory` are used by `agent:` calls only. For `+cortex`, an `agent:` call already grounds itself in the agent's own knowledge unless you add `+nocortex`, so the flag adds nothing there. For `+sensitive` on an `agent:` address, the Gateway does not pass the flag to the agent pipeline today; use `pulse-sensitive` when local processing is a hard requirement. ### Sensitive > **WARNING: Local means local only where a local model exists** > `pulse-sensitive` and `+sensitive` guarantee that the call stays on a local model on an installation that has one: a local or on-premise Subsidia, or a cloud deployment whose active provider is local. On a hosted cloud deployment without a local lane, the engine cannot route to a machine it does not have; the PII shield masks personal data before any external provider sees it, as for every call. Read `x-pulse-provider` or the certificate's `egress.provider` to see what actually answered. Details in [Synapse](https://dev.subsidia.protypa.fr/docs/synapse.md). ## Composition | Address | Parsed as | | --- | --- | | `pulse-auto` | Synapse routing, nothing added. | | `gpt-4o` | Passthrough, Synapse routing, nothing added. The name is only recorded. | | `gpt-4o+cortex` | Passthrough plus knowledge grounding. | | `pulse-auto+cortex` | Synapse routing plus knowledge grounding. | | `pulse-agent+cortex` | Full model, always, grounded in your knowledge base. | | `pulse-sensitive+cortex` | Local model, grounded. The passages never leave the machine either. | | `pulse-auto+sensitive+cortex` | Same as the line above, in a different spelling. Flag order is irrelevant. | | `agent:demo-cfo` | The agent, with its knowledge base and memory on. | | `agent:demo-cfo+nocortex` | The agent, persona only. | | `agent:demo-cfo+nomemory` | The agent, with knowledge, no memory read or written. | | `agent:demo-cfo+nocortex+nomemory` | The agent as a stateless persona. | | `agent:Demo CFO` | Same as `agent:demo-cfo` (slug normalised). | | `debate:board` | Reserved. 501. | | `pulse-auto+cortx` | Typo in a flag: ignored, so a plain `pulse-auto`. | ## Examples The request body is identical in every case; only `model` changes. **Ground a call in your knowledge base** _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/chat/completions \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "pulse-auto+cortex", "messages": [{"role": "user", "content": "What notice period does the Martin lease require?"}] }' ``` _TypeScript_ ```typescript import OpenAI from 'openai' const client = new OpenAI({ baseURL: 'https://api.subsidia.protypa.fr/v1', apiKey: process.env.SUBSIDIA_API_KEY, }) const res = await client.chat.completions.create({ model: 'pulse-auto+cortex', messages: [{ role: 'user', content: 'What notice period does the Martin lease require?' }], }) // res.pulse.grounded_sources is the number of passages the certificate commits to console.log(res.choices[0].message.content, (res as any).pulse) ``` _Python_ ```python import os from openai import OpenAI client = OpenAI( base_url="https://api.subsidia.protypa.fr/v1", api_key=os.environ["SUBSIDIA_API_KEY"], ) res = client.chat.completions.create( model="pulse-auto+cortex", messages=[{"role": "user", "content": "What notice period does the Martin lease require?"}], ) print(res.choices[0].message.content) ``` **Talk to an agent, then continue the same thread** _curl_ ```bash # First turn: read the conversation id from the response headers curl -i https://api.subsidia.protypa.fr/v1/chat/completions \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"agent:demo-cfo","messages":[{"role":"user","content":"Summarise our cash position."}]}' # Second turn: send it back to continue the same persona-side conversation curl https://api.subsidia.protypa.fr/v1/chat/completions \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "x-pulse-conversation: $CONVERSATION_ID" \ -H "Content-Type: application/json" \ -d '{"model":"agent:demo-cfo","messages":[{"role":"user","content":"And next quarter?"}]}' ``` _TypeScript_ ```typescript const base = 'https://api.subsidia.protypa.fr/v1/chat/completions' const headers = { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', } const first = await fetch(base, { method: 'POST', headers, body: JSON.stringify({ model: 'agent:demo-cfo', messages: [{ role: 'user', content: 'Summarise our cash position.' }] }), }) const conversation = first.headers.get('x-pulse-conversation')! const second = await fetch(base, { method: 'POST', headers: { ...headers, 'x-pulse-conversation': conversation }, body: JSON.stringify({ model: 'agent:demo-cfo', messages: [{ role: 'user', content: 'And next quarter?' }] }), }) console.log((await second.json()).choices[0].message.content) ``` _Python_ ```python import os, requests url = "https://api.subsidia.protypa.fr/v1/chat/completions" headers = {"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]} first = requests.post(url, headers=headers, json={ "model": "agent:demo-cfo", "messages": [{"role": "user", "content": "Summarise our cash position."}], }) conversation = first.headers["x-pulse-conversation"] second = requests.post(url, headers={**headers, "x-pulse-conversation": conversation}, json={ "model": "agent:demo-cfo", "messages": [{"role": "user", "content": "And next quarter?"}], }) print(second.json()["choices"][0]["message"]["content"]) ``` **Other addresses, same call** _Python_ ```python # Hard requirement: stay on a local model, grounded in the knowledge base client.chat.completions.create(model="pulse-sensitive+cortex", messages=msgs) # Always the full model, for an agent harness or a long tool loop client.chat.completions.create(model="pulse-agent", messages=msgs) # An agent with no memory and no knowledge base: a reproducible bench client.chat.completions.create(model="agent:demo-cfo+nocortex+nomemory", messages=msgs) # A name your tool insists on: still masked, routed, metered and signed client.chat.completions.create(model="gpt-4o", messages=msgs) ``` _Claude Code_ ```bash export ANTHROPIC_BASE_URL="https://api.subsidia.protypa.fr" export ANTHROPIC_AUTH_TOKEN="sk_live_..." export ANTHROPIC_MODEL="pulse-agent+cortex" # full model, grounded in your documents claude ``` 1. **Pick the base** Do you need a specific agent (`agent:`), a guaranteed-local call (`pulse-sensitive`), a guaranteed-full model (`pulse-agent`), or simply the sensible default (`pulse-auto`)? 2. **Add flags only for what you need** `+cortex` when the answer must come from your documents. `+nocortex` or `+nomemory` on an agent when you want an isolated, repeatable run. 3. **Check what happened** Read `x-pulse-provider` and the `pulse` block of the response, or fetch the [certificate](https://dev.subsidia.protypa.fr/docs/proofs.md): it records your raw address, the parsed kind, the provider that answered and the grounding hashes. ## Where the address shows up in a certificate `payload.request.model` is the exact string you sent. `payload.address` records what was parsed: - `address.kind`: `auto` or `agent`. - `address.agent`: the agent slug for plain gateway calls. For `agent:` calls the certificate is signed by the agent pipeline itself, which records the agent identifier it holds internally rather than the slug, so match certificates to agents through the response's `pulse.agent` field or your own bookkeeping, not by string comparison with the slug. - `address.cortex`: `true` when `+cortex` was on a non-agent call. - `address.knowledge` / `address.memory`: `false` only when `+nocortex` / `+nomemory` switched them off on the OpenAI dialect, so an auditor can see that an answer ran without the knowledge base or without memory. - `grounding`: one entry per passage used, with source name, chunk id, page and SHA-256 of the text. The Anthropic dialect records `kind`, `agent` and `cortex` only. ## Discover addresses with GET /v1/models ### GET /v1/models List the aliases and your workspace agents as addresses. The listing is the discovery surface of the addressing scheme. It always contains `pulse-auto`, `pulse-sensitive` and `pulse-agent`, then the full model currently configured on the installation (for clients that insist on a real model id), then one `agent:` entry per agent visible to the key's workspace, oldest agent first. Because model pickers are filled from this route, your own agents appear in the dropdown of Cursor, Continue or n8n with no integration work. The flags are not listed: they are suffixes you append to any entry. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `engine` #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/models \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/models', { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const { data } = await res.json() const agents = data.filter((m: { id: string }) => m.id.startsWith('agent:')) console.log(agents.map((m: { id: string }) => m.id)) ``` _Python_ ```python import os, requests data = requests.get( "https://api.subsidia.protypa.fr/v1/models", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json()["data"] agents = [m["id"] for m in data if m["id"].startswith("agent:")] print(agents) ``` #### Responses **200**: An OpenAI-style list. Gateway aliases are owned by `pulse-gateway`, agents by `pulse-agents`. ```json { "object": "list", "data": [ { "id": "pulse-auto", "object": "model", "created": 1790000000, "owned_by": "pulse-gateway" }, { "id": "pulse-sensitive", "object": "model", "created": 1790000000, "owned_by": "pulse-gateway" }, { "id": "pulse-agent", "object": "model", "created": 1790000000, "owned_by": "pulse-gateway" }, { "id": "gpt-4o", "object": "model", "created": 1790000000, "owned_by": "pulse-gateway" }, { "id": "agent:demo-cfo", "object": "model", "created": 1790000000, "owned_by": "pulse-agents" } ] } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 401 | | No key, wrong key, revoked or expired key. | | 403 | `scope_denied` | The key does not carry the `engine` scope. | #### Notes The entry after the three aliases is whatever full model the installation is configured with; its id changes when an administrator changes the provider. Do not hard-code it. ## Errors specific to addressing | Status | Code | Cause | What to do | | --- | --- | --- | --- | | 404 | `model_not_found` (OpenAI) or `not_found_error` (Anthropic) | No agent in the key's workspace has that slug. | Call `GET /v1/models` and copy the id. Renaming an agent changes its slug. | | 501 | `address_not_supported` (OpenAI) or `invalid_request_error` (Anthropic) | A `debate:` address. | Use the [Conversations API](https://dev.subsidia.protypa.fr/docs/conversations.md). | | 404 | `conversation_not_found` | The `x-pulse-conversation` header names a conversation that does not exist in this workspace. | Omit the header to start a new conversation. | ## Frequently asked **My tool only lets me pick from a fixed list of model names. Can I still use Subsidia?** If the list is editable or the tool fetches it from `GET /v1/models`, yes. If it is truly fixed, any real model name works as a passthrough (masked, routed, metered, signed), but you lose the capabilities that need an address: agents, `+cortex`, `pulse-sensitive`. **Does an unknown model name cause an error?** No. It is accepted, recorded in the certificate, and routed exactly like `pulse-auto`. The name does not choose the provider or the model, Synapse does. Only the `agent:` and `debate:` prefixes can fail. **Can I combine pulse-sensitive with an agent?** Not in one address: the base is either a `pulse-*` alias or `agent:`. `agent:+sensitive` parses, but the Gateway does not forward the flag to the agent pipeline today. If an agent must work on data that stays local, restrict the installation's providers instead (see [Synapse](https://dev.subsidia.protypa.fr/docs/synapse.md)). **Why did +cortex not change my answer?** Retrieval found no passage for the last user message, so the call ran ungrounded and the certificate has no `grounding`. The flag also requires a workspace on the key; and on `agent:` addresses it is redundant, because agents search their knowledge by default. See [Querying knowledge](https://dev.subsidia.protypa.fr/docs/knowledge-query.md) to test what your question retrieves. **Is the address case-sensitive?** The base is, the flags are not. Write `pulse-auto`, not `Pulse-Auto`. The agent slug is normalised, so `agent:Demo CFO` finds `demo-cfo`. **Can I use the + syntax inside a SDK that rejects model names with a plus sign?** A few UIs validate model ids against a pattern. Add the flagged name to the tool's custom models list, or fetch it from your own code. The plus sign is the only separator, there is no alternative spelling. --- # 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. **Lifecycle of a certificate. It is written to storage before the response completes, so the id you receive can be fetched immediately.** Flow: 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. > **TIP: Streamed equals signed** > The response hash is computed over the full text that was streamed to you, after personal data was restored. What your user saw is what was hashed. There is no separate non-streamed version that the certificate describes. ## 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](https://dev.subsidia.protypa.fr/docs/model-addressing.md). | | `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](https://dev.subsidia.protypa.fr/docs/synapse.md). | | `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. | **A real-shaped certificate (hashes shortened)** ```json { "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": , "messages": }` | 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": , "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](https://dev.subsidia.protypa.fr/docs/synapse.md) 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: 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](https://dev.subsidia.protypa.fr/docs/verify.md), a [register export](https://dev.subsidia.protypa.fr/docs/registre.md), 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. 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. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `engine` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The value of the `x-pulse-proof` header: `pf_` followed by 32 hex characters. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/proofs/pf_a059713a5f1c4e0b8d7a3c21e9b64f10 \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -o certificate.json ``` _TypeScript_ ```typescript const proofId = 'pf_a059713a5f1c4e0b8d7a3c21e9b64f10' // from the x-pulse-proof header const res = await fetch('https://api.subsidia.protypa.fr/v1/proofs/' + proofId, { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) if (res.status === 404) throw new Error('unknown proof, or it belongs to another workspace') const certificate = await res.json() console.log(certificate.chain_index, certificate.payload.egress.provider) ``` _Python_ ```python import os, requests proof_id = "pf_a059713a5f1c4e0b8d7a3c21e9b64f10" # from the x-pulse-proof header res = requests.get( "https://api.subsidia.protypa.fr/v1/proofs/" + proof_id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) res.raise_for_status() certificate = res.json() print(certificate["chain_index"], certificate["payload"]["egress"]["provider"]) ``` #### Responses **200**: The certificate envelope described above. ```json { "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" } ``` **404**: Unknown id, or the certificate belongs to another workspace (`proof_not_found`). ```json { "error": { "message": "proof not found", "type": "invalid_request_error", "code": "proof_not_found", "param": null } } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 401 | | No key, wrong key, revoked or expired key. | | 403 | `scope_denied` | The key does not carry the `engine` scope. | | 404 | `proof_not_found` | Unknown id or another workspace. | ### GET /v1/proofs/:id/verify Verify a certificate on the server. 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. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `engine` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | Certificate id. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/proofs/$PROOF_ID/verify \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/proofs/' + proofId + '/verify', { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const verdict = await res.json() if (!verdict.valid) console.error('Do not rely on this certificate:', verdict.reason) ``` _Python_ ```python import os, requests verdict = requests.get( "https://api.subsidia.protypa.fr/v1/proofs/" + proof_id + "/verify", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json() if not verdict["valid"]: print("Do not rely on this certificate:", verdict["reason"]) ``` #### Responses **200**: `valid` is true only when both the signature and the chain link hold. `reason` is null when valid, otherwise a short explanation. ```json { "id": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10", "signature_valid": true, "chain_valid": true, "valid": true, "reason": null } ``` **404**: Unknown id or another workspace (`proof_not_found`). #### Errors | Status | Code | When | | --- | --- | --- | | 401 | | No key, wrong key, revoked or expired key. | | 403 | `scope_denied` | The key does not carry the `engine` scope. | | 404 | `proof_not_found` | Unknown 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. - **Authentication:** None #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/gateway/public-key ``` _TypeScript_ ```typescript const { public_key } = await fetch('https://api.subsidia.protypa.fr/v1/gateway/public-key').then((r) => r.json()) // public_key is a PEM string: "-----BEGIN PUBLIC KEY-----..." ``` _Python_ ```python import requests public_key_pem = requests.get("https://api.subsidia.protypa.fr/v1/gateway/public-key").json()["public_key"] ``` #### Responses **200**: The key in SPKI PEM format. ```json { "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. **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. **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. **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. **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.js_ ```typescript // 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 ``` _Python_ ```python # pip install cryptography # python verify.py certificate.json public-key.pem import base64 import hashlib import json import sys from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.serialization import load_pem_public_key def canonical_json(value) -> str: # Keys sorted, no whitespace, non-ASCII kept as is (JSON.stringify semantics). return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) def sha256_hex(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() cert_path, key_path = sys.argv[1:3] with open(cert_path, encoding="utf-8") as f: cert = json.load(f) with open(key_path, "rb") as f: public_key = load_pem_public_key(f.read()) # 1. The payload must hash to payload_hash. hash_ok = sha256_hex(canonical_json(cert["payload"])) == cert["payload_hash"] # 2. The signature covers the hex string payload_hash (its UTF-8 bytes). sig_ok = False if hash_ok: try: public_key.verify(base64.b64decode(cert["signature"]), cert["payload_hash"].encode("utf-8")) sig_ok = True except InvalidSignature: sig_ok = False # 3. The envelope fields must agree with the signed payload. payload = cert["payload"] envelope_ok = ( payload["id"] == cert["id"] and payload["chainIndex"] == cert["chain_index"] and payload["prevHash"] == cert["prev_hash"] ) print("payload hash :", "match" if hash_ok else "MISMATCH (document altered)") print("signature :", "valid" if sig_ok else "INVALID") print("envelope :", "consistent" if envelope_ok else "INCONSISTENT") egress = payload["egress"] print("egress :", egress["provider"] + "/" + egress["model"], "| masked:", egress["pii"]["masked"]) sys.exit(0 if (hash_ok and sig_ok and envelope_ok) else 1) ``` _Bind to your request_ ```typescript import crypto from 'node:crypto' // canonicalJson as above const messages = [{ role: 'user', content: 'Summarise the termination clause for Jean Dupont.' }] const requestHash = crypto .createHash('sha256') .update(canonicalJson({ model: cert.payload.request.model, messages }), 'utf8') .digest('hex') console.log(requestHash === cert.payload.request.sha256 ? 'this certificate is about that request' : 'different request') // The answer you received, personal data restored: const answerHash = crypto.createHash('sha256').update(answerText, 'utf8').digest('hex') console.log(answerHash === cert.payload.response.sha256) ``` > **INFO: Checking the chain offline** > A single certificate proves itself. To prove the chain around it you need the certificate whose `chain_index` is one less, then check that its `payload_hash` equals this one's `prev_hash`. Because the chain is shared by the whole installation, you will usually hold only your own certificates and not their neighbours. Use `GET /v1/proofs/:id/verify`, which sees the whole chain. > **TIP: The Gateway repository ships the same verifier** > `proof/verify.mjs` is a standalone script that does steps 2 and 3 above (`node verify.mjs certificate.json public-key.pem`). The end-to-end demos next to it fetch a certificate, recompute its hash, check the signature and the chain link. ## Two 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](https://dev.subsidia.protypa.fr/docs/verify.md). - **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](https://dev.subsidia.protypa.fr/docs/model-addressing.md#where-the-address-shows-up-in-a-certificate). --- # Synapse > Synapse is the engine behind every call: it masks personal data, picks the model by task, meters usage in questions and spreads work over machines. Synapse is the layer every Subsidia call passes through, whether it arrives on the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md), through an [agent](https://dev.subsidia.protypa.fr/docs/agent-chat.md), or from one of the product modules. It makes four decisions on your behalf, and a fifth thing, the certificate, records them: 1. **Is there personal data in this request, and may it leave?** (the PII shield) 2. **Which provider and model should answer?** (routing by task and sensitivity) 3. **What does this cost the workspace?** (metering in questions) 4. **Which machine runs it?** (multi-node scheduling, for installations with several) As an API consumer you mostly never see these decisions, which is the point. This page explains how they work so that you can predict them, steer them with the [model address](https://dev.subsidia.protypa.fr/docs/model-addressing.md), and read the few places where they are exposed. **The path of one call. Masking happens before routing is applied to the text, restoration after the model answers.** Flow: Request -> Address parsing -> PII scan + mask -> Route: provider + model -> Machine (node) -> Answer -> Restore values -> Meter + sign ## Routing Routing turns two inputs, a **task** and a **sensitivity**, into a provider and a model. You do not pick the model by name: whatever you put in `model` is recorded but not obeyed (apart from the address prefixes and flags, which change the task and the sensitivity). **How the task is decided** The task is resolved in this order. The first rule that applies wins. 1. A **sensitive** call (see below) goes to the local lane. 2. An **agent** task goes to the full-quality model. A call is agentic when it uses `pulse-agent` or `+agent`, when the request defines `tools`, when the system prompt is longer than 8 000 characters (agent harnesses ship very long operating instructions), or when tool calls and tool results already appear in the conversation. Small models derail tool loops, so these never go to a small one. 3. A **simple** or **extraction** task goes to the fast tier. 4. A **complex** or **code** task goes to the full tier. 5. Otherwise Synapse **auto-classifies**: JSON mode (`response_format: json_object`) counts as complex, an estimated prompt of 500 tokens or more counts as complex, anything shorter is simple. The estimate is the character count divided by four, the same heuristic `POST /v1/messages/count_tokens` returns. The call classifier is deliberately cheap and deterministic. There is no extra model call on the Gateway path, so routing adds no latency and no cost. | Tier | Chosen for | Model | | --- | --- | --- | | Fast | Short, simple asks and extraction. | The cheapest fast model among the providers allowed for the workspace. If no distinct fast model is configured, the full model is used. | | Full | Complex, code and agent work, JSON mode, long prompts. | The installation's configured full-quality model, or the best of the allowed providers when an administrator has enabled several. | | Local | Sensitive calls and the `local` preference. | The locally hosted model (Ollama). Nothing leaves the machine. | | You send | Resolved task | Tier | | --- | --- | --- | | `pulse-auto`, 20-word question | `simple` | Fast | | `pulse-auto`, 3 000-word contract | `complex` | Full | | `pulse-auto`, `response_format: json_object` | `complex` | Full | | Any model, request with `tools` | `agent` | Full | | `pulse-agent` | `agent` | Full | | `pulse-sensitive`, or `+sensitive` | `sensitive` (on an installation with a local lane) | Local | | `gpt-4o` or any unknown name | As `pulse-auto` | As `pulse-auto` | **Providers and administrator policy** An installation can be configured with several providers (for example a European hosted provider, a US provider and a local model). Two rules bound what Synapse may choose: - **Workspace allow-list.** An administrator can restrict which providers the workspace may use. Synapse never picks outside it, and never silently replaces a refused choice with an external provider: a request whose preference cannot be honoured is refused. - **The local lane is never restricted.** A model that runs on the installation's own machines cannot leak anything, so it is always available. Until an administrator writes an allow-list, routing stays on the installation's active provider. The `x-pulse-provider` header and the certificate tell you which one answered. ### Sensitive calls A call is treated as sensitive in two ways: - **Explicitly**, with `pulse-sensitive` or the `+sensitive` flag. This always wins. - **Automatically**, when the shield finds personal data in the request. This auto-escalation is skipped for agentic traffic (tool loops and harness prompts are full of incidental identifiers such as emails in code, and would otherwise land on a small local model every turn). What "sensitive" does depends on the installation: | Installation | Sensitive call | |---|---| | Local or on-premise (`APP_MODE=local`) | Served by the local model, whatever provider preference was configured. The text reaches the model **unmasked**, since nothing leaves the machine. | | Hosted cloud without a local lane | There is no local machine to route to. The call goes through normal routing, but the shield masks personal data before any external provider sees it. | The certificate records the outcome: `egress.provider` is `ollama` when the call stayed local, and `routing.taskType` is `sensitive`. > **WARNING: Verify, do not assume, for hard data-residency requirements** > If a contract says a category of data must never reach an external provider, check `x-pulse-provider` (or `payload.egress.provider` in the [certificate](https://dev.subsidia.protypa.fr/docs/proofs.md)) in your integration tests against the target installation, and fail the build when it is not local. The address expresses intent; the certificate records what happened. ## PII shield The shield finds personal values in the system prompt and every message, replaces each with a token before the text is sent, and swaps the originals back into the answer before you see it. The model reasons over `[EMAIL_k3j2h]`, you read `jean.dupont@example.com`. **It only masks what leaves** Masking exists to protect data from third parties, so Synapse applies it **only when the request can reach an external provider**. A request that can only land on a local model is sent as is: full fidelity, no tokens, no restoration step. That is why local answers are often better on names and figures. - The shield is a no-op for local providers and when the mode is `off`. - When a call routes to a local model after the scan, the original text is sent, even if PII was found. - The count of masked values is reported as `pii_masked`, and is `0` for a local call. **Modes** The mode is a server setting chosen by the installation administrator (it can be changed from the admin interface, with a deployment-level default). It is not a per-request parameter. | Mode | What it masks | Use it for | | --- | --- | --- | | `standard` (default) | Values with a rigid syntactic shape: `EMAIL`, `PHONE` (and fax), `CREDIT_CARD` (Luhn-validated), `IBAN`, `SSN`, `IP_ADDRESS`, `MAC_ADDRESS`. Free text such as names and dates is left untouched, so ordinary prose is not mangled. | Accounting, legal and general business traffic. | | `medical` | Everything in `standard`, plus HIPAA Safe Harbor identifiers: title-anchored and bare person names (`NAME`), `DATE` (the year is kept), `AGE` for ages 90 and over, `ZIP`, `URL`, record and account numbers (`MRN`), national health identifiers (`NIR` for France, `NHS_NUMBER` for the UK), `VIN`. Clinical content itself (diagnoses, medications) is preserved so the model can still reason about the case. | Health data. | | `off` | Nothing. | Installations that only use local models, or tests. | > **INFO: Medical mode is not the right default for professional firms** > Medical mode strips dates and bare names, which removes exactly the facts an accounting or legal question depends on. Use `standard` unless the traffic is clinical. ### Consistent pseudonymization Tokens are **deterministic per entity**. The suffix is a hash of the normalised value (accents, case and leading titles removed), so: - The same entity gets the same token everywhere: in every turn of a conversation, across separate requests, and across documents. "Dr Jean Dupuis" and "jean dupuis" become the same alias. - Distinct entities stay distinct. Two different people never collapse to one token, which would let a model confuse them. - Restoration works inside a single call from a vault kept for that call only. This is why a model can follow "the second email from `[EMAIL_k3j2h]`" across a long conversation without ever seeing the address. **What the provider receives (illustration)** _You send_ ```text Reply to jean.dupont@example.com and confirm the transfer to FR7630006000011234567890189. Call +33 6 12 34 56 78 if there is any issue. ``` _External provider receives_ ```text Reply to [EMAIL_k3j2h] and confirm the transfer to [IBAN_9f2x1a]. Call [PHONE_1zq8d0] if there is any issue. ``` _You read in the answer_ ```text Dear Mr Dupont, the transfer to FR7630006000011234567890189 is confirmed. (values restored before the answer reaches you) ``` The exact token suffixes depend on the value; do not parse them. The categories that were masked, never the values, are written to the certificate's `egress.pii.categories`. ## What the API tells you Routing decisions are exposed in three places, from lightest to most complete. Everything below is also true of the Anthropic dialect except where noted. | Where | Field | Meaning | | --- | --- | --- | | Response header (non-streamed) | `x-pulse-provider` | The provider that answered: `ollama`, `openai`, `anthropic`, `mistral`... | | Response header (non-streamed) | `x-pulse-pii-masked` | Number of values masked in the request. `0` for a local call. | | Response header | `x-pulse-proof` | The certificate id (also on streamed responses). | | Body, OpenAI dialect | `model` | On a non-streamed response, the model that actually answered, not the string you sent. On streamed chunks, the string you sent. | | Body, OpenAI dialect | `pulse.provider`, `pulse.task`, `pulse.pii_masked` | Provider, resolved task type (`simple`, `complex`, `agent`, `sensitive`...) and masked count. Non-streamed responses. | | Body, Anthropic dialect | `model` | The model that answered. The `pulse` block does not exist in this dialect; use the headers. | | Certificate | `routing.taskType`, `routing.reason` | The resolved task and a sentence such as `task=complex -> configured model (openai/gpt-4o)` or `sensitive=true -> local Ollama (data never leaves machine)`. | | Certificate | `egress.provider`, `egress.model`, `egress.pii` | Who answered and what was masked. | **Reading the routing decision from a call** _curl_ ```bash curl -s -D - -o /dev/null https://api.subsidia.protypa.fr/v1/chat/completions \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"pulse-auto","messages":[{"role":"user","content":"Hi"}]}' \ | grep -i "^x-pulse" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/chat/completions', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'pulse-auto', messages: [{ role: 'user', content: 'Hi' }], }), }) console.log({ provider: res.headers.get('x-pulse-provider'), masked: Number(res.headers.get('x-pulse-pii-masked')), proof: res.headers.get('x-pulse-proof'), }) const body = await res.json() console.log(body.model, body.pulse.task) // model that answered, resolved task ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/chat/completions", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={"model": "pulse-auto", "messages": [{"role": "user", "content": "Hi"}]}, ) print(res.headers["x-pulse-provider"], res.headers["x-pulse-pii-masked"], res.headers["x-pulse-proof"]) body = res.json() print(body["model"], body["pulse"]["task"]) ``` > **TIP: Guard a residency rule in a test** > In a CI job, send a representative prompt to `pulse-sensitive` and assert that `x-pulse-provider` is `ollama`, then fetch the certificate and assert the same on `payload.egress.provider`. The first catches a misconfigured installation, the second proves the evidence matches. ## Metering and cost Customers buy **questions**, and Synapse is where usage becomes questions. For an API consumer this comes down to four facts: - **Preflight check.** Before any model is called, the workspace allowance is checked. An exhausted allowance is refused up front (`402 insufficient_quota` on the OpenAI dialect, `429 rate_limit_error` on the Anthropic dialect), so a refused call consumes nothing. - **Charged on the model that answered.** `pulse-auto` may route one call to a small model and the next to a large one. A larger model counts for more questions per answer, so routing a simple ask to the fast tier is also the cheapest outcome for you. Local models are the lightest class. - **Charged after the fact.** The deduction happens once the completion has been served; an accounting failure is logged on the server and never fails your call. - **Local installations are not metered** against a question allowance. They keep a usage ledger for the dashboard. A key can also carry its own monthly ceiling (`API_KEY_BUDGET_EXCEEDED`, a 402). See [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md). The `usage` object in every response (`prompt_tokens`, `completion_tokens`, `total_tokens`) is the real token count reported by the model, useful for your own cost tracking. It is not a bill. Behind the scenes Synapse also estimates a dollar cost per call from the model's public price (local models cost zero) and compares it with what the configured full model would have cost. Those figures feed the operator dashboard of the installation (cost, saved cost, latency, time to first token); they are not returned by the Gateway. ## Multiple machines On an installation with more than one machine running local models, Synapse spreads calls across them. It is entirely transparent to API clients: there is nothing to configure on your side and no field to set. - **One whole request per machine.** A single request is never split across machines; sharding one model over a local network is slower than running it on one box. - **Least in-flight first.** A new request goes to the healthy machine with the fewest requests already running. - **Failover.** An unhealthy machine is skipped, and a request that fails on one machine is retried on another. - **Discovery is automatic, admission is a decision.** A new machine on the network appears as a candidate and joins routing once an administrator admits it (or a shared cluster key is configured). The visible effect is capacity and resilience: more concurrent calls without queueing, and no outage when one box is down. The `egress.provider` in the certificate is still `ollama`. ## Putting it together 1. **Start with pulse-auto** It gives you masking when needed, a small model for small asks, and the full model for real work. This is the right default for almost everything. 2. **Declare intent with the address when it matters** `pulse-agent` for tool loops, `pulse-sensitive` when the data must stay local, `agent:` when a configured agent should answer. See [Model addressing](https://dev.subsidia.protypa.fr/docs/model-addressing.md). 3. **Observe** Log `x-pulse-proof`, `x-pulse-provider` and `x-pulse-pii-masked` with each call. They cost nothing and answer most support questions. 4. **Keep the evidence** Store the [certificate](https://dev.subsidia.protypa.fr/docs/proofs.md) with the record the answer fed. It states the provider, the masking and the routing reason in a form a third party can verify. ## Frequently asked **Can I choose the model by name?** No. The model name you send is recorded, but Synapse chooses the model from the task and the installation's configuration. What you control is the intent: `pulse-agent` for the full model, `pulse-sensitive` for local, an agent address for an agent. This keeps routing, cost and the data-residency guarantee in one place instead of in every client. **Why is my answer slower or lower quality than usual?** Check `pulse.task` and `x-pulse-provider`. A short prompt resolves to the fast tier, and a call forced local may use a smaller model than the hosted one. Send `pulse-agent` to force the full model for that call, or add context so the call is classified as complex. **The shield masked something that was not personal, or missed something.** Standard mode is built on syntactic patterns and validators (a card number must pass the Luhn check), so it is conservative and does not detect free-text names. If you need names and dates masked, the installation must run in medical mode, or the call must be local. Report false positives with the proof id; the certificate shows the categories involved. **Does masking change my prompt in the certificate?** The certificate hashes your original request (`request.sha256`) and, separately, the masked text that left toward the provider (`egress.sha256`). Both are hashes, so neither shows content. **Are streamed responses masked and restored too?** Yes, in the same way, and the hash in the certificate covers the restored text you received. Streamed responses carry `x-pulse-proof` but not `x-pulse-provider` or `x-pulse-pii-masked`, because the headers are sent before routing completes; read the certificate instead. **Is Synapse a separate API?** No. Synapse is the engine inside the endpoints you already call. Its decisions surface in headers, response fields and certificates, as described above. --- # Completions and data extraction > POST /v1/ai/complete runs a masked, routed, metered completion with optional context blocks, JSON mode and automatic fact extraction. `POST /v1/ai/complete` is the native Subsidia completion endpoint. It goes through the same pipeline as the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md) (PII shield, routing by task complexity, metering against your question allowance) but takes a simpler body, and adds two things the OpenAI dialect has no place for: - **Supplementary context**: structured data blocks (a customer record, a price list) that are rendered into the system prompt in a consistent, prioritised format. - **Automatic extraction**: after the answer, a second light call pulls typed facts out of the user message and stores them, so a chat turns into structured records as a side effect. Use the Gateway when you want drop-in compatibility with an existing SDK. Use this endpoint when you are writing new code and want context injection, JSON output or extraction in one call. > **WARNING: Scope and differences from the Gateway** > Needs the `engine` scope, like the Gateway; the extracted-data routes below too. This endpoint does **not** return an `x-pulse-proof` certificate and has no model addressing (`pulse-auto`, `agent:`, `+cortex`): routing is decided by Synapse from the request. If you need a certificate or knowledge retrieval, use the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md) or [`/v1/knowledge/query`](https://dev.subsidia.protypa.fr/docs/knowledge-query.md). ### POST /v1/ai/complete Run a completion, optionally with context blocks, JSON output and fact extraction. Runs one chat completion. Personal data is masked before an external provider sees it and restored in the answer; detected-sensitive requests are kept on a local model. The call is metered against the workspace allowance of questions. The answer comes back whole (no streaming). For streaming, use the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md). - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `engine` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `systemPrompt` | `string` | yes | | The instructions. Must not be empty. | | `messages` | `object[]` | yes | | The conversation so far. Must be a non-empty array; the last `user` message drives routing and extraction. | | `messages.role` | `string` | yes | | Author of the message. One of: `user`, `assistant`, `system`. | | `messages.content` | `string` | yes | | Text of the message. | | `supplementary` | `object[]` | no | | Structured context appended to the system prompt under a `SUPPLEMENTARY CONTEXT` heading. Items are sorted by priority, high first; each becomes a section titled by its label or category. | | `supplementary.category` | `string` | yes | | Kind of data, for example `customer` or `pricing`. Used as the section title when there is no `label`. | | `supplementary.label` | `string` | no | | Human title of the section. | | `supplementary.data` | `object | string` | yes | | The content. Objects are serialised as indented JSON, strings are inserted as is. | | `supplementary.priority` | `string` | no | `medium` | Ordering of the sections. `high` is flagged as high priority to the model. One of: `high`, `medium`, `low`. | | `extraction` | `object` | no | | Extract typed facts from the last user message, after the answer. Extraction cost is a system cost and is not added to the question count of the call. | | `extraction.source` | `string` | yes | | A label of your choosing that groups the facts (a customer id, a form name). Used for filtering and for deduplication. | | `extraction.categories` | `string[]` | yes | | Allowed categories, non-empty, for example `["contact", "preferences"]`. Facts in any other category are dropped. | | `extraction.persist` | `boolean` | no | `true` | Store the facts so they can be listed later. With `false` they are only returned in `extracted`. | | `json` | `boolean` | no | | Ask for a JSON object as the answer. For models without native JSON mode, an instruction is appended to the system prompt. Still validate what you receive. | | `max_tokens` | `number` | no | | Output ceiling. | | `temperature` | `number` | no | | Sampling temperature. Lower it (0 to 0.2) for extraction and classification. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/ai/complete \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "systemPrompt": "You are a support assistant. Answer briefly.", "messages": [ {"role": "user", "content": "Hi, I am Marie Lambert, please call me on 06 12 34 56 78 after 5pm."} ], "supplementary": [ {"category": "customer", "label": "Account", "priority": "high", "data": {"plan": "Pro", "since": "2024-03"}} ], "extraction": {"source": "ticket-4812", "categories": ["contact", "preferences"]}, "temperature": 0.2 }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/ai/complete', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ systemPrompt: 'You are a support assistant. Answer briefly.', messages: [{ role: 'user', content: 'Hi, I am Marie Lambert, please call me on 06 12 34 56 78 after 5pm.' }], supplementary: [{ category: 'customer', label: 'Account', priority: 'high', data: { plan: 'Pro', since: '2024-03' } }], extraction: { source: 'ticket-4812', categories: ['contact', 'preferences'] }, temperature: 0.2, }), }) if (res.status === 402) throw new Error('Out of questions') const { content, usage, extracted } = await res.json() ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/ai/complete", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "systemPrompt": "You are a support assistant. Answer briefly.", "messages": [ {"role": "user", "content": "Hi, I am Marie Lambert, please call me on 06 12 34 56 78 after 5pm."} ], "supplementary": [ {"category": "customer", "label": "Account", "priority": "high", "data": {"plan": "Pro", "since": "2024-03"}} ], "extraction": {"source": "ticket-4812", "categories": ["contact", "preferences"]}, "temperature": 0.2, }, ) body = res.json() print(body["content"], body["extracted"]) ``` #### Responses **200**: `content` is the answer with personal data restored. `usage` is the token usage of the main call. `extracted` lists the facts found in the user message (empty when `extraction` was not requested, or nothing matched). The body also carries diagnostic fields about routing and latency; do not depend on them. ```json { "content": "Thanks Marie, we will call you after 5pm.", "usage": { "promptTokens": 142, "completionTokens": 14, "totalTokens": 156 }, "extracted": [ { "category": "contact", "key": "phone", "value": "06 12 34 56 78", "confidence": 0.97 }, { "category": "preferences", "key": "call_window", "value": "after 5pm", "confidence": 0.9 } ] } ``` **400**: `systemPrompt` missing, `messages` missing or empty, `extraction.source` missing, or `extraction.categories` empty. ```json { "error": "messages array is required and must not be empty" } ``` **402**: The workspace has no questions left. Nothing was consumed. ```json { "error": "..." } ``` **500**: The model call failed. ```json { "error": "AI completion failed" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | Invalid body, see above. | | 401 | | Missing or invalid key. | | 402 | | Workspace allowance exhausted. | | 402 | `API_KEY_BUDGET_EXCEEDED` | The key reached its own monthly question ceiling. | | 403 | `scope_denied` | The key lacks the `engine` scope. | | 429 | `rate_limited` | More than 30 requests per minute (also the per-key limit when one is set). | | 500 | | Provider or internal failure. | ## Use cases ### JSON output for a pipeline Set `json: true` and describe the shape in `systemPrompt`. Keep the temperature low and parse defensively: a model can still return something that is not the shape you asked for, so validate before you trust it. **Classify an incoming email into a typed object** _TypeScript_ ```typescript type Triage = { category: 'invoice' | 'contract' | 'other'; urgent: boolean; summary: string } async function triage(emailText: string): Promise { const res = await fetch('https://api.subsidia.protypa.fr/v1/ai/complete', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ systemPrompt: 'Classify the email. Reply with a JSON object: ' + '{"category": "invoice" | "contract" | "other", "urgent": boolean, "summary": string (max 20 words)}.', messages: [{ role: 'user', content: emailText }], json: true, temperature: 0, max_tokens: 300, }), }) const { content } = await res.json() try { const parsed = JSON.parse(content) const ok = ['invoice', 'contract', 'other'].includes(parsed.category) && typeof parsed.urgent === 'boolean' return ok ? (parsed as Triage) : null } catch { return null // send to a human instead of guessing } } ``` _Python_ ```python import json, os, requests def triage(email_text): res = requests.post( "https://api.subsidia.protypa.fr/v1/ai/complete", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "systemPrompt": 'Classify the email. Reply with a JSON object: ' '{"category": "invoice" | "contract" | "other", "urgent": boolean, "summary": string (max 20 words)}.', "messages": [{"role": "user", "content": email_text}], "json": True, "temperature": 0, "max_tokens": 300, }, ) try: parsed = json.loads(res.json()["content"]) except (ValueError, KeyError): return None # send to a human instead of guessing if parsed.get("category") not in ("invoice", "contract", "other") or not isinstance(parsed.get("urgent"), bool): return None return parsed ``` ### Fact extraction as a side effect of a chat With `extraction`, each call that includes a customer message also leaves behind structured facts, grouped by your `source` label and one of your `categories`. Facts are upserted on the combination of source, category and key, so a customer who corrects their phone number updates the record rather than adding a second one. Read them back with the routes below. > **INFO: Facts here are not Cortex facts** > Extracted data belongs to the **user** of the key and holds what people say in conversations. The facts of [`/v1/knowledge/facts`](https://dev.subsidia.protypa.fr/docs/knowledge-insights.md) belong to the **workspace** and describe what its documents assert. ## Extracted data ### GET /v1/ai/extracted-data List the facts extracted by your completions. Newest first. Scoped to the user the key belongs to, not to the whole workspace. Filter by `source` to retrieve everything gathered for one customer or one ticket. - **Authentication:** API key - **Scopes:** `engine` #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `source` | `string` | no | | Only facts stored under this `extraction.source`. | | `category` | `string` | no | | Only this category. | | `limit` | `number` | no | `100` | Maximum number of facts, capped at 500. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/v1/ai/extracted-data?source=ticket-4812&category=contact" \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const url = new URL('https://api.subsidia.protypa.fr/v1/ai/extracted-data') url.searchParams.set('source', 'ticket-4812') const { data, count } = await fetch(url, { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }).then((r) => r.json()) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr/v1/ai/extracted-data", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, params={"source": "ticket-4812"}, ).json() print(res["count"], res["data"]) ``` #### Responses **200**: The facts and their number. ```json { "data": [ { "id": "clx9m1zq20003xyz", "source": "ticket-4812", "category": "contact", "key": "phone", "value": "06 12 34 56 78", "confidence": 0.97, "createdAt": "2026-10-09T09:31:10.000Z", "updatedAt": "2026-10-09T09:31:10.000Z" } ], "count": 1 } ``` ### GET /v1/ai/extracted-data/:id Get one extracted fact. - **Authentication:** API key - **Scopes:** `engine` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | Fact id. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/ai/extracted-data/$FACT_ID -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _Python_ ```python import os, requests fact = requests.get( "https://api.subsidia.protypa.fr/v1/ai/extracted-data/" + fact_id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json()["data"] ``` #### Responses **200**: The fact. ```json { "data": { "id": "clx9m1zq20003xyz", "source": "ticket-4812", "category": "contact", "key": "phone", "value": "06 12 34 56 78", "confidence": 0.97 } } ``` **404**: Unknown id, or the fact belongs to another user. ```json { "error": "Not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | No such fact for this user. | ### DELETE /v1/ai/extracted-data/:id Delete one extracted fact. Use it to honour an erasure request about a person whose details you extracted: delete by `source` listing, fact by fact. - **Authentication:** API key - **Scopes:** `engine` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | Fact id. | #### Request examples _curl_ ```bash curl -X DELETE https://api.subsidia.protypa.fr/v1/ai/extracted-data/$FACT_ID -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _Python_ ```python import os, requests headers = {"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]} base = "https://api.subsidia.protypa.fr/v1/ai/extracted-data" for fact in requests.get(base, headers=headers, params={"source": "ticket-4812"}).json()["data"]: requests.delete(base + "/" + fact["id"], headers=headers) ``` #### Responses **204**: Deleted. No body. **404**: No such fact for this user. #### Errors | Status | Code | When | | --- | --- | --- | | 403 | `read_only_key` | The key is read-only. | ## Tips | Goal | Advice | | --- | --- | | Reliable extraction | Keep `categories` short and meaningful; anything outside them is discarded. Use a stable `source` so repeated mentions update the same record. | | Reliable JSON | Describe the exact shape, set `temperature` to 0, validate, and have a fallback path to a person. | | Context from your systems | Put records in `supplementary` rather than pasting them into the user message: they are labelled, ordered by priority and kept apart from the question. | | Personal data | Masking applies to external providers and is restored in `content`. Persisted facts hold the values people gave you (here, a phone number): treat the extracted-data store as personal data and delete on request. | | Grounding in documents | This endpoint does not retrieve from your knowledge base. Retrieve with [`/v1/knowledge/query`](https://dev.subsidia.protypa.fr/docs/knowledge-query.md) and pass the passages in `supplementary`, or use [Light](https://dev.subsidia.protypa.fr/docs/light.md). | --- # Knowledge sources > Ingest text, URLs and files (PDF, Office, email, images) into Cortex, track ingestion, and attach sources to agents. A **knowledge source** is one document in your workspace: a file, a web page or a block of text. When you add one, Cortex parses it, cuts it into passages, embeds them, extracts atomic facts and prepares health-check questions. From then on its passages can be retrieved by [`/v1/knowledge/query`](https://dev.subsidia.protypa.fr/docs/knowledge-query.md), by agents linked to it, and by Gateway calls that carry the `+cortex` flag. Ingestion is asynchronous. The API answers immediately with the source id and a status; you poll the source (or listen to a [webhook](https://dev.subsidia.protypa.fr/docs/webhooks.md)) until it is ready. **Ingestion lifecycle. The `progressStage` field of a source walks through these stages while `status` is `processing`.** Flow: Request (text, url or file) -> Duplicate check -> Parsing (+ OCR) -> Chunks -> Contextualizing -> Embedding -> Facts + contradictions -> Health QA -> ready ## Concepts | Field | Values | Meaning | | --- | --- | --- | | `status` | `processing`, `ready`, `failed` | Lifecycle of the ingestion. Only `ready` sources are searchable. A failed source keeps the reason in `error`. | | `progressStage` | `parsing`, `chunks`, `contextualizing`, `embedding`, `facts`, `qa`, `done` | Where a `processing` source currently is. `progressPercent` (0-100) and `etaSeconds` accompany it. | | `sourceType` | `text`, `file`, `url` | How the source entered the workspace. | | `collectionId` | string or null | The collection (a "client file" or dossier in the app) the source is filed under. Retrieval results echo it back as `collectionId` and `dossierName`. | > **INFO: Identical content is ingested once** > Every text or file is hashed (SHA-256 of the raw bytes). If the workspace already holds a non-failed source with the same hash, the API returns `status: "duplicate"` with the `id` of the existing source and `duplicateOf` set to its name, and nothing is re-ingested. A `failed` source does not block a retry. URL sources are exempt, because their content is only known after the fetch. > **INFO: Ingestion consumes no questions** > Ingestion is background work and does not count against your question allowance. What your plan limits is the **volume of documents**: when the workspace is full the request fails with `402` and `code: "DOCUMENT_LIMIT"`. See [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md). ## Supported formats | Format | Extensions | Notes | | --- | --- | --- | | PDF | `pdf` | Text layer first. A scanned PDF falls back to OCR (French and English by default, 60 pages by default on the server). Passages carry a `page` number when known. | | Word | `docx` | Headings are kept as sections. | | Spreadsheets | `xlsx`, `xlsm`, `csv`, `fec` | Tables are linearised into readable rows, capped at 5000 rows per sheet (a truncation is stated in the text). Legacy `.xls` is refused with a message asking for `.xlsx` or `.csv`. | | Presentations | `pptx` | Slide text. | | Email | `eml`, `msg` | Headers and body. | | Images | `png`, `jpg`, `jpeg`, `webp`, `bmp` | Read by OCR. | | Web and data | `html`, `htm`, `xml`, `json` | HTML is converted to Markdown. | | Text | `md`, `markdown`, `txt`, `text` | Used as is. | Any other type is rejected at parsing time: the source ends in `failed` with the message `Unsupported file type ...`. Passages are cut on the document structure (headings, paragraphs, table rows) and never exceed 2000 characters. ## Limits | Limit | Value | | --- | --- | | File size (`/upload`) | 25 MB. Larger files return `413`. | | Request rate | 30 requests per minute for `POST /v1/knowledge-sources` and `POST /v1/knowledge-sources/upload`; 429 beyond that. | | URL ingestion | Public `http(s)` addresses only. Private, local and non-http addresses are refused with `400`. | | Documents per workspace | Set by the plan. `402 DOCUMENT_LIMIT` when reached. | ## Endpoints > **WARNING: Scopes** > The source endpoints need the `knowledge` scope on a restricted key. Linking a source to an agent goes through `/v1/agents/...` and needs the `agents` scope. A missing scope is a `403 scope_denied`. See [Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md). ### POST /v1/knowledge-sources Ingest raw text or a public URL. Creates a source from text you send or from a page Cortex fetches. Send **either** `content` **or** `url`. The response comes back at once with `status: "processing"`; poll `GET /v1/knowledge-sources/:id` until `ready`. Use this route when you already hold the text (a CRM note, an export). For files, use the upload route: you do not need to extract the text yourself, and confidential documents never have to be published to a URL. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `knowledge` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | `string` | no | | Display name. Required with `content`; defaults to the URL for URL ingestion. | | `description` | `string` | no | | Free description. | | `content` | `string` | no | | Raw text to ingest. Must not be empty. | | `url` | `string` | no | | Public http(s) page or document to fetch and ingest. Alternative to `content`. | | `agentIds` | `string[]` | no | | Agent ids to link the source to at creation, so those agents can retrieve from it. | | `collectionId` | `string` | no | | Collection to file the source under. | | `contextualize` | `boolean` | no | `true` | Generate a one-line context for each passage before embedding. Improves retrieval, costs ingestion time. Set `false` to skip. | | `extractFacts` | `boolean` | no | `true` | Extract atomic facts and detect contradictions with other sources. See [Facts, contradictions and health](https://dev.subsidia.protypa.fr/docs/knowledge-insights.md). | | `generateQa` | `boolean` | no | `true` | Generate question/answer pairs used by the health replay. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/knowledge-sources \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Termination policy", "content": "Either party may terminate the agreement with 30 days written notice." }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/knowledge-sources', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Termination policy', content: 'Either party may terminate the agreement with 30 days written notice.', }), }) const { id, status } = await res.json() // status: "processing" ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/knowledge-sources", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "name": "Termination policy", "content": "Either party may terminate the agreement with 30 days written notice.", }, ) source = res.json() # {"id": "...", "status": "processing"} ``` #### Responses **201**: The source was created and is being processed. A duplicate returns `status: "duplicate"` and `duplicateOf`. ```json { "id": "clx9k2m4p0001abcd", "status": "processing" } ``` **400**: Neither `content` nor `url`; `content` without `name`; a URL that is invalid, private or not http(s). ```json { "error": "content or url is required" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | Missing `content`/`url`, missing `name` with `content`, or an unsafe URL. | | 401 | | Missing or invalid key. | | 402 | `DOCUMENT_LIMIT` | The workspace reached the document volume of its plan. | | 403 | `scope_denied` | The key lacks the `knowledge` scope, or is read-only (`read_only_key`). | | 429 | `rate_limited` | More than 30 requests per minute. | | 500 | | Unexpected failure while creating the source. | ### POST /v1/knowledge-sources/upload Upload a file (multipart) and ingest it. Sends the file itself, as `multipart/form-data`. Cortex parses it server-side (OCR included), so a script can push PDFs, Word and Excel files, emails and scans without any text extraction on your side. The `file` part carries the document; every other part is an optional text field. Keep field values as plain strings (JSON text for `agentIds` and `metadata`). - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `knowledge` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `file` | `file` | yes | | The document. 25 MB maximum. See the formats table above. | | `name` | `string` | no | | Display name. Defaults to the file name. | | `description` | `string` | no | | Free description. | | `collectionId` | `string` | no | | Existing collection to file the source under. An unknown id is a `400 Collection not found`. | | `collectionName` | `string` | no | | Collection by name. Created if missing, reused if it already exists. Ignored when `collectionId` is set. | | `metadata` | `string (JSON object)` | no | | Provenance you want stored with the source, for example `{"origin":"nightly-sync"}`. Arrays and malformed JSON are ignored. | | `agentIds` | `string (JSON array)` | no | | Agent ids to link, for example `["agent_1","agent_2"]`. Malformed JSON is ignored. | | `contextualize` | `string` | no | | Send `"false"` to skip the per-passage context generation. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/knowledge-sources/upload \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -F "file=@./contrat-bail-2025.pdf" \ -F "collectionName=Dupont SARL" \ -F 'metadata={"origin":"nightly-sync"}' ``` _TypeScript_ ```typescript import { readFile } from 'node:fs/promises' const form = new FormData() form.append('file', new Blob([await readFile('./contrat-bail-2025.pdf')], { type: 'application/pdf' }), 'contrat-bail-2025.pdf') form.append('collectionName', 'Dupont SARL') form.append('metadata', JSON.stringify({ origin: 'nightly-sync' })) const res = await fetch('https://api.subsidia.protypa.fr/v1/knowledge-sources/upload', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, // no Content-Type: fetch sets the boundary body: form, }) const source = await res.json() // { id, status } ``` _Python_ ```python import os, requests with open("contrat-bail-2025.pdf", "rb") as f: res = requests.post( "https://api.subsidia.protypa.fr/v1/knowledge-sources/upload", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, files={"file": ("contrat-bail-2025.pdf", f, "application/pdf")}, data={"collectionName": "Dupont SARL", "metadata": '{"origin": "nightly-sync"}'}, ) print(res.json()) # {"id": "...", "status": "processing"} ``` #### Responses **201**: Accepted. `status` is `processing`, or `duplicate` (with `duplicateOf`) when the same bytes are already in the workspace. ```json { "id": "clx9k2m4p0002abcd", "status": "processing" } ``` **400**: No `file` part, or `collectionId` does not exist in this workspace. **413**: File larger than 25 MB. ```json { "error": "File too large (25 MB max)" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | No file part, or unknown `collectionId`. | | 402 | `DOCUMENT_LIMIT` | Document volume of the plan reached. | | 413 | | File over 25 MB. | | 429 | `rate_limited` | More than 30 requests per minute. | #### Notes An unsupported file type is **not** rejected at upload: the request succeeds and the source ends in `failed` with the parsing error. Check the source after processing. ### GET /v1/knowledge-sources List the sources of the workspace. Newest first. Each entry carries its ingestion state, `_count.chunks` (number of passages) and the agents it is linked to (`agents: [{ id, name }]`). Not paginated. - **Authentication:** API key - **Scopes:** `knowledge` #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/knowledge-sources -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const { sources } = await fetch('https://api.subsidia.protypa.fr/v1/knowledge-sources', { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }).then((r) => r.json()) const failed = sources.filter((s: any) => s.status === 'failed') ``` _Python_ ```python import os, requests sources = requests.get( "https://api.subsidia.protypa.fr/v1/knowledge-sources", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json()["sources"] failed = [s for s in sources if s["status"] == "failed"] ``` #### Responses **200**: The list. ```json { "sources": [ { "id": "clx9k2m4p0002abcd", "name": "contrat-bail-2025.pdf", "sourceType": "file", "fileName": "contrat-bail-2025.pdf", "mimeType": "application/pdf", "status": "ready", "error": null, "collectionId": "col_123", "progressStage": "done", "progressPercent": 100, "createdAt": "2026-10-09T08:12:44.000Z", "_count": { "chunks": 42 }, "agents": [{ "id": "agent_1", "name": "Accueil" }] } ] } ``` ### GET /v1/knowledge-sources/:id Get one source and its ingestion state. Poll this route after an ingestion call. `status` moves from `processing` to `ready` or `failed`; while it is `processing`, `progressStage` and `progressPercent` tell you how far it is. On `failed`, `error` explains why. - **Authentication:** API key - **Scopes:** `knowledge` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | Source id. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/knowledge-sources/$SOURCE_ID -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript async function waitUntilReady(id: string) { for (;;) { const { source } = await fetch('https://api.subsidia.protypa.fr/v1/knowledge-sources/' + id, { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }).then((r) => r.json()) if (source.status !== 'processing') return source await new Promise((r) => setTimeout(r, 3000)) } } ``` _Python_ ```python import os, time, requests def wait_until_ready(source_id): while True: source = requests.get( "https://api.subsidia.protypa.fr/v1/knowledge-sources/" + source_id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json()["source"] if source["status"] != "processing": return source time.sleep(3) ``` #### Responses **200**: The source. ```json { "source": { "id": "clx9k2m4p0002abcd", "name": "contrat-bail-2025.pdf", "status": "processing", "progressStage": "embedding", "progressPercent": 62, "etaSeconds": 18, "error": null, "agents": [], "_count": { "chunks": 42 } } } ``` **404**: Unknown id, or the source belongs to another workspace. ```json { "error": "Knowledge source not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | No such source in this workspace. | ### DELETE /v1/knowledge-sources/:id Delete a source. Removes the source from the workspace so its passages can no longer be retrieved. Certificates of answers that already cited it are unaffected. Irreversible. - **Authentication:** API key - **Scopes:** `knowledge` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | Source id. | #### Request examples _curl_ ```bash curl -X DELETE https://api.subsidia.protypa.fr/v1/knowledge-sources/$SOURCE_ID -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _Python_ ```python import os, requests res = requests.delete( "https://api.subsidia.protypa.fr/v1/knowledge-sources/" + source_id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) assert res.status_code == 204 ``` #### Responses **204**: Deleted. No body. **404**: Unknown id. ```json { "error": "Knowledge source not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 403 | `read_only_key` | The key is read-only. | ## Attach a source to an agent An [agent](https://dev.subsidia.protypa.fr/docs/agents.md) answers from the sources linked to it. Link at creation with `agentIds`, or afterwards with the two routes below. Linking is many-to-many: one source can serve several agents, and unlinking never deletes the source. Both routes use the `agents` scope. ### POST /v1/agents/:id/knowledge-sources/:sourceId Link a source to an agent. Linking twice is harmless. Returns the agent with the list of its linked sources. - **Authentication:** API key - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | Agent id. | | `sourceId` | `string` | yes | | Source id. | #### Request examples _curl_ ```bash curl -X POST https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/knowledge-sources/$SOURCE_ID \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const { agent } = await fetch( 'https://api.subsidia.protypa.fr/v1/agents/' + agentId + '/knowledge-sources/' + sourceId, { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY } }, ).then((r) => r.json()) console.log(agent.knowledgeSources) ``` _Python_ ```python import os, requests agent = requests.post( "https://api.subsidia.protypa.fr/v1/agents/" + agent_id + "/knowledge-sources/" + source_id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json()["agent"] ``` #### Responses **200**: The updated agent. ```json { "agent": { "id": "agent_1", "name": "Accueil", "knowledgeSources": [{ "id": "clx9k2m4p0002abcd", "name": "contrat-bail-2025.pdf" }] } } ``` **404**: `Agent not found` or `Knowledge source not found` in this workspace. #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | The agent or the source does not exist in this workspace. | ### DELETE /v1/agents/:id/knowledge-sources/:sourceId Unlink a source from an agent. The agent stops retrieving from the source. The source stays in the workspace. - **Authentication:** API key - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | Agent id. | | `sourceId` | `string` | yes | | Source id. | #### Request examples _curl_ ```bash curl -X DELETE https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/knowledge-sources/$SOURCE_ID \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _Python_ ```python import os, requests requests.delete( "https://api.subsidia.protypa.fr/v1/agents/" + agent_id + "/knowledge-sources/" + source_id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) ``` #### Responses **204**: Unlinked. No body. **404**: `Agent not found`. ## Know when a source is ready Polling works for scripts. For pipelines, subscribe to the `source.ingested` webhook (payload: `sourceId`, `name`, `chunkCount`, `factCount`, `contradictionCount`, `qaCount`) and `source.failed` (payload: `sourceId`, `name`, `error`). See [Webhooks](https://dev.subsidia.protypa.fr/docs/webhooks.md). ## Frequently asked **Can I update a document?** Not in place. Delete the old source and upload the new version; a changed file has a different hash, so it is ingested as new content. **A source stays in `processing` for a long time.** `progressStage` and `etaSeconds` show the current step. Large PDFs, scans (OCR) and contextualization take time, especially on a small local machine. If the server restarts mid-ingestion, sources that got past parsing resume where they stopped; the others are marked `failed` with a clear message and can be uploaded again. **Does ingestion send my documents to an external model?** Ingestion uses the model configured for the workspace. With a local model nothing leaves the machine; with an external provider the PII shield applies as for any other call. See [Synapse](https://dev.subsidia.protypa.fr/docs/synapse.md). --- # Querying the knowledge base > POST /v1/knowledge/query runs hybrid retrieval with reranking and returns cited passages plus a trace of why they matched. `POST /v1/knowledge/query` returns the passages of your documents that best answer a question. It does **not** write an answer: you get the evidence and decide what to do with it, which is what you want when you build your own grounded prompt, a citation panel or a verification step. For a ready-made answer with citations or an honest refusal, see [Light](https://dev.subsidia.protypa.fr/docs/light.md). For retrieval inside a normal chat call, add `+cortex` to the model address in the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md). ## The retrieval pipeline **Every query goes through the same stages. Query expansion and reranking use the workspace model and can be switched off per call.** Flow: Question -> Query expansion -> Vector search + full-text search -> RRF fusion -> Rerank (guarded) -> topK passages + trace 1. **Expand** A compound question ("What is the notice period and who signs?") is decomposed into sub-queries, so each part can find its own passages. Disable with `expand: false`. 2. **Search twice per sub-query** A semantic (embedding) search and a full-text search run in parallel. Meaning finds paraphrases; the full-text channel finds exact terms, numbers and names that embeddings blur. A passage found by both ranks higher. 3. **Fuse** The rankings are merged with reciprocal rank fusion (RRF), which rewards agreement between channels without needing comparable scores. 4. **Rerank, with guards** A small pool of the best candidates is re-scored by the model for relevance. If the judge finds nothing relevant, or contradicts the consensus ranking wildly, its verdict is discarded and a deterministic order is used (passages sharing words with the question first). A bad judge therefore cannot corrupt the result. If the pool is no larger than `topK`, reranking is skipped since it could not change anything. 5. **Return** The `topK` best passages come back with their source, section, page and scores, and a `trace` that tells you what happened. > **INFO: What the query respects** > Results are always limited to your workspace. Collections that a workspace administrator has closed to you are excluded from the search itself, not filtered afterwards. ### POST /v1/knowledge/query Retrieve the passages that best match a question. Returns up to `topK` passages from the sources of the workspace, best first, and a retrieval trace. An empty `matches` array is a normal answer meaning the documents contain nothing relevant; treat it as an honest "not found" and do not let a model fill the gap. By default the whole workspace is searched. Pass `agentId` to restrict the search to the sources linked to one agent (see [Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md)). - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `knowledge` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `query` | `string` | yes | | The question or search phrase. Must not be empty. A full question works better than keywords. | | `topK` | `number` | no | `5` | How many passages to return, 1 to 20. | | `agentId` | `string` | no | | Restrict retrieval to the sources linked to this agent. | | `expand` | `boolean` | no | `true` | Decompose compound questions into sub-queries. Set `false` for short, single-fact lookups to save a model call and latency. | | `rerank` | `boolean` | no | `true` | Let the model re-score the fused candidates. Set `false` for the fastest, cheapest path (embeddings and full-text only). | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/knowledge/query \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"query": "What is the notice period to terminate the lease?", "topK": 5}' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/knowledge/query', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ query: 'What is the notice period to terminate the lease?', topK: 5 }), }) const { matches, trace } = await res.json() for (const m of matches) console.log(m.sourceName, m.page, m.content.slice(0, 80)) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/knowledge/query", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={"query": "What is the notice period to terminate the lease?", "topK": 5}, ) data = res.json() for m in data["matches"]: print(m["sourceName"], m["page"], m["content"][:80]) ``` #### Responses **200**: The matches and the trace. `matches` may be empty. ```json { "matches": [ { "sourceId": "clx9k2m4p0002abcd", "sourceName": "contrat-bail-2025.pdf", "collectionId": "col_123", "dossierName": "Dupont SARL", "chunkId": "ck_8f31a0c2", "section": "Article 12 - Termination", "page": 6, "content": "The tenant may terminate the lease at the end of each three-year period by giving six months' notice by registered letter.", "similarity": 0.81, "score": 0.0328, "matchedBy": ["vector", "fts"], "rerankScore": 9 } ], "trace": { "queries": ["What is the notice period to terminate the lease?"], "vectorBackend": "pgvector", "candidateCount": 14, "rerankApplied": true, "timings": { "expandMs": 0, "searchMs": 41, "rerankMs": 612 }, "tokensUsed": 380 } } ``` **400**: Missing or empty `query`, or a `topK` outside 1-20 (schema validation). #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | `query` missing or empty, `topK` out of range. | | 401 | | Missing or invalid key. | | 403 | `scope_denied` | The key lacks the `knowledge` scope. | | 429 | `rate_limited` | More than 60 requests per minute. | #### Notes `page` is `null` when the source has no pages (text, Word, spreadsheets). `rerankScore` is `null` when reranking did not apply. `trace.tokensUsed` is the internal cost of expansion and reranking; it is not a question count. ## Reading the response | Field | Meaning | | --- | --- | | `matches[].content` | The passage text, at most about 2000 characters. Quote it, do not paraphrase it, when you cite. | | `matches[].sourceId`, `sourceName` | The document it comes from. Use `sourceId` to link back to [`GET /v1/knowledge-sources/:id`](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md). | | `matches[].section`, `page` | Where in the document: nearest heading, and 1-based page for PDFs and scans. | | `matches[].collectionId`, `dossierName` | The collection (client file) of the source, or `null` when unfiled. | | `matches[].chunkId` | Stable id of the passage. This is what [proof certificates](https://dev.subsidia.protypa.fr/docs/proofs.md) commit to. | | `matches[].similarity` | Best cosine similarity of the passage, 0 to 1. A raw signal, not a probability: do not set a universal threshold on it. | | `matches[].score` | The fused RRF score. Only meaningful for comparing passages inside this response. | | `matches[].matchedBy` | `vector`, `fts`, or both. A passage matched by both is usually the safest evidence. | | `matches[].rerankScore` | The model relevance score when reranking applied, else `null`. | | `trace.queries` | The sub-queries actually searched (one when expansion is off or the question is simple). | | `trace.vectorBackend` | `pgvector` or `memory`; the engine that served the semantic search. | | `trace.candidateCount` | Number of candidates before the final cut. | | `trace.rerankApplied` | Whether the model order was used. `false` also covers the case where its verdict was discarded by the guards. | | `trace.timings` | Milliseconds spent expanding, searching and reranking. | ## Build a grounded answer The pattern: retrieve, number the passages, instruct the model to answer only from them and to say so when they do not cover the question, then map the `[n]` markers in the answer back to sources. This is the recipe Light applies server-side. If you send the final call through the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md), you get a signed certificate for it. If you would rather not assemble the prompt at all, use `model: "pulse-auto+cortex"` and the Gateway does the retrieval and records the passages in the certificate. **Retrieve, then answer strictly from the passages** _TypeScript_ ```typescript const API = 'https://api.subsidia.protypa.fr' const headers = { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', } async function answer(question: string) { const { matches } = await fetch(API + '/v1/knowledge/query', { method: 'POST', headers, body: JSON.stringify({ query: question, topK: 6 }), }).then((r) => r.json()) if (matches.length === 0) return { answer: 'The documents do not cover this.', sources: [] } const passages = matches .map((m: any, i: number) => '[' + (i + 1) + '] ' + m.sourceName + (m.page ? ' p.' + m.page : '') + '\n' + m.content) .join('\n\n') const res = await fetch(API + '/v1/chat/completions', { method: 'POST', headers, body: JSON.stringify({ model: 'pulse-auto', messages: [ { role: 'system', content: 'Answer using ONLY the numbered passages below. Cite each claim as [n]. ' + 'If the passages do not contain the answer, reply exactly: The documents do not cover this.\n\n' + passages, }, { role: 'user', content: question }, ], }), }) const proofId = res.headers.get('x-pulse-proof') const completion = await res.json() return { answer: completion.choices[0].message.content, sources: matches, proofId } } ``` _Python_ ```python import os, requests API = "https://api.subsidia.protypa.fr" HEADERS = {"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]} def answer(question): matches = requests.post( API + "/v1/knowledge/query", headers=HEADERS, json={"query": question, "topK": 6} ).json()["matches"] if not matches: return {"answer": "The documents do not cover this.", "sources": []} passages = "\n\n".join( "[%d] %s%s\n%s" % (i + 1, m["sourceName"], " p.%s" % m["page"] if m["page"] else "", m["content"]) for i, m in enumerate(matches) ) res = requests.post( API + "/v1/chat/completions", headers=HEADERS, json={ "model": "pulse-auto", "messages": [ { "role": "system", "content": "Answer using ONLY the numbered passages below. Cite each claim as [n]. " "If the passages do not contain the answer, reply exactly: The documents do not cover this.\n\n" + passages, }, {"role": "user", "content": question}, ], }, ) return { "answer": res.json()["choices"][0]["message"]["content"], "sources": matches, "proof": res.headers.get("x-pulse-proof"), } ``` > **TIP: Check the citations** > A model can cite a passage number that does not exist, or attach a claim to the wrong passage. Reject any `[n]` outside `1..matches.length`, and run the final text through [Le Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md) when the answer matters: it gives every claim a verdict against the same documents, with no model involved. ## Tips | Situation | Do | | --- | --- | | Single-fact lookup (a date, an amount, a name) | `expand: false` and `rerank: false` for the lowest latency. The full-text channel handles exact values well. | | Compound or vague question | Keep `expand: true`; ask the whole question rather than keywords. | | Lists ("all deadlines", "every client") | Raise `topK` (10 to 20). The query detects enumeration questions and widens its candidate pool, and tries to surface the table that lists the answer. | | One client or one team | Link the sources to an agent and pass its `agentId`, or file them in a collection and use [Light](https://dev.subsidia.protypa.fr/docs/light.md) with `dossierId`. | | Empty `matches` | Say so to the user. Do not fall back to a model answer without telling them it is not from their documents. | | Fresh uploads not found | Check the source is `ready`. Passages of a `processing` source are not searchable yet. | ## Frequently asked **Does this endpoint consume questions?** It returns passages, not an answer. Expansion and reranking use the workspace model internally (see `trace.tokensUsed`); setting `expand: false` and `rerank: false` means no generative model is called (only the embedding of your question). **Is there a similarity threshold to drop weak matches?** Not in the API. `similarity` depends on the language and the embedding model, so a fixed threshold is fragile. Prefer the combination of `matchedBy` containing both channels and a high `rerankScore`, or let [Light](https://dev.subsidia.protypa.fr/docs/light.md) decide with its strict grounded prompt. **How do I cite a page?** Use `sourceName`, `page` (when not null) and `section`. Link `sourceId` back to your own record of the document. --- # Facts, contradictions and health > Read the atomic facts Cortex extracted, review conflicts between documents, and track a health score for the knowledge base. Search finds passages. Once a knowledge base holds hundreds of documents, three other questions matter: *what exactly do my documents assert*, *do two of them disagree*, and *can I still trust what comes out of this corpus*. Cortex answers them at ingestion time and exposes the results here, so a workflow can act on them instead of discovering a bad answer in front of a client. **Where the three objects come from. Facts and QA pairs are produced during ingestion; contradictions are found by comparing new facts with facts from other sources.** Flow: Source ingested -> Atomic facts (with provenance) -> Compared with other sources -> Contradiction (review queue) -> Resolved by a person -> Health score > **WARNING: Scope and flags** > These routes need the `knowledge` scope. Facts and contradictions exist only for sources ingested with `extractFacts` enabled (the default), and the health replay only has material when `generateQa` was enabled. See [Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md). Resolving a contradiction changes data, so a read-only key gets `403 read_only_key`. ## Facts A **fact** is one atomic claim taken verbatim from a document (for example, the lease runs until 30 September 2028), with the source and typed entities it mentions (people, companies, dates...). Each fact has a status: | Status | Meaning | |---|---| | `active` | Current and not in conflict. | | `disputed` | Part of an open contradiction with a fact from another source. | | `stale` | Its validity window (`validUntil`) has passed. An hourly sweep moves expired facts here and fires the `knowledge.stale` webhook. | | `retired` | Set aside when a contradiction was resolved in favour of the other fact. | ### GET /v1/knowledge/facts List extracted facts with provenance. Newest first, at most 200 per call, with no pagination: narrow with the filters instead of fetching everything. Use it to build a structured view of a client file, to feed a downstream system with sourced values, or to find facts that went stale. - **Authentication:** API key - **Scopes:** `knowledge` #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `status` | `string` | no | | Only facts in this status. One of: `active`, `disputed`, `stale`, `retired`. | | `sourceId` | `string` | no | | Only facts extracted from this source. | | `q` | `string` | no | | Case-insensitive substring match on the fact statement. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/v1/knowledge/facts?status=stale&q=lease" \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const url = new URL('https://api.subsidia.protypa.fr/v1/knowledge/facts') url.searchParams.set('status', 'stale') const { facts } = await fetch(url, { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }).then((r) => r.json()) for (const f of facts) console.log(f.statement, f.validUntil, f.source.name) ``` _Python_ ```python import os, requests facts = requests.get( "https://api.subsidia.protypa.fr/v1/knowledge/facts", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, params={"status": "stale"}, ).json()["facts"] ``` #### Responses **200**: The facts. ```json { "facts": [ { "id": "fct_41ac9e", "statement": "The commercial lease runs until 30 September 2028.", "confidence": 0.92, "status": "active", "validFrom": "2025-10-01T00:00:00.000Z", "validUntil": "2028-09-30T00:00:00.000Z", "createdAt": "2026-10-09T08:14:02.000Z", "source": { "id": "clx9k2m4p0002abcd", "name": "contrat-bail-2025.pdf" }, "entities": [{ "id": "ent_77", "name": "Dupont SARL", "type": "organization" }] } ] } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 401 | | Missing or invalid key. | | 403 | `scope_denied` | The key lacks the `knowledge` scope. | #### Notes `validFrom` and `validUntil` are `null` when the document states no validity window. `confidence` is the extractor self-assessment, 0 to 1. ## Contradictions When new facts arrive, each is compared with the active facts of **other** sources (conflicts inside a single document are mostly extraction noise and are ignored). Candidate pairs are proposed by embedding similarity, then judged by a model, which gives a one-line explanation and a severity. A confirmed conflict becomes a contradiction in the review queue, both facts become `disputed`, and the `knowledge.contradiction_detected` webhook fires. The judge fails open: if it is unavailable, no contradiction is recorded rather than a doubtful one. ### GET /v1/knowledge/contradictions List contradictions between documents. Newest first, at most 100. Use `?status=open` as your review queue: each entry shows the two facts side by side with the document each comes from, so a reviewer can decide in one look. - **Authentication:** API key - **Scopes:** `knowledge` #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `status` | `string` | no | | Only contradictions in this state. Omit for all. One of: `open`, `resolved`, `dismissed`. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/v1/knowledge/contradictions?status=open" \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const { contradictions } = await fetch( 'https://api.subsidia.protypa.fr/v1/knowledge/contradictions?status=open', { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY } }, ).then((r) => r.json()) for (const c of contradictions) { console.log(c.severity, c.factA.source.name, 'vs', c.factB.source.name, '-', c.explanation) } ``` _Python_ ```python import os, requests open_items = requests.get( "https://api.subsidia.protypa.fr/v1/knowledge/contradictions", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, params={"status": "open"}, ).json()["contradictions"] ``` #### Responses **200**: The contradictions. ```json { "contradictions": [ { "id": "ctr_5d20", "explanation": "The two documents give different notice periods for terminating the lease.", "severity": "high", "status": "open", "resolution": null, "createdAt": "2026-10-09T08:14:09.000Z", "resolvedAt": null, "factAId": "fct_41ac9e", "factBId": "fct_9b13d0", "factA": { "id": "fct_41ac9e", "statement": "Notice period: six months.", "status": "disputed", "source": { "id": "clx9k2m4p0002abcd", "name": "contrat-bail-2025.pdf" } }, "factB": { "id": "fct_9b13d0", "statement": "Notice period: three months.", "status": "disputed", "source": { "id": "clx9k2m4p0007efgh", "name": "avenant-2026.docx" } } } ] } ``` #### Notes `severity` is `low`, `medium` or `high`. ### POST /v1/knowledge/contradictions/:id/resolve Record the decision on an open contradiction. Resolution is a human decision, so the API only records it and updates the two facts accordingly. Only **open** contradictions can be resolved: resolving twice returns 404. | `action` | Fact A | Fact B | Contradiction becomes | |---|---|---|---| | `keep_a` | `active` | `retired` | `resolved` | | `keep_b` | `retired` | `active` | `resolved` | | `both` | `active` | `active` | `resolved` (both are true, for example different periods) | | `dismiss` | unchanged | unchanged | `dismissed` (false alarm) | The `resolution` field stores the action, followed by your note when you give one (500 characters kept). - **Authentication:** API key - **Scopes:** `knowledge` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | Contradiction id. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `action` | `string` | yes | | The decision. One of: `keep_a`, `keep_b`, `both`, `dismiss`. | | `note` | `string` | no | | Why, for the record. Appended to `resolution`. | #### Request examples _curl_ ```bash curl -X POST https://api.subsidia.protypa.fr/v1/knowledge/contradictions/ctr_5d20/resolve \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"action": "keep_b", "note": "The 2026 amendment supersedes the lease."}' ``` _TypeScript_ ```typescript const { contradiction } = await fetch( 'https://api.subsidia.protypa.fr/v1/knowledge/contradictions/' + id + '/resolve', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'keep_b', note: 'The 2026 amendment supersedes the lease.' }), }, ).then((r) => r.json()) ``` _Python_ ```python import os, requests contradiction = requests.post( "https://api.subsidia.protypa.fr/v1/knowledge/contradictions/" + contradiction_id + "/resolve", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={"action": "keep_b", "note": "The 2026 amendment supersedes the lease."}, ).json()["contradiction"] ``` #### Responses **200**: The updated contradiction. ```json { "contradiction": { "id": "ctr_5d20", "status": "resolved", "resolution": "keep_b: The 2026 amendment supersedes the lease.", "resolvedAt": "2026-10-09T09:02:31.000Z" } } ``` **400**: `action` missing or not one of the four values. **404**: No **open** contradiction with this id in the workspace (unknown, already resolved, or another workspace). ```json { "error": "Open contradiction not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | Invalid or missing `action`. | | 403 | `read_only_key` | The key is read-only. | | 404 | | Not found or not open. | #### Notes Resolving does not delete either source. Retired facts stay readable with `status=retired`. ## Health The health score answers a question every team eventually asks: *is this corpus still in good shape?* It blends two measurements: - **Answerable rate (70 percent).** At ingestion Cortex writes questions whose answer is in a known passage. Replaying them through retrieval shows how many are still found: a drop means documents were added that drown the old ones, or a source was damaged. The replay uses embeddings and full-text search only, so it costs no model call. - **Hygiene (30 percent).** Open contradictions (up to 50 percent penalty), stale facts (up to 25 percent) and failed sources (up to 25 percent). The score is an integer from 0 to 100, or `null` when there is nothing to measure. With no replayed question yet, it falls back to the hygiene rate alone. ### GET /v1/knowledge/health Get the health score and its components. Computed on read from the current state, so it always reflects open contradictions and failed sources right now. The answerable part reflects the last replay (see below), which also runs by itself once a day. - **Authentication:** API key - **Scopes:** `knowledge` #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/knowledge/health -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const health = await fetch('https://api.subsidia.protypa.fr/v1/knowledge/health', { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }).then((r) => r.json()) if (health.score !== null && health.score < 80) console.warn('Knowledge base needs attention', health.hygiene) ``` _Python_ ```python import os, requests health = requests.get( "https://api.subsidia.protypa.fr/v1/knowledge/health", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json() print(health["score"], health["hygiene"]["openContradictions"]) ``` #### Responses **200**: The health object. ```json { "score": 88, "answerable": { "rate": 0.94, "passed": 47, "failed": 3, "total": 50, "lastCheckedAt": "2026-10-09T03:00:12.000Z" }, "hygiene": { "rate": 0.75, "openContradictions": 2, "staleFacts": 1, "disputedFacts": 4, "failedSources": 0 }, "corpus": { "sources": 38, "chunks": 1204, "facts": 612, "entities": 187, "qaPairs": 50 } } ``` #### Notes `answerable.rate` is `null` until a replay has checked at least one question. ### POST /v1/knowledge/health/replay Re-run the health questions now. Runs every stored question (up to 200 per call) through retrieval and records pass or fail. A question passes when its original passage is in the top 5, or when its source ranks in the top 3. Use it right after a large import or a deletion to see the effect at once instead of waiting for the daily run. Returns the replay summary and the refreshed health. - **Authentication:** API key - **Scopes:** `knowledge` #### Request examples _curl_ ```bash curl -X POST https://api.subsidia.protypa.fr/v1/knowledge/health/replay -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const { replay, health } = await fetch('https://api.subsidia.protypa.fr/v1/knowledge/health/replay', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }).then((r) => r.json()) console.log(replay.passed + '/' + replay.total, 'questions still answerable; score', health.score) ``` _Python_ ```python import os, requests data = requests.post( "https://api.subsidia.protypa.fr/v1/knowledge/health/replay", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json() print(data["replay"], data["health"]["score"]) ``` #### Responses **200**: The replay result and the new health. ```json { "replay": { "total": 50, "passed": 47, "failed": 3 }, "health": { "score": 88, "answerable": { "rate": 0.94, "passed": 47, "failed": 3, "total": 50, "lastCheckedAt": "2026-10-09T09:10:44.000Z" } } } ``` #### Notes The replay is synchronous and proportional to the number of stored questions. Do not call it in a tight loop. ## Use it in a workflow 1. **React to events** Subscribe to `knowledge.contradiction_detected` and `knowledge.stale` [webhooks](https://dev.subsidia.protypa.fr/docs/webhooks.md) so a person is told when a conflict or an expired fact appears, rather than polling. 2. **Keep a review queue** List `?status=open` contradictions in your own tool, show the two facts and their documents, and call `resolve` with the reviewer decision and a note. The note is your audit trail. 3. **Gate on health** Before a batch job that relies on the corpus (a monthly report, a client mailing), read `GET /v1/knowledge/health` and stop or warn below your own threshold. 4. **Verify what leaves the building** Facts tell you what the documents assert; the [Verificateur](https://dev.subsidia.protypa.fr/docs/verify.md) checks a text against them claim by claim. ## Frequently asked **Why does a document that I know is consistent show a contradiction?** Two documents can legitimately differ (an amendment, two periods). Resolve with `both`, or `dismiss` if it is a false alarm. Only conflicts across different sources are raised. **Are retired facts used in answers?** Retired facts are the losing side of a contradiction you resolved. They stay readable for history; filter `status=active` when you want the current state. Fact status does not delete the source passages, which remain searchable until you delete the source. **Why is the score null?** There is nothing to measure yet: no source, or sources without health questions. Ingest a source with `generateQa` enabled and run a replay. --- # Light > Ask a question and get an answer strictly from your documents, with numbered citations or an honest refusal; web search only as an explicit second step. Light is the document assistant of Subsidia: **retrieve, cite, compute, never invent**. One retrieval and one strictly grounded completion per question, no memory, no persona. If your documents do not contain the answer, Light says so plainly instead of answering from general knowledge. That refusal is the feature: an answer that is always confident cannot be trusted when it matters. Light is reachable with an API key (scope `light`), so you can put it behind your own interface, an internal bot or an automation. **One Light turn. The web step is a separate call that you make on purpose, never automatically.** Flow: Question -> Scope to a dossier (named or given) -> Retrieve passages -> Strict grounded completion -> Answer with [n] citations, or refusal -> Optional: /light/web ## Behaviour you can rely on | Situation | What Light does | | --- | --- | | The passages answer the question | An answer whose claims carry `[n]` markers; `sources` lists those passages with file, section and page. | | The documents do not cover it | The `answer` states, in the language of the question, that the documents do not cover it ("Les documents ne couvrent pas cette question."), and may suggest how to reformulate or which document to add. It never answers from outside knowledge. | | Several client files hold the same document and the question does not say which | Light does not guess. It returns a question back in `answer`, fills `ambiguity` with the candidate dossiers, uses no model and no question from your allowance. Ask again with `dossierId`, or name the client in the question. | | Table questions (sums, counts over a spreadsheet) | Computed deterministically; the `precomputed` field shows the tool, its arguments and its result. | | Retrieval itself failed | A `503`. This is an outage, not an answer: nothing was searched, so nothing is claimed or refused. | | Personal data in the question | Masked before leaving toward an external provider; the categories are listed in `masked`. Local models are never masked against. | > **INFO: No separate refusal flag** > A refusal is a sentence in `answer`, not a boolean. Show it to the user as it is. If your automation has to branch on it, branch on `sources` being empty or on `ambiguity` being non-null, and keep a person in the loop for the rest. ## Endpoints > **WARNING: Scope** > Both routes need the `light` scope on a restricted key. They only read, so a `readonly` key may call them too. See [Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md). ### POST /light/ask Ask a question of the workspace documents. Answers only from the documents of the workspace, with numbered citations. The server keeps no conversation: to hold a dialogue, send the previous turns in `history` yourself. The response carries a `proofId`, the id of a signed [proof certificate](https://dev.subsidia.protypa.fr/docs/proofs.md) of this turn, except when Light asked a clarifying question (no model answered). The route lives at `/light/ask` (no `/v1` prefix). - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `light` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `question` | `string` | yes | | The question. Trimmed; an empty value is a 400. | | `history` | `object[]` | no | | Previous turns of the conversation, so a follow-up ("and for the second lease?") is understood. | | `history.role` | `string` | no | | Author of the turn. One of: `user`, `assistant`. | | `history.content` | `string` | no | | Text of the turn. | | `dossierId` | `string` | no | | Collection id (a client file). Restricts retrieval to that dossier. Without it, Light infers the dossier when the question or the history names one. | | `provider` | `string` | no | | `auto`, `local`, or a provider id allowed in the workspace. An unknown, unconfigured or disallowed value is a 400 with a code; Light never silently switches provider. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/light/ask \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"question": "What is the notice period to terminate the lease?", "dossierId": "col_123"}' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/light/ask', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ question: 'What is the notice period to terminate the lease?', dossierId: 'col_123', }), }) if (!res.ok) throw new Error('Light failed: ' + res.status) const { answer, sources, proofId } = await res.json() console.log(answer) for (const s of sources) console.log('[' + s.ref + ']', s.sourceName, s.page ?? '', s.section ?? '') ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/light/ask", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={"question": "What is the notice period to terminate the lease?", "dossierId": "col_123"}, ) res.raise_for_status() data = res.json() print(data["answer"]) for s in data["sources"]: print("[%d]" % s["ref"], s["sourceName"], s["page"], s["section"]) ``` #### Responses **200**: The answer and everything needed to cite it. ```json { "answer": "The tenant may terminate at the end of each three-year period with six months' notice, by registered letter [1].", "sources": [ { "ref": 1, "sourceId": "clx9k2m4p0002abcd", "sourceName": "contrat-bail-2025.pdf", "dossierId": "col_123", "dossierName": "Dupont SARL", "section": "Article 12 - Termination", "page": 6, "chunkId": "ck_8f31a0c2", "chunkPreview": "The tenant may terminate the lease at the end of each three-year period...", "similarity": 0.81, "pinnedByUser": false } ], "masked": [], "precomputed": null, "tokensUsed": 1210, "scope": { "dossiers": [{ "id": "col_123", "name": "Dupont SARL" }], "how": "chosen" }, "ambiguity": null, "proofId": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10" } ``` **400**: `question` missing or empty, no workspace on the key, or an invalid `provider` (`provider_unknown` and the other provider policy codes, with a `message`). ```json { "error": "question is required" } ``` **503**: The search in the documents failed. Nothing was answered. Retry shortly. #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | Empty question, or `provider` not allowed. | | 401 | | Missing or invalid key. | | 402 | `QUESTION_LIMIT` | The workspace has no questions left. | | 403 | `scope_denied` | The key lacks the `light` scope. | | 429 | `rate_limited` | The key exceeded its own requests-per-minute limit. | | 503 | | Retrieval failed. | #### Notes `sources[].ref` matches the `[n]` markers in `answer`. `scope.how` is `named` when the dossier was inferred from the question, `chosen` when you passed `dossierId`. A clarifying turn has `ambiguity` set to `{ document, dossiers, more }`, `tokensUsed` 0 and `proofId` null. The answer text may contain internal grounding rail characters in some surfaces; strip any of the four characters U+27E6, U+27E7, U+27EC, U+27ED before displaying it in your own UI. ## Web fallback, only after a refusal A web search takes the question outside the building, so it is never part of `/light/ask`. When Light has refused and the person agrees, call `/light/web` as a separate, explicit step: - The query is **redacted before it leaves** (personal data, names of your dossiers), and the query actually sent is returned as `query` and written to the audit journal, so a firm can check what left the machine. - The answer comes from the fetched pages only and is returned with the page URLs as `sources`. Never display it as if it came from the client's documents. - It needs web search to be configured on the server; otherwise it answers `409`. ### POST /light/web Answer from the web, as an explicit follow-up to a refusal. Searches the web with a redacted query and answers from the pages found, with numbered citations to those pages. Call it only when the user has chosen to go to the web. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `light` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `question` | `string` | yes | | The question that Light refused. Redacted server-side before the search. | | `provider` | `string` | no | | Same meaning as on `/light/ask`. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/light/web \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"question": "What is the legal minimum notice period for a commercial lease in France?"}' ``` _TypeScript_ ```typescript async function askLightThenWeb(question: string, userAgreesToWeb: () => Promise) { const headers = { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', } const doc = await fetch('https://api.subsidia.protypa.fr/light/ask', { method: 'POST', headers, body: JSON.stringify({ question }), }).then((r) => r.json()) if (doc.sources.length > 0 || doc.ambiguity) return { origin: 'documents', ...doc } if (!(await userAgreesToWeb())) return { origin: 'documents', ...doc } // the refusal, as is const web = await fetch('https://api.subsidia.protypa.fr/light/web', { method: 'POST', headers, body: JSON.stringify({ question }), }).then((r) => r.json()) return { origin: 'web', ...web } // label it as web in your UI } ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/light/web", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={"question": "What is the legal minimum notice period for a commercial lease in France?"}, ) if res.status_code == 409: print("Web search is not configured on this server") else: web = res.json() print(web["answer"], [s["url"] for s in web["sources"]]) ``` #### Responses **200**: The web answer. When the search finds nothing, `answer` is empty, `sources` is empty and `reachable` tells you whether the search engine could be reached at all. ```json { "answer": "For a commercial lease the tenant may give notice at the end of each three-year period, six months in advance [1].", "sources": [ { "ref": 1, "sourceName": "service-public.fr", "title": "Commercial lease: termination by the tenant", "url": "https://www.service-public.fr/professionnels-entreprises/vosdroits/F31125", "preview": "The tenant may terminate the lease at the end of each three-year period..." } ], "reachable": true, "query": "minimum notice period commercial lease France", "tokensUsed": 940 } ``` **400**: `question` missing or empty, or an invalid `provider`. **409**: Web search is not configured on this server (`web_search_disabled`). ```json { "error": "web_search_disabled", "message": "Web search is not configured (SearXNG). Enable it in the server settings." } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | Empty question, or `provider` not allowed. | | 403 | `scope_denied` | The key lacks the `light` scope. | | 409 | `web_search_disabled` | No web search engine is configured on the server. | ## Light, Gateway or knowledge/query? | You want | Use | | --- | --- | | A cited answer or an honest refusal, no prompt to write | `POST /light/ask` | | The passages only, to build your own prompt, panel or check | [`POST /v1/knowledge/query`](https://dev.subsidia.protypa.fr/docs/knowledge-query.md) | | Retrieval inside an existing OpenAI or Anthropic client | Add `+cortex` to the model in the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md); the passages are committed to the certificate. | | Check an existing text against the documents, claim by claim | [The Verificateur](https://dev.subsidia.protypa.fr/docs/verify.md) | ## Frequently asked **Does Light count against my questions?** Yes: a Light turn that reaches the model is a question of your allowance, like any answer. A clarifying turn (`ambiguity`) uses no model and costs nothing. Ingestion never counts. See [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md). **Can I keep a conversation?** The server is stateless. Send the earlier turns in `history`. Pinned passages and saved workbooks are features of the app, not of this API. **Why does Light refuse when I know the answer is in a file?** Check the source is `ready` ([Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md)), that you did not restrict to the wrong `dossierId`, and run the same question through [`/v1/knowledge/query`](https://dev.subsidia.protypa.fr/docs/knowledge-query.md) to see which passages are retrieved. A refusal on a scan usually means OCR did not read it. --- # Agents > Create, read, update and delete the agents (personas) of a workspace through the API. An **agent** is a persona that lives in a workspace: a name, a role, a character, a mission and a system prompt. Once it exists you can talk to it ([Agent chat](https://dev.subsidia.protypa.fr/docs/agent-chat.md)), give it memory ([Agent memory](https://dev.subsidia.protypa.fr/docs/agent-memory.md)), link it to your own HTTP tools ([Tools](https://dev.subsidia.protypa.fr/docs/tools.md)), link it to knowledge sources ([Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md)), put it around a table with other agents ([Conversations](https://dev.subsidia.protypa.fr/docs/conversations.md)), or call it by name from any OpenAI or Anthropic client through the Gateway with the model address `agent:` ([Model addressing](https://dev.subsidia.protypa.fr/docs/model-addressing.md)). This page covers the persona itself and the four endpoints that manage it. Everything is scoped to the workspace of your API key: another workspace's agents are indistinguishable from missing ones. ## The persona model Six text fields are required to create an agent. They are not decoration: each one is injected into the prompt the model sees, in a different place and with a different effect. The API returns the full row, including a few fields that only the console edits. | Field | Type | Set through the API | What it does | | --- | --- | --- | --- | | `id` | string | No, generated | Stable identifier. Use it in every path (`/v1/agents/:id/...`). | | `name` | string | Required | How the agent is named in answers, in debates and in the roster other agents see when they consult a colleague. Keep it short and unique inside the workspace. | | `role` | string | Required | A job title in a few words ("Contrôle de gestion"). Shown next to the name in every surface and read by the auto-pick router of [Agent chat](https://dev.subsidia.protypa.fr/docs/agent-chat.md). | | `personality` | string | Required | The character: tone, temperament, habits of speech. This is what makes an answer sound like the agent and not like a generic assistant. | | `mission` | string | Required | What the agent is for, in one specific sentence. It is the boundary other agents read before consulting this one and the line the agent quotes when it declines an off-topic question. Also read by the auto-pick router. | | `decisionStyle` | string | Required | How it reasons and chooses: cautious, contrarian, evidence-first, speed-first. It shapes disagreements in debates. | | `systemPrompt` | string | Required | Free-form instructions appended to the persona: domain rules, output conventions, things to never do. | | `isDevilsAdvocate` | boolean | Optional, default `false` | Marks a contrarian persona. Debates always seat one devil's advocate per workspace; agents flagged this way are not drawn as regular debaters (see [Conversations](https://dev.subsidia.protypa.fr/docs/conversations.md)). | | `expertise`, `outOfScope` | string[] | No, console only | The subjects inside the mission, and the subjects to decline and hand off. Returned as empty arrays for agents created through the API. Postes use them heavily. | | `collectionScope`, `checklist` | string[] | No, console only | A poste's dossier scope and control list. See [Postes](https://dev.subsidia.protypa.fr/docs/postes.md). | | `isShared` | boolean | No | Whether the agent's global memories are shared across the workspace. | | `createdAt` | ISO date | No | Creation time. Lists are ordered by it, oldest first. | > **INFO: Postes are agents too** > A poste (a job-role agent such as "Auditeur de pièces") is an agent whose `personality` and `decisionStyle` are empty strings. It appears in `GET /v1/agents`, but free-form chat with it is refused: it runs on a dossier through [`POST /v1/postes/:key/run`](https://dev.subsidia.protypa.fr/docs/postes.md). The API never creates postes; they are installed from the console. ## Design tips - **One agent, one job.** A precise `mission` beats a long prompt. "Check that every figure is backed by a document of the file" gives the router, the colleagues and the agent itself something to hold on to; "Help with finance" gives them nothing. - **Put character in `personality` and `decisionStyle`, rules in `systemPrompt`.** Character fields are weighed on every answer; the system prompt is where you write constraints ("never quote a figure without its source", "answer in the language of the question"). - **Do not write output formats into the prompt.** Ask for structure per call with `responseFormat` ([Agent chat](https://dev.subsidia.protypa.fr/docs/agent-chat.md#structured-json-answers)); the prompt stays reusable. - **Keep facts out of the prompt.** Documents belong in [Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md) linked to the agent, where they are retrieved, cited and signed into the proof. Per-call data belongs in the `context` array of the chat request. - **Give the workspace a contrarian.** A debate with only agreeable personas converges in one round. One agent with `decisionStyle` "contrarian" earns its seat. - **Test with a fixed question set.** Change one field at a time and replay the same three or four questions; persona behaviour moves more than people expect. ## Authentication and scopes Send the key as `Authorization: Bearer sk_live_...` or `x-api-key: sk_live_...` ([Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md)). A key issued without capability scopes (a legacy key) reaches every route. A **restricted** key, one that carries a capability scope such as `engine`, reaches the agent routes only if it also carries the `agents` scope; otherwise the answer is a `403 scope_denied`. A key with the `readonly` scope can list and read agents but cannot create, change or delete them (`403 read_only_key`). ## Endpoints ### GET /v1/agents List the agents of the workspace. Returns every agent of the workspace, oldest first, with all persona fields. The list includes the workspace's devil's advocate once a debate has created it, and any installed poste. Filter on the client side if you only want conversational personas (postes have empty `personality` and `decisionStyle`). - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/agents \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/agents', { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const { agents } = await res.json() console.log(agents.map((a: any) => a.name + ' (' + a.role + ')')) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr/v1/agents", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) for agent in res.json()["agents"]: print(agent["id"], agent["name"], "-", agent["role"]) ``` #### Responses **200**: An object with an `agents` array. ```json { "agents": [ { "id": "cm2k8x1ab0001qz0f7h3d9t4e", "userId": "cm1u0a0000000qz0fowner001", "workspaceId": "cm1w0b0000000qz0fworksp01", "name": "Claire", "role": "Contrôle de gestion", "personality": "Precise, calm, allergic to round numbers nobody can source.", "mission": "Check that every figure in a client file is backed by a document of that file.", "expertise": [], "outOfScope": [], "decisionStyle": "Evidence first: never concludes without a cited piece.", "collectionScope": [], "checklist": [], "systemPrompt": "You are Claire, a management controller. Answer in the language of the question...", "isDevilsAdvocate": false, "isShared": false, "createdAt": "2026-10-09T08:15:42.118Z" } ] } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 401 | | No key, invalid, revoked or expired key. | | 403 | `scope_denied` | A restricted key without the `agents` scope. | ### POST /v1/agents Create an agent. Creates a persona in the workspace of the key. The six text fields are all required and must be non-empty. The agent is usable immediately: chat with it, link tools, link knowledge sources. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | `string` | yes | | Display name of the agent. | | `role` | `string` | yes | | Job title in a few words. | | `personality` | `string` | yes | | Tone and temperament. | | `mission` | `string` | yes | | What the agent is for, in one sentence. | | `decisionStyle` | `string` | yes | | How the agent reasons and decides. | | `systemPrompt` | `string` | yes | | Additional instructions and rules. | | `isDevilsAdvocate` | `boolean` | no | `false` | Mark the persona as a contrarian. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/agents \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Claire", "role": "Contrôle de gestion", "personality": "Precise, calm, allergic to round numbers nobody can source.", "mission": "Check that every figure in a client file is backed by a document of that file.", "decisionStyle": "Evidence first: never concludes without a cited piece.", "systemPrompt": "You are Claire, a management controller. Answer in the language of the question." }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/agents', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Claire', role: 'Contrôle de gestion', personality: 'Precise, calm, allergic to round numbers nobody can source.', mission: 'Check that every figure in a client file is backed by a document of that file.', decisionStyle: 'Evidence first: never concludes without a cited piece.', systemPrompt: 'You are Claire, a management controller. Answer in the language of the question.', }), }) if (res.status !== 201) throw new Error(await res.text()) const { agent } = await res.json() console.log(agent.id) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/agents", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "name": "Claire", "role": "Contrôle de gestion", "personality": "Precise, calm, allergic to round numbers nobody can source.", "mission": "Check that every figure in a client file is backed by a document of that file.", "decisionStyle": "Evidence first: never concludes without a cited piece.", "systemPrompt": "You are Claire, a management controller. Answer in the language of the question.", }, ) res.raise_for_status() agent_id = res.json()["agent"]["id"] ``` #### Responses **201**: The created agent. ```json { "agent": { "id": "cm2k8x1ab0001qz0f7h3d9t4e", "userId": "cm1u0a0000000qz0fowner001", "workspaceId": "cm1w0b0000000qz0fworksp01", "name": "Claire", "role": "Contrôle de gestion", "personality": "Precise, calm, allergic to round numbers nobody can source.", "mission": "Check that every figure in a client file is backed by a document of that file.", "expertise": [], "outOfScope": [], "decisionStyle": "Evidence first: never concludes without a cited piece.", "collectionScope": [], "checklist": [], "systemPrompt": "You are Claire, a management controller. Answer in the language of the question...", "isDevilsAdvocate": false, "isShared": false, "createdAt": "2026-10-09T08:15:42.118Z" } } ``` **400**: A required field is missing or empty. The message names it. ```json { "error": "mission is required" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | A required field is missing or an empty string. | | 403 | `read_only_key` | The key carries the `readonly` scope. | | 403 | `scope_denied` | A restricted key without the `agents` scope. | ### PUT /v1/agents/:id Update an agent. Partial update despite the verb: send only the fields you want to change. Omitted fields (or fields sent as `null`) keep their value. Unlike creation, update does not validate emptiness: an empty string is stored as is, so do not send one unless you mean it. In particular, emptying both `personality` and `decisionStyle` turns the agent into a poste, which can no longer be chatted with. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The agent id. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | `string` | no | | New display name. | | `role` | `string` | no | | New role. | | `personality` | `string` | no | | New personality. | | `mission` | `string` | no | | New mission. | | `decisionStyle` | `string` | no | | New decision style. | | `systemPrompt` | `string` | no | | New system prompt. | | `isDevilsAdvocate` | `boolean` | no | | Toggle the contrarian flag. | #### Request examples _curl_ ```bash curl -X PUT https://api.subsidia.protypa.fr/v1/agents/cm2k8x1ab0001qz0f7h3d9t4e \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"mission": "Check every figure of a client file against its documents, and say which one is missing."}' ``` _TypeScript_ ```typescript await fetch('https://api.subsidia.protypa.fr/v1/agents/' + agentId, { method: 'PUT', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ mission: 'Check every figure of a client file against its documents, and say which one is missing.' }), }) ``` _Python_ ```python import os, requests requests.put( "https://api.subsidia.protypa.fr/v1/agents/" + agent_id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={"mission": "Check every figure of a client file against its documents, and say which one is missing."}, ).raise_for_status() ``` #### Responses **200**: The updated agent. ```json { "agent": { "id": "cm2k8x1ab0001qz0f7h3d9t4e", "userId": "cm1u0a0000000qz0fowner001", "workspaceId": "cm1w0b0000000qz0fworksp01", "name": "Claire", "role": "Contrôle de gestion", "personality": "Precise, calm, allergic to round numbers nobody can source.", "mission": "Check that every figure in a client file is backed by a document of that file.", "expertise": [], "outOfScope": [], "decisionStyle": "Evidence first: never concludes without a cited piece.", "collectionScope": [], "checklist": [], "systemPrompt": "You are Claire, a management controller. Answer in the language of the question...", "isDevilsAdvocate": false, "isShared": false, "createdAt": "2026-10-09T08:15:42.118Z" } } ``` **404**: No agent with this id in the workspace. ```json { "error": "Agent not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | Unknown id, or the agent belongs to another workspace. | | 403 | `read_only_key` | The key carries the `readonly` scope. | ### DELETE /v1/agents/:id Delete an agent and its memories. Deletes the persona and every memory attached to it. This cannot be undone. Chat sessions that the agent took part in are kept (they are workspace conversations), but the agent can no longer answer in them. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The agent id. | #### Request examples _curl_ ```bash curl -X DELETE https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _Python_ ```python import os, requests res = requests.delete( "https://api.subsidia.protypa.fr/v1/agents/" + agent_id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) assert res.status_code == 204 ``` #### Responses **204**: Deleted. No body. **404**: No agent with this id in the workspace. ```json { "error": "Agent not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | Unknown id, or the agent belongs to another workspace. | | 403 | `read_only_key` | The key carries the `readonly` scope. | ## What to do next - **[Talk to the agent](https://dev.subsidia.protypa.fr/docs/agent-chat.md)**: One-to-one chat, streaming, structured answers, citations. - **[Give it memory](https://dev.subsidia.protypa.fr/docs/agent-memory.md)**: Read what it remembers and seed facts it should know. - **[Connect your systems](https://dev.subsidia.protypa.fr/docs/tools.md)**: Register an HTTP endpoint as a tool the agent can call. - **[Use it from any client](https://dev.subsidia.protypa.fr/docs/model-addressing.md)**: Call it as agent: through the Gateway. --- # Agent memory > What an agent remembers across conversations: how memories are written, ranked, deduplicated and labelled by provenance, and how to read, seed and delete them. A conversation has a history ([Sessions](https://dev.subsidia.protypa.fr/docs/sessions.md)); an agent has a **memory**. Memories are short notes (a decision, a preference, a fact worth keeping) that survive from one session to the next and are recalled into the prompt of later turns. They are what lets an agent say "as you told me last week, the client closes on 30 June" in a brand-new session. Memories are private to the agent that wrote them. They are not a knowledge base: documents belong in [Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md), where they are retrieved, cited and signed into a proof. Memory is for what the agent learned *from the conversation*. ## How memories are written There are two ways in. - **The agent writes them itself.** At the end of a text answer the agent may emit up to three memory blocks (a short key, a short value, an optional type). They are stripped from what you receive, then stored. The ones saved during a turn come back in `memoriesSaved` in the chat response ([Agent chat](https://dev.subsidia.protypa.fr/docs/agent-chat.md)). This happens on every chat call unless memory is switched off. Structured JSON answers (`responseFormat.type: "json"`) do not carry memory instructions. - **You write them through the API.** `POST /v1/agents/:id/memory` stores a fact you want the agent to know, without waiting for a conversation to produce it. See the endpoints below for how this path differs. Every agent-written memory is checked before it is stored. Nothing is refused, but the memory is labelled with where its content came from. ### Provenance A model that invents "revenue grew 50%" in turn three must not be able to recall it in turn nine as if it had read it in a document. So at write time Subsidia looks for the memory's checkable content (every figure, or enough of its content words) in the evidence that turn actually had, strongest source first: | Provenance | Found in | Starting salience | How it is recalled | | --- | --- | --- | --- | | `knowledge` | Passages retrieved from the knowledge base this turn | 0.65 | Labelled "sourced: knowledge base". | | `data` | Structured data injected through the `context` array | 0.65 | Labelled "sourced: workspace data". | | `tool` | A tool result or a colleague consultation of the turn | 0.65 | Labelled "sourced: tool result". | | `stated` | What the user said in the conversation | 0.50 | Labelled "said in conversation". | | `unverified` | Nowhere: the agent's own claim | 0.35 | Labelled UNVERIFIED, with an instruction never to present it as established. | A figure is the part of a memory that can be wrong in a costly way, so when a memory contains numbers, **every** number must appear in the source for it to be credited to that source. Unverified memories rank lower and fade sooner, but they are kept: an agent's own judgment is worth remembering as long as it is not mistaken for a fact. The provenance is stored in the memory's `metadata.provenance`. ### Deduplication Models re-label the same fact on every turn (`client_year_end`, then `fiscal_closing_date`). Two protections keep the memory from filling with copies: - A memory with the **same key** replaces the previous value. - A memory with a **different key but the same content** is folded into the existing record: among the agent's 60 most recent memories, one whose embedding is more than 0.92 cosine-similar to the new value, or whose content words overlap by at least 80%, is treated as a twin. The longer value is kept, the new key is recorded as an alias, and the salience goes up slightly (repetition is evidence of importance, but a claim asserted without a source stays unverified). These protections apply to memories the agent writes. Memories you create with `POST /v1/agents/:id/memory` are inserted as they are. ## How memories are recalled Before each turn, the agent's memories are ranked against the new message and the best twelve are placed in its prompt. The score is a weighted sum: 50% semantic similarity to the message, 20% recency (a 14-day half-life, counted from the last time the memory was recalled), 20% salience, and 10% congruence with the agent's current mood. Memories without an embedding, such as those created through the API, get a neutral semantic score: they can still be recalled, but they win less often against memories that match the question closely. If the embedding service is unavailable, the agent falls back to its most recent memories. Global memories (a fact worth sharing with every agent of the workspace, flagged by the agent when it writes it) are added on top, up to eight. They only exist for agents with sharing enabled in the console; for other agents the flag is ignored and the memory stays private. ## Turning memory off Memory is on by default for chat. To call an agent **without** it being read or written, use the Gateway address flag `+nomemory`, for example `agent:claire+nomemory` as the model name of a chat completion ([Model addressing](https://dev.subsidia.protypa.fr/docs/model-addressing.md)). The agent then neither recalls nor stores anything for that call. This is the right setting for tests, evaluations and any caller that keeps its own conversation state. The `/v1/agents/:id/chat` endpoints have no per-call memory switch: they always recall and may always write. Postes never read or write memory ([Postes](https://dev.subsidia.protypa.fr/docs/postes.md)). > **INFO: Deleting an agent deletes its memory** > `DELETE /v1/agents/:id` removes every memory of that agent with it. To forget one thing, delete that memory; to forget everything and keep the persona, delete the memories one by one. ## Endpoints All three routes need the `agents` scope on restricted keys, and the two write routes are refused for `readonly` keys. See [Agents](https://dev.subsidia.protypa.fr/docs/agents.md#authentication-and-scopes). ### GET /v1/agents/:id/memory List the memories of an agent. Returns the agent's own memories (those not flagged global), as stored rows. Rows include internal fields such as the embedding vector, a list of floating-point numbers that you can ignore; drop it before logging. Useful to audit what an agent has learned, to find an UNVERIFIED claim before it spreads, or to export memory. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The agent id. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/memory \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/agents/' + agentId + '/memory', { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const { memory } = await res.json() const doubtful = memory.filter((m: any) => m.metadata?.provenance === 'unverified') console.log(doubtful.map((m: any) => m.key + ': ' + m.value)) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr/v1/agents/" + agent_id + "/memory", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) for m in res.json()["memory"]: provenance = (m.get("metadata") or {}).get("provenance", "none") print(m["id"], m["key"], provenance) ``` #### Responses **200**: An object with a `memory` array, in no guaranteed order. The list is not paginated. ```json { "memory": [ { "id": "cm2kb7q4w0007qz0fm3n8c1xa", "agentId": "cm2k8x1ab0001qz0f7h3d9t4e", "workspaceId": null, "key": "client_fiscal_year_end", "value": "The client closes its books on 30 June.", "metadata": { "type": "fact", "provenance": "knowledge" }, "isGlobal": false, "type": "fact", "connectedTo": [], "valence": null, "salience": 0.65, "embedding": [0.0121, -0.0443, "... 768 numbers"], "lastRecalledAt": "2026-10-09T09:02:11.402Z", "recallCount": 3, "createdAt": "2026-10-02T14:20:05.771Z", "updatedAt": "2026-10-09T09:02:11.402Z" } ] } ``` **404**: No agent with this id in the workspace. ```json { "error": "Agent not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | Unknown agent id, or the agent belongs to another workspace. | | 403 | `scope_denied` | A restricted key without the `agents` scope. | ### POST /v1/agents/:id/memory Add a memory to an agent. Stores a fact the agent should know from its next turn on. The memory is created exactly as sent: no provenance check, no deduplication against existing memories, and no embedding at creation time (so it is ranked with a neutral semantic score). It starts with the default salience of 0.5. If you call it twice with the same key you get two records. Keep values short and factual, one idea per memory, like a note on a card. For documents, use [Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md) instead. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The agent id. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `key` | `string` | yes | | Short label in snake_case, for example `client_fiscal_year_end`. | | `value` | `string` | yes | | The thing to remember, one or two sentences. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/memory \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"key": "client_fiscal_year_end", "value": "The client closes its books on 30 June."}' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/agents/' + agentId + '/memory', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ key: 'client_fiscal_year_end', value: 'The client closes its books on 30 June.', }), }) const { memory } = await res.json() console.log(memory.id) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/agents/" + agent_id + "/memory", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={"key": "client_fiscal_year_end", "value": "The client closes its books on 30 June."}, ) res.raise_for_status() memory_id = res.json()["memory"]["id"] ``` #### Responses **201**: The stored memory. ```json { "memory": { "id": "cm2kb7q4w0007qz0fm3n8c1xa", "agentId": "cm2k8x1ab0001qz0f7h3d9t4e", "workspaceId": null, "key": "client_fiscal_year_end", "value": "The client closes its books on 30 June.", "metadata": null, "isGlobal": false, "type": null, "connectedTo": [], "valence": null, "salience": 0.5, "embedding": [], "lastRecalledAt": null, "recallCount": 0, "createdAt": "2026-10-02T14:20:05.771Z", "updatedAt": "2026-10-09T09:02:11.402Z" } } ``` **400**: `key` or `value` is missing or empty. ```json { "error": "key and value are required" } ``` **404**: No agent with this id in the workspace. ```json { "error": "Agent not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | `key` or `value` missing, empty or not a string. | | 404 | | Unknown agent id. | | 403 | `read_only_key` | The key carries the `readonly` scope. | ### DELETE /v1/agents/:id/memory/:memoryId Delete one memory. Removes a single memory. Use it to retract a wrong or UNVERIFIED claim. The memory must belong to the agent in the path. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The agent id. | | `memoryId` | `string` | yes | | The `id` of the memory row, from the list endpoint. | #### Request examples _curl_ ```bash curl -X DELETE https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/memory/$MEMORY_ID \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _Python_ ```python import os, requests headers = {"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]} base = "https://api.subsidia.protypa.fr/v1/agents/" + agent_id + "/memory" # Retract every claim the agent could not source. for m in requests.get(base, headers=headers).json()["memory"]: if (m.get("metadata") or {}).get("provenance") == "unverified": requests.delete(base + "/" + m["id"], headers=headers).raise_for_status() ``` #### Responses **204**: Deleted. No body. **404**: Unknown agent (`Agent not found`) or unknown memory for this agent (`Memory not found`). ```json { "error": "Memory not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | The agent or the memory does not exist, or the memory belongs to another agent. | | 403 | `read_only_key` | The key carries the `readonly` scope. | ## Frequently asked **Can an agent remember something a user told it in confidence?** Memory is scoped to the agent and workspace and is never shown to other workspaces. But anything an agent stores is recalled into later prompts, for any user of that agent. Do not use a shared agent as a private notebook, and delete a memory that should not be kept. **Why did my memory not show up in answers?** Only the twelve best-ranked memories enter each prompt. A memory created through the API has no embedding, so it competes on recency and salience alone. Phrase it the way a user will ask about it, or put the document in a knowledge source if it must be found reliably. **Does memory count as a question?** No. Reading and writing memory is part of the chat turn that triggers it; the CRUD routes above call no model and consume nothing. --- # Agent chat > Talk to one agent, or let Subsidia pick the best-fit agent: multi-turn sessions, knowledge citations, tool calls, structured JSON and a server-sent event stream. Agent chat is the endpoint family for **conversations with a persona**: the agent answers in character, remembers past sessions, reads the documents linked to it, calls your HTTP tools when it needs live data, and can ask a colleague for a second opinion. It is the right choice when your application is built around a specific agent. If you only want to point an existing OpenAI or Anthropic client at an agent, use the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md) with the model name `agent:` instead. There are two styles, each with a plain and a streamed form: | Style | Plain | Streamed | Use when | | --- | --- | --- | --- | | A specific agent | `POST /v1/agents/:id/chat` | `POST /v1/agents/:id/chat/stream` | Your application knows which agent should answer. | | Auto-pick | `POST /v1/agents/chat` | `POST /v1/agents/chat/stream` | You have several agents and want the best fit for each message, for example behind a single input box. | **One turn. The steps with a dashed role (knowledge, tools, consult) are skipped when the corresponding block is disabled or the agent has nothing linked.** Flow: Message -> Session + history -> Memory recall -> Knowledge retrieval -> Tools + consult -> Answer -> Memory, facts, proof > **INFO: Postes cannot be chatted with** > A poste is a bounded job, not a conversation partner. Chat refuses it with a 500 whose message says so; run it with [`POST /v1/postes/:key/run`](https://dev.subsidia.protypa.fr/docs/postes.md). Auto-pick considers every agent of the workspace, so in a workspace that has installed postes, address the agent you want explicitly. ## Authentication, scopes and billing Use a developer API key ([Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md)). On a restricted key, these routes need the `agents` scope alongside your capability scope; a `readonly` key is refused because chatting writes (history, memory). Each chat route is also limited to 30 calls per minute per client, and a key can carry its own per-minute limit and monthly ceiling ([Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md)). Each turn is metered against the workspace allowance of questions on the model usage of the whole turn (retrieval, tool rounds and consultations included), and an exhausted allowance is a `402` before any model runs ([Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md)). ## A first call 1. **Pick an agent** List your agents with [`GET /v1/agents`](https://dev.subsidia.protypa.fr/docs/agents.md) and keep the `id`, or create one. 2. **Send a message** Without a `sessionId`, a new session is created for you and its id is returned. ```bash curl https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/chat \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "message": "What is the Q3 invoice total, and is the file complete?", "context": [ { "category": "client", "label": "Client record", "data": { "name": "Atelier Moreau", "plan": "monthly" }, "priority": "high" } ], "knowledge": { "topK": 5 } }' ``` 3. **Continue the conversation** Send the returned `sessionId` back. The agent sees the last 20 messages of the session, so it can answer "and the previous quarter?" without being told what "it" refers to. ```bash curl https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/chat \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" -H "Content-Type: application/json" \ -d '{"message": "And the previous quarter?", "sessionId": "cm2kc4t9p000bqz0fr6s2a8dk"}' ``` ## Endpoints ### POST /v1/agents/:id/chat Send a message to one agent and get the full answer. Runs one turn with the agent in `:id`: recalls its memories, loads the session history, retrieves passages from its linked knowledge sources, lets it call its tools and consult colleagues, produces the answer, then saves the exchange, any new memories and the extracted facts. The call returns when the answer is complete; for long answers use the streamed form below. Knowledge, tools and consultation are **on by default**, exactly like in the Subsidia app. An agent that has no linked sources or tools simply does not use them. Pass `enabled: false` in the matching block for an isolated call. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The agent id. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `message` | `string` | yes | | What you say to the agent. Must be a non-empty string. | | `sessionId` | `string` | no | | Continue an existing session ([Sessions](https://dev.subsidia.protypa.fr/docs/sessions.md)). Omit it to start a new one: its id comes back in the response. A session that does not exist, or that was started by another user, is a 404 `Session not found`. | | `context` | `object[]` | no | | Structured data injected into the agent prompt for this call only (it is not stored). See [Context injection](#context-injection). | | `context.category` | `string` | yes | | What kind of data this is, for example `client`, `invoice`, `crm`. Used as the block heading when `label` is absent. | | `context.label` | `string` | no | | Heading shown to the model instead of the category. | | `context.data` | `object | string` | yes | | The data. Objects are serialised to indented JSON. | | `context.priority` | `"high" | "medium" | "low"` | no | `medium` | Blocks are ordered high first; `high` blocks are flagged as high priority to the model. One of: `high`, `medium`, `low`. | | `extraction` | `object` | no | | Extract structured facts from your message, alongside the answer. See [Fact extraction](#fact-extraction). | | `extraction.categories` | `string[]` | yes | | Categories to extract, at least one, for example `["company_info", "decisions", "preferences"]`. | | `extraction.persist` | `boolean` | no | `true` | Store the extracted facts. Set `false` to only return them. | | `options` | `object` | no | | Generation settings. | | `options.reflection` | `boolean` | no | `false` | Run a separate reasoning pass before the answer and return it as `thoughts`. Skipped for small talk. Adds latency and cost. | | `options.temperature` | `number` | no | `0.8` | Sampling temperature, 0 to 2. | | `options.maxTokens` | `number` | no | `1500` | Output ceiling, 100 to 4000. | | `responseFormat` | `object` | no | | Force a structured JSON answer. See [Structured JSON answers](#structured-json-answers). | | `responseFormat.type` | `"json" | "text"` | yes | | `json` forces a JSON object; `text` is the default prose mode. One of: `json`, `text`. | | `responseFormat.fields` | `object` | no | | Map of field name to a description of what it must contain. | | `responseFormat.schema` | `string` | no | | A free-form schema description, as an alternative to `fields`. | | `knowledge` | `object` | no | | Retrieval from the knowledge sources linked to the agent. On by default. | | `knowledge.enabled` | `boolean` | no | `true` | Set `false` to answer without reading any document. | | `knowledge.topK` | `number` | no | `5` | Passages to retrieve, 1 to 20. Questions that ask for a list (who are our clients) use 8 when you do not set it. | | `tools` | `object` | no | | The HTTP tools linked to the agent ([Tools](https://dev.subsidia.protypa.fr/docs/tools.md)). On by default. | | `tools.enabled` | `boolean` | no | `true` | Set `false` so the agent never calls a tool in this turn. | | `tools.toolIds` | `string[]` | no | | Restrict the turn to a subset of the linked tools. Omit to allow all of them. Inactive tools are never offered. | | `consult` | `object` | no | | Let the agent ask other agents of the workspace for a second opinion mid-turn. See [Consulting colleagues](#consulting-colleagues). | | `consult.enabled` | `boolean` | no | `true` | Set `false` for an isolated call. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/chat \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "message": "What is the Q3 invoice total, and is the file complete?", "context": [ { "category": "client", "label": "Client record", "data": { "name": "Atelier Moreau", "plan": "monthly" }, "priority": "high" } ], "knowledge": { "topK": 5 } }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/agents/' + agentId + '/chat', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ message: 'What is the Q3 invoice total, and is the file complete?', context: [ { category: 'client', label: 'Client record', data: { name: 'Atelier Moreau', plan: 'monthly' }, priority: 'high' }, ], knowledge: { topK: 5 }, }), }) if (!res.ok) throw new Error(res.status + ' ' + (await res.text())) const turn = await res.json() console.log(turn.content) for (const k of turn.knowledgeUsed ?? []) console.log('[' + k.ref + ']', k.sourceName, 'p.' + k.page) const nextSessionId = turn.sessionId // send it back to continue ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/agents/" + agent_id + "/chat", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "message": "What is the Q3 invoice total, and is the file complete?", "context": [ {"category": "client", "label": "Client record", "data": {"name": "Atelier Moreau", "plan": "monthly"}, "priority": "high"}, ], "knowledge": {"topK": 5}, }, timeout=300, ) res.raise_for_status() turn = res.json() print(turn["content"]) for k in turn.get("knowledgeUsed", []): print("[%d] %s p.%s" % (k["ref"], k["sourceName"], k["page"])) session_id = turn["sessionId"] ``` #### Responses **200**: The turn. `content` is the answer; `contentParsed` is present only with `responseFormat.type: "json"` and valid JSON; `thoughts` only with `options.reflection`; `knowledgeUsed`, `toolCalls` and `memoriesSaved` are empty or absent when nothing was used or saved. `tokenUsage` is the model usage of the whole turn, internal steps included. ```json { "agentId": "cm2k8x1ab0001qz0f7h3d9t4e", "agentName": "Claire", "agentRole": "Contrôle de gestion", "content": "The Q3 invoice is 12 480 EUR excluding VAT [1]. It matches the purchase order, but the delivery note is missing from the file.", "sessionId": "cm2kc4t9p000bqz0fr6s2a8dk", "memoriesSaved": [ { "key": "q3_invoice_total", "value": "Q3 invoice: 12 480 EUR excl. VAT.", "type": "fact", "provenance": "knowledge", "salience": 0.65 } ], "extracted": [], "tokenUsage": { "promptTokens": 2140, "completionTokens": 62, "totalTokens": 2202 }, "knowledgeUsed": [ { "ref": 1, "sourceId": "cm2k9aa7e0003qz0fh8w1p2lb", "sourceName": "facture-q3-2026.pdf", "section": "Totals", "page": 2, "chunkPreview": "Total HT : 12 480,00 EUR ...", "similarity": 0.83, "chunkId": "ck_8f1d2c40", "collectionId": null, "dossierName": null } ], "toolCalls": [] } ``` **400**: `message` missing or empty, or a block that does not match its schema (for example `maxTokens` above 4000). **402**: The workspace has no questions left. Nothing was consumed. ```json { "error": "Insufficient tokens" } ``` **404**: `Agent not found: `, or `Session not found: `. ```json { "error": "Agent not found: cm2k8x1ab0001qz0f7h3d9t4e" } ``` **500**: The model call failed, or the agent is a poste. ```json { "error": "Agent chat failed" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 401 | | Missing, invalid, revoked or expired key. | | 402 | `API_KEY_BUDGET_EXCEEDED` | The key reached its own monthly question ceiling. | | 403 | `scope_denied` | A restricted key without the `agents` scope. | | 403 | `read_only_key` | The key carries the `readonly` scope. | | 429 | `rate_limited` | More than 30 calls per minute on this route, or the key's own per-minute limit. Honour `retry-after`. | #### Notes The plain response carries the fields listed above. The streamed form ends with a `done` event holding the complete result of the turn, which also includes `proofId`, `consultations`, `mood`, `masked` and `contextUsed`; use it when you need those. ### POST /v1/agents/:id/chat/stream Send a message to one agent and stream the answer. Same request body and same turn as `POST /v1/agents/:id/chat`, answered as Server-Sent Events (`text/event-stream`). You receive tokens as they are produced, plus progress events for retrieval, tool calls and consultations, and a final `done` event with the complete result. See the [event reference](#stream-events) and the [client examples](#complete-streaming-client). The HTTP status is 200 as soon as the stream opens. Failures that happen after that point, including an unknown agent or session and an exhausted allowance, arrive as an `error` event, not as a 4xx. Only an empty `message` or a malformed body is rejected before the stream starts. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` - **Streaming:** yes, Server-Sent Events when `stream: true` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The agent id. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `message` | `string` | yes | | What you say to the agent. Must be a non-empty string. | | `sessionId` | `string` | no | | Continue an existing session ([Sessions](https://dev.subsidia.protypa.fr/docs/sessions.md)). Omit it to start a new one: its id comes back in the response. A session that does not exist, or that was started by another user, is a 404 `Session not found`. | | `context` | `object[]` | no | | Structured data injected into the agent prompt for this call only (it is not stored). See [Context injection](#context-injection). | | `context.category` | `string` | yes | | What kind of data this is, for example `client`, `invoice`, `crm`. Used as the block heading when `label` is absent. | | `context.label` | `string` | no | | Heading shown to the model instead of the category. | | `context.data` | `object | string` | yes | | The data. Objects are serialised to indented JSON. | | `context.priority` | `"high" | "medium" | "low"` | no | `medium` | Blocks are ordered high first; `high` blocks are flagged as high priority to the model. One of: `high`, `medium`, `low`. | | `extraction` | `object` | no | | Extract structured facts from your message, alongside the answer. See [Fact extraction](#fact-extraction). | | `extraction.categories` | `string[]` | yes | | Categories to extract, at least one, for example `["company_info", "decisions", "preferences"]`. | | `extraction.persist` | `boolean` | no | `true` | Store the extracted facts. Set `false` to only return them. | | `options` | `object` | no | | Generation settings. | | `options.reflection` | `boolean` | no | `false` | Run a separate reasoning pass before the answer and return it as `thoughts`. Skipped for small talk. Adds latency and cost. | | `options.temperature` | `number` | no | `0.8` | Sampling temperature, 0 to 2. | | `options.maxTokens` | `number` | no | `1500` | Output ceiling, 100 to 4000. | | `responseFormat` | `object` | no | | Force a structured JSON answer. See [Structured JSON answers](#structured-json-answers). | | `responseFormat.type` | `"json" | "text"` | yes | | `json` forces a JSON object; `text` is the default prose mode. One of: `json`, `text`. | | `responseFormat.fields` | `object` | no | | Map of field name to a description of what it must contain. | | `responseFormat.schema` | `string` | no | | A free-form schema description, as an alternative to `fields`. | | `knowledge` | `object` | no | | Retrieval from the knowledge sources linked to the agent. On by default. | | `knowledge.enabled` | `boolean` | no | `true` | Set `false` to answer without reading any document. | | `knowledge.topK` | `number` | no | `5` | Passages to retrieve, 1 to 20. Questions that ask for a list (who are our clients) use 8 when you do not set it. | | `tools` | `object` | no | | The HTTP tools linked to the agent ([Tools](https://dev.subsidia.protypa.fr/docs/tools.md)). On by default. | | `tools.enabled` | `boolean` | no | `true` | Set `false` so the agent never calls a tool in this turn. | | `tools.toolIds` | `string[]` | no | | Restrict the turn to a subset of the linked tools. Omit to allow all of them. Inactive tools are never offered. | | `consult` | `object` | no | | Let the agent ask other agents of the workspace for a second opinion mid-turn. See [Consulting colleagues](#consulting-colleagues). | | `consult.enabled` | `boolean` | no | `true` | Set `false` for an isolated call. | #### Request examples _curl_ ```bash curl -N https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/chat/stream \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"message": "What is the Q3 invoice total?"}' ``` #### Responses **200**: A `text/event-stream`: one `data:` line per event, each holding a JSON object `{ "type": ..., "data": ... }`, blank-line separated. Comment lines `: ping` are sent every 15 seconds to keep proxies from closing an idle connection. ```text data: {"type":"stage","data":{"name":"prepare","phase":"start"}} data: {"type":"stage","data":{"name":"retrieval","phase":"start"}} data: {"type":"stage","data":{"name":"retrieval","phase":"end","ms":412}} data: {"type":"knowledge","data":[{"ref":1,"sourceId":"cm2k9aa7e0003qz0fh8w1p2lb","sourceName":"facture-q3-2026.pdf","section":"Totals","page":2,"chunkPreview":"Total HT : 12 480,00 EUR ...","similarity":0.83,"chunkId":"ck_8f1d2c40"}]} data: {"type":"tool_call","data":{"toolName":"crm_lookup","arguments":{"client":"Atelier Moreau"}}} data: {"type":"tool_result","data":{"toolName":"crm_lookup","arguments":{"client":"Atelier Moreau"},"result":{"status":"active"},"success":true,"durationMs":182}} data: {"type":"token","data":{"delta":"The Q3 invoice is "}} data: {"type":"token","data":{"delta":"12 480 EUR excluding VAT [1]."}} : ping data: {"type":"mood","data":{"agentId":"cm2k8x1ab0001qz0f7h3d9t4e","name":"Claire","emotion":"focus","valence":0.2,"arousal":0.4,"intensity":0.5}} data: {"type":"done","data":{"agentId":"cm2k8x1ab0001qz0f7h3d9t4e","agentName":"Claire","agentRole":"Contrôle de gestion","content":"The Q3 invoice is 12 480 EUR excluding VAT [1].","sessionId":"cm2kc4t9p000bqz0fr6s2a8dk","memoriesSaved":[],"extracted":[],"tokenUsage":{"promptTokens":2140,"completionTokens":14,"totalTokens":2154},"knowledgeUsed":[{"ref":1,"sourceName":"facture-q3-2026.pdf"}],"toolCalls":[{"toolName":"crm_lookup","success":true,"durationMs":182}],"proofId":"pf_a059713a5f1c4e0b8d7a3c21e9b64f10"}} ``` **400**: `message` missing or empty. ```json { "error": "message is required" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 401 | | Missing, invalid, revoked or expired key. | | 403 | `scope_denied` | A restricted key without the `agents` scope. | | 429 | `rate_limited` | More than 30 calls per minute on this route, or the key's own limit. | ### POST /v1/agents/chat Let Subsidia pick the best-fit agent, then answer. Like `POST /v1/agents/:id/chat` without the agent. A lightweight planning step reads the message together with the name, role and mission of every agent of the workspace and routes to the single best fit; if it cannot decide, the oldest agent of the workspace answers. The response tells you who answered through `agentId`, `agentName` and `agentRole`. Keep using the returned `sessionId` and you stay in the same conversation, but note that the router re-evaluates every message: a follow-up may be routed to a different agent. For a stable conversation partner, take the `agentId` from the first answer and switch to the `:id` form. The quality of the routing is the quality of your `role` and `mission` fields ([Agents](https://dev.subsidia.protypa.fr/docs/agents.md#design-tips)). - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `message` | `string` | yes | | What you say to the agent. Must be a non-empty string. | | `sessionId` | `string` | no | | Continue an existing session ([Sessions](https://dev.subsidia.protypa.fr/docs/sessions.md)). Omit it to start a new one: its id comes back in the response. A session that does not exist, or that was started by another user, is a 404 `Session not found`. | | `context` | `object[]` | no | | Structured data injected into the agent prompt for this call only (it is not stored). See [Context injection](#context-injection). | | `context.category` | `string` | yes | | What kind of data this is, for example `client`, `invoice`, `crm`. Used as the block heading when `label` is absent. | | `context.label` | `string` | no | | Heading shown to the model instead of the category. | | `context.data` | `object | string` | yes | | The data. Objects are serialised to indented JSON. | | `context.priority` | `"high" | "medium" | "low"` | no | `medium` | Blocks are ordered high first; `high` blocks are flagged as high priority to the model. One of: `high`, `medium`, `low`. | | `extraction` | `object` | no | | Extract structured facts from your message, alongside the answer. See [Fact extraction](#fact-extraction). | | `extraction.categories` | `string[]` | yes | | Categories to extract, at least one, for example `["company_info", "decisions", "preferences"]`. | | `extraction.persist` | `boolean` | no | `true` | Store the extracted facts. Set `false` to only return them. | | `options` | `object` | no | | Generation settings. | | `options.reflection` | `boolean` | no | `false` | Run a separate reasoning pass before the answer and return it as `thoughts`. Skipped for small talk. Adds latency and cost. | | `options.temperature` | `number` | no | `0.8` | Sampling temperature, 0 to 2. | | `options.maxTokens` | `number` | no | `1500` | Output ceiling, 100 to 4000. | | `responseFormat` | `object` | no | | Force a structured JSON answer. See [Structured JSON answers](#structured-json-answers). | | `responseFormat.type` | `"json" | "text"` | yes | | `json` forces a JSON object; `text` is the default prose mode. One of: `json`, `text`. | | `responseFormat.fields` | `object` | no | | Map of field name to a description of what it must contain. | | `responseFormat.schema` | `string` | no | | A free-form schema description, as an alternative to `fields`. | | `knowledge` | `object` | no | | Retrieval from the knowledge sources linked to the agent. On by default. | | `knowledge.enabled` | `boolean` | no | `true` | Set `false` to answer without reading any document. | | `knowledge.topK` | `number` | no | `5` | Passages to retrieve, 1 to 20. Questions that ask for a list (who are our clients) use 8 when you do not set it. | | `tools` | `object` | no | | The HTTP tools linked to the agent ([Tools](https://dev.subsidia.protypa.fr/docs/tools.md)). On by default. | | `tools.enabled` | `boolean` | no | `true` | Set `false` so the agent never calls a tool in this turn. | | `tools.toolIds` | `string[]` | no | | Restrict the turn to a subset of the linked tools. Omit to allow all of them. Inactive tools are never offered. | | `consult` | `object` | no | | Let the agent ask other agents of the workspace for a second opinion mid-turn. See [Consulting colleagues](#consulting-colleagues). | | `consult.enabled` | `boolean` | no | `true` | Set `false` for an isolated call. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/agents/chat \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"message": "Can you check the VAT on the attached invoice?"}' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/agents/chat', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ message: 'Can you check the VAT on the attached invoice?' }), }) const turn = await res.json() console.log('Answered by', turn.agentName, '(' + turn.agentRole + ')') console.log(turn.content) ``` _Python_ ```python import os, requests turn = requests.post( "https://api.subsidia.protypa.fr/v1/agents/chat", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={"message": "Can you check the VAT on the attached invoice?"}, timeout=300, ).json() print("Answered by", turn["agentName"], "-", turn["agentRole"]) print(turn["content"]) ``` #### Responses **200**: Same shape as the single-agent response. `agentId` is the agent that was picked. ```json { "agentId": "cm2k8x1ab0001qz0f7h3d9t4e", "agentName": "Claire", "agentRole": "Contrôle de gestion", "content": "The Q3 invoice is 12 480 EUR excluding VAT [1]. It matches the purchase order, but the delivery note is missing from the file.", "sessionId": "cm2kc4t9p000bqz0fr6s2a8dk", "memoriesSaved": [ { "key": "q3_invoice_total", "value": "Q3 invoice: 12 480 EUR excl. VAT.", "type": "fact", "provenance": "knowledge", "salience": 0.65 } ], "extracted": [], "tokenUsage": { "promptTokens": 2140, "completionTokens": 62, "totalTokens": 2202 }, "knowledgeUsed": [ { "ref": 1, "sourceId": "cm2k9aa7e0003qz0fh8w1p2lb", "sourceName": "facture-q3-2026.pdf", "section": "Totals", "page": 2, "chunkPreview": "Total HT : 12 480,00 EUR ...", "similarity": 0.83, "chunkId": "ck_8f1d2c40", "collectionId": null, "dossierName": null } ], "toolCalls": [] } ``` **400**: `message` missing or empty. **402**: The workspace has no questions left. ```json { "error": "Insufficient tokens" } ``` **404**: The workspace has no agents (`No agents available in this workspace`), or the `sessionId` is unknown. ```json { "error": "No agents available in this workspace" } ``` **500**: Model failure, or the picked agent is a poste. #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | No agent in the workspace, or unknown session. | | 429 | `rate_limited` | More than 30 calls per minute on this route. | ### POST /v1/agents/chat/stream Let Subsidia pick the best-fit agent, then stream the answer. The streamed form of auto-pick. The agent is chosen **before** the stream opens, so the two failures that depend on it (no agent in the workspace, or the planning step failing) are ordinary JSON errors (404 and 500); after that, events are the same as in `POST /v1/agents/:id/chat/stream`. The `done` event tells you which agent answered (`agentId`, `agentName`). - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` - **Streaming:** yes, Server-Sent Events when `stream: true` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `message` | `string` | yes | | What you say to the agent. Must be a non-empty string. | | `sessionId` | `string` | no | | Continue an existing session ([Sessions](https://dev.subsidia.protypa.fr/docs/sessions.md)). Omit it to start a new one: its id comes back in the response. A session that does not exist, or that was started by another user, is a 404 `Session not found`. | | `context` | `object[]` | no | | Structured data injected into the agent prompt for this call only (it is not stored). See [Context injection](#context-injection). | | `context.category` | `string` | yes | | What kind of data this is, for example `client`, `invoice`, `crm`. Used as the block heading when `label` is absent. | | `context.label` | `string` | no | | Heading shown to the model instead of the category. | | `context.data` | `object | string` | yes | | The data. Objects are serialised to indented JSON. | | `context.priority` | `"high" | "medium" | "low"` | no | `medium` | Blocks are ordered high first; `high` blocks are flagged as high priority to the model. One of: `high`, `medium`, `low`. | | `extraction` | `object` | no | | Extract structured facts from your message, alongside the answer. See [Fact extraction](#fact-extraction). | | `extraction.categories` | `string[]` | yes | | Categories to extract, at least one, for example `["company_info", "decisions", "preferences"]`. | | `extraction.persist` | `boolean` | no | `true` | Store the extracted facts. Set `false` to only return them. | | `options` | `object` | no | | Generation settings. | | `options.reflection` | `boolean` | no | `false` | Run a separate reasoning pass before the answer and return it as `thoughts`. Skipped for small talk. Adds latency and cost. | | `options.temperature` | `number` | no | `0.8` | Sampling temperature, 0 to 2. | | `options.maxTokens` | `number` | no | `1500` | Output ceiling, 100 to 4000. | | `responseFormat` | `object` | no | | Force a structured JSON answer. See [Structured JSON answers](#structured-json-answers). | | `responseFormat.type` | `"json" | "text"` | yes | | `json` forces a JSON object; `text` is the default prose mode. One of: `json`, `text`. | | `responseFormat.fields` | `object` | no | | Map of field name to a description of what it must contain. | | `responseFormat.schema` | `string` | no | | A free-form schema description, as an alternative to `fields`. | | `knowledge` | `object` | no | | Retrieval from the knowledge sources linked to the agent. On by default. | | `knowledge.enabled` | `boolean` | no | `true` | Set `false` to answer without reading any document. | | `knowledge.topK` | `number` | no | `5` | Passages to retrieve, 1 to 20. Questions that ask for a list (who are our clients) use 8 when you do not set it. | | `tools` | `object` | no | | The HTTP tools linked to the agent ([Tools](https://dev.subsidia.protypa.fr/docs/tools.md)). On by default. | | `tools.enabled` | `boolean` | no | `true` | Set `false` so the agent never calls a tool in this turn. | | `tools.toolIds` | `string[]` | no | | Restrict the turn to a subset of the linked tools. Omit to allow all of them. Inactive tools are never offered. | | `consult` | `object` | no | | Let the agent ask other agents of the workspace for a second opinion mid-turn. See [Consulting colleagues](#consulting-colleagues). | | `consult.enabled` | `boolean` | no | `true` | Set `false` for an isolated call. | #### Request examples _curl_ ```bash curl -N https://api.subsidia.protypa.fr/v1/agents/chat/stream \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"message": "Can you check the VAT on the attached invoice?"}' ``` #### Responses **200**: A `text/event-stream`, identical in format to the single-agent stream. ```text data: {"type":"stage","data":{"name":"prepare","phase":"start"}} data: {"type":"stage","data":{"name":"retrieval","phase":"start"}} data: {"type":"stage","data":{"name":"retrieval","phase":"end","ms":412}} data: {"type":"knowledge","data":[{"ref":1,"sourceId":"cm2k9aa7e0003qz0fh8w1p2lb","sourceName":"facture-q3-2026.pdf","section":"Totals","page":2,"chunkPreview":"Total HT : 12 480,00 EUR ...","similarity":0.83,"chunkId":"ck_8f1d2c40"}]} data: {"type":"tool_call","data":{"toolName":"crm_lookup","arguments":{"client":"Atelier Moreau"}}} data: {"type":"tool_result","data":{"toolName":"crm_lookup","arguments":{"client":"Atelier Moreau"},"result":{"status":"active"},"success":true,"durationMs":182}} data: {"type":"token","data":{"delta":"The Q3 invoice is "}} data: {"type":"token","data":{"delta":"12 480 EUR excluding VAT [1]."}} : ping data: {"type":"mood","data":{"agentId":"cm2k8x1ab0001qz0f7h3d9t4e","name":"Claire","emotion":"focus","valence":0.2,"arousal":0.4,"intensity":0.5}} data: {"type":"done","data":{"agentId":"cm2k8x1ab0001qz0f7h3d9t4e","agentName":"Claire","agentRole":"Contrôle de gestion","content":"The Q3 invoice is 12 480 EUR excluding VAT [1].","sessionId":"cm2kc4t9p000bqz0fr6s2a8dk","memoriesSaved":[],"extracted":[],"tokenUsage":{"promptTokens":2140,"completionTokens":14,"totalTokens":2154},"knowledgeUsed":[{"ref":1,"sourceName":"facture-q3-2026.pdf"}],"toolCalls":[{"toolName":"crm_lookup","success":true,"durationMs":182}],"proofId":"pf_a059713a5f1c4e0b8d7a3c21e9b64f10"}} ``` **400**: `message` missing or empty. **404**: The workspace has no agents. ```json { "error": "No agents available in this workspace" } ``` **500**: The agent selection step failed. ```json { "error": "Agent selection failed" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 429 | `rate_limited` | More than 30 calls per minute on this route. | ## Stream events Every event is one line `data: ` followed by a blank line, where the JSON is `{ "type": "", "data": }`. A line starting with a colon (`: ping`) is a keep-alive comment, not an event: ignore it. The stream ends after `done` or `error`; the server then closes the connection, there is no `[DONE]` sentinel. | type | Payload (`data`) | When | What to do | | --- | --- | --- | --- | | `stage` | `{ name, phase, ms?, detail? }`. `name` is one of `prepare`, `retrieval`, `plan`, `websearch`, `reflection`, `answer`; `phase` is `start` or `end`; `ms` is the duration on `end`. | Around each internal step. | Drive a progress indicator ("searching the documents..."). Safe to ignore. | | `knowledge` | Array of passages: `{ ref, sourceId, sourceName, section, page, chunkPreview, similarity, chunkId, origin?, url?, collectionId, dossierName }`. | Once, before the answer, when retrieval found passages. | Keep the list: `[n]` markers in the answer point to `ref`. Show sources as soon as they exist. | | `thought` | `{ delta }` | Only with `options.reflection`, while the agent reasons. | Render in a collapsible "reasoning" area. Never mix with the answer. | | `tool_call` | `{ toolName, arguments }` | The agent decided to call one of your tools. | Show "calling crm_lookup...". The call is executed by Subsidia, not by you. | | `tool_result` | `{ toolName, arguments, result?, success, error?, durationMs }` | The tool call finished. | Log it. `success: false` means your endpoint failed; the agent is told and answers anyway. | | `consult_start` | `{ colleagueName, question }` | The agent asks a colleague for an opinion. | Show who is being asked what. A consultation the user cannot read would be a black box. | | `consult_result` | `{ colleagueId, colleagueName, colleagueRole, question, answer, durationMs, error? }` | The colleague answered. | Display it as a quoted aside; the final answer builds on it. | | `token` | `{ delta }` | Repeatedly, as the answer is generated. | Append `delta` to the visible answer. With `responseFormat.type: "json"` the deltas are raw JSON text; parse the final `contentParsed` instead. | | `mood` | `{ agentId, name, emotion, valence, arousal, intensity, dominance?, narrative? }` | After the answer, text mode. | Optional. The agent's emotional state after the exchange. | | `done` | The complete result of the turn: `agentId`, `agentName`, `agentRole`, `content`, `contentParsed?`, `thoughts?`, `sessionId`, `memoriesSaved`, `extracted`, `tokenUsage`, `knowledgeUsed?`, `toolCalls?`, `consultations?`, `contextUsed?`, `masked?`, `mood?`, `proofId?`. | Last event on success. The exchange is saved and the proof certificate is signed before it is sent. | Replace the streamed text by `content` (it is the canonical, final text), store `sessionId`, keep `proofId` next to your record. | | `error` | `{ message }` | Any failure after the stream opened. | Show the message and stop reading. | > **WARNING: Handle error as a normal event** > Because the stream is already open, an unknown agent, an unknown session, an exhausted allowance and a model outage are all delivered as `{ "type": "error" }` with HTTP status 200. A client that only checks the status code will treat a failed turn as a success and display nothing. > **INFO: Streamed equals saved** > The text you streamed is the text that is saved and signed. Internal markers (the memory blocks, mood tags, and citation markers that point to no passage) are filtered out of the deltas on the way, so `content` in `done` matches what the user saw. The proof certificate in `proofId` can be fetched with [`GET /v1/proofs/:id`](https://dev.subsidia.protypa.fr/docs/proofs.md) using a key that carries the `engine` scope. ## Complete streaming client A client has to do four things: read the body incrementally, split it on blank lines, parse `data:` lines as JSON, and treat `error` as a failure. The examples below print tokens as they arrive, collect citations and tool calls, and return the final `done` result. **Streaming client** _TypeScript_ ```typescript type StreamEvent = | { type: 'stage'; data: { name: string; phase: 'start' | 'end'; ms?: number } } | { type: 'knowledge'; data: Array<{ ref: number; sourceName: string; page: number | null }> } | { type: 'thought' | 'token'; data: { delta: string } } | { type: 'tool_call'; data: { toolName: string; arguments: Record } } | { type: 'tool_result'; data: { toolName: string; success: boolean; durationMs: number; error?: string } } | { type: 'consult_start'; data: { colleagueName: string; question: string } } | { type: 'consult_result'; data: { colleagueName: string; answer: string } } | { type: 'mood'; data: Record } | { type: 'done'; data: { content: string; sessionId: string; proofId?: string; knowledgeUsed?: unknown[] } } | { type: 'error'; data: { message: string } } export async function streamAgent( agentId: string, message: string, sessionId?: string, onToken: (delta: string) => void = (d) => process.stdout.write(d), ) { const res = await fetch('https://api.subsidia.protypa.fr/v1/agents/' + agentId + '/chat/stream', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', Accept: 'text/event-stream', }, body: JSON.stringify({ message, sessionId }), }) // Errors before the stream opens (400, 401, 403, 429) are plain JSON. if (!res.ok || !res.body) throw new Error(res.status + ' ' + (await res.text())) const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' let done: Extract['data'] | undefined while (true) { const { value, done: finished } = await reader.read() if (finished) break buffer += decoder.decode(value, { stream: true }) let boundary: number while ((boundary = buffer.indexOf('\n\n')) !== -1) { const raw = buffer.slice(0, boundary) buffer = buffer.slice(boundary + 2) const line = raw.split('\n').find((l) => l.startsWith('data: ')) if (!line) continue // ": ping" keep-alive comment const event = JSON.parse(line.slice(6)) as StreamEvent switch (event.type) { case 'token': onToken(event.data.delta) break case 'tool_call': console.error('\n[tool] calling ' + event.data.toolName) break case 'tool_result': console.error('[tool] ' + event.data.toolName + (event.data.success ? ' ok' : ' failed') + ' in ' + event.data.durationMs + ' ms') break case 'done': done = event.data break case 'error': throw new Error(event.data.message) } } } if (!done) throw new Error('Stream ended without a done event') return done // done.content is the canonical text; keep done.sessionId and done.proofId } const turn = await streamAgent(process.env.AGENT_ID!, 'What is the Q3 invoice total?') console.log('\n\nsession', turn.sessionId, 'proof', turn.proofId) ``` _Python_ ```python import json, os, sys import requests def stream_agent(agent_id, message, session_id=None): res = requests.post( "https://api.subsidia.protypa.fr/v1/agents/" + agent_id + "/chat/stream", headers={ "Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"], "Accept": "text/event-stream", }, json={"message": message, "sessionId": session_id}, stream=True, timeout=(10, 300), ) # Errors before the stream opens (400, 401, 403, 429) are plain JSON. if res.status_code != 200: raise RuntimeError(str(res.status_code) + " " + res.text) done = None for raw in res.iter_lines(decode_unicode=True): if not raw or not raw.startswith("data: "): continue # blank separator or ": ping" keep-alive event = json.loads(raw[6:]) kind, data = event["type"], event["data"] if kind == "token": sys.stdout.write(data["delta"]) sys.stdout.flush() elif kind == "tool_call": print("\n[tool] calling", data["toolName"], file=sys.stderr) elif kind == "tool_result": status = "ok" if data["success"] else "failed" print("[tool]", data["toolName"], status, file=sys.stderr) elif kind == "done": done = data elif kind == "error": raise RuntimeError(data["message"]) if done is None: raise RuntimeError("Stream ended without a done event") return done # done["content"] is the canonical text turn = stream_agent(os.environ["AGENT_ID"], "What is the Q3 invoice total?") print("\n\nsession", turn["sessionId"], "proof", turn.get("proofId")) ``` ## Context injection `context` lets your application hand the agent live data without storing it anywhere: the customer record on screen, today's figures, the form the user is filling in. Each item becomes a labelled section of the agent's prompt, sorted `high` priority first. It is used for this call only and is not saved to the session. The agent can cite what it used. Items it relied on come back in `contextUsed` (streamed `done` event) with `origin: "context"`, numbered after the document passages in `knowledgeUsed`, so a `[3]` in the answer may point to your data rather than to a document: do not offer to open it as a file. Provenance also applies to memory: a fact the agent saves from context is stored with provenance `data` ([Agent memory](https://dev.subsidia.protypa.fr/docs/agent-memory.md#provenance)). When the call is served by an external model provider, personal data in the context is masked first and restored in the answer, like any other Subsidia call ([Synapse](https://dev.subsidia.protypa.fr/docs/synapse.md)). ```json { "message": "Is this client eligible for the annual discount?", "context": [ { "category": "client", "label": "Client record", "data": { "name": "Atelier Moreau", "since": "2023-04-01" }, "priority": "high" }, { "category": "pricing_rules", "data": "Annual discount applies after 24 months of continuous subscription.", "priority": "medium" } ] } ``` ## Fact extraction With `extraction`, a separate pass reads **your message** (not the answer) and pulls out structured facts in the categories you name. They come back in `extracted` as `{ category, key, value, confidence }` and, unless `persist` is `false`, are stored in the workspace's extracted data. It is a convenient way to turn free-text intake ("I'm Anne, I run a bakery in Lyon with 4 employees, I prefer email") into fields for your CRM in the same call that produces the reply. _Request_ ```json { "message": "I'm Anne, I run a bakery in Lyon with 4 employees. Please email me, not call.", "extraction": { "categories": ["company_info", "preferences"], "persist": false } } ``` _Response (extracted)_ ```json { "extracted": [ { "category": "company_info", "key": "business_type", "value": "bakery", "confidence": 0.95 }, { "category": "company_info", "key": "city", "value": "Lyon", "confidence": 0.93 }, { "category": "company_info", "key": "employees", "value": "4", "confidence": 0.9 }, { "category": "preferences", "key": "contact_channel", "value": "email", "confidence": 0.92 } ] } ``` ## Structured JSON answers Set `responseFormat.type` to `json` and describe what you want in `fields` (a map of field name to description) or as a free-form `schema`. The agent answers with a JSON object only. In the response, `content` is the JSON text and `contentParsed` is the parsed object; if the model's output was not valid JSON, `contentParsed` is absent and you should treat `content` as unparsed. JSON mode changes how the turn behaves: the agent does not write memories, does not consult colleagues, and with `options.reflection` its reasoning is returned as a `thoughts` field inside the JSON instead of a separate pass. Validate the object on your side; the format is an instruction to the model, not a schema enforced by the server. _Request_ ```json { "message": "Is the delivery note in the file for invoice Q3-114?", "responseFormat": { "type": "json", "fields": { "answer": "direct answer, yes or no", "evidence": "the document and page that support the answer, or null", "confidence": "a number between 0 and 1" } } } ``` _Response_ ```json { "agentName": "Claire", "content": "{\"answer\":\"no\",\"evidence\":null,\"confidence\":0.8}", "contentParsed": { "answer": "no", "evidence": null, "confidence": 0.8 }, "sessionId": "cm2kc4t9p000bqz0fr6s2a8dk" } ``` ## Knowledge and citations When retrieval is on, the agent reads passages from the knowledge sources **linked to it** (attach them with the `agentIds` field of [`POST /v1/knowledge-sources`](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md)), within the access rules of the workspace. The retrieved passages are numbered `1..n` in `knowledgeUsed`. In the answer, a claim backed by a passage is followed by its marker, for example `[1]`; markers that match no passage are removed before you see them. Subsidia also marks provenance inside the text, and computes it itself instead of trusting the model: every marker the model wrote is removed, then each anchor an auditor would copy (a number, a date, an amount, a proper name) is checked against the retrieved passages. An anchor found in a passage that discusses the same subject is wrapped as U+27E6 anchor U+27E7 followed by its `[n]` (white square brackets, a verified claim); an anchor found nowhere is wrapped in U+27EC and U+27ED (white tortoise-shell brackets) and is the agent's own, **not** backed by your documents. Reasoning and opinion stay unmarked. If you display answers, either render these spans (a rail or underline is enough) or strip the delimiters; do not leave them in as plain text. A missing document is a reason to mark provenance, not to refuse, so the agent still answers when the base is silent. The passages retrieved for a turn are committed to the proof certificate of the turn, so an auditor can check what the answer was grounded in ([Proof certificates](https://dev.subsidia.protypa.fr/docs/proofs.md)). ## Consulting colleagues With `consult` on (the default), an agent can ask another agent of the workspace for a second opinion before it answers, through an internal tool called `ask_colleague`. Rules that matter for an integration: - It is the colleague's **judgment**, inside its declared mission, that is consulted. The colleague answers from its persona and expertise alone, with no documents and no tools of its own, in at most about 400 tokens, and consultations never nest. - At most **two consultations per turn**, among up to twelve other agents of the workspace. Postes are not colleagues. - Consultations are visible: they stream as `consult_start` and `consult_result` events and come back in `consultations` in the `done` event, each with the question, the answer and the duration. Nothing happens off the record. - It is disabled in JSON mode, and with `consult: { "enabled": false }`. Write each agent's `mission` and `outOfScope` so that colleagues know what to ask whom ([Agents](https://dev.subsidia.protypa.fr/docs/agents.md#design-tips)). ## Tool calls If the agent has [tools](https://dev.subsidia.protypa.fr/docs/tools.md) linked and `tools.enabled` is not `false`, the model may ask for one or more of them while preparing its answer. Subsidia calls your endpoint, reads the JSON you return, feeds it back to the model, and repeats for at most three rounds before the final answer. Each call is reported in `toolCalls` (`toolName`, `arguments`, `result`, `success`, `error`, `durationMs`) and streamed as `tool_call` and `tool_result`. A failing tool does not fail the turn: the agent receives the error and answers with what it has, and it is told never to claim an action it has no tool result for. Use `tools.toolIds` to narrow a turn to the tools relevant to the screen you are on. ## Memory and sessions Each turn is saved in its session ([Sessions](https://dev.subsidia.protypa.fr/docs/sessions.md)): your message and the agent's answer, with its metadata (sources, tool calls, tokens). The agent also recalls and may write long-term memory ([Agent memory](https://dev.subsidia.protypa.fr/docs/agent-memory.md)); the notes written during the turn are returned in `memoriesSaved`. To call an agent without memory, use the Gateway address `agent:+nomemory`. ## Frequently asked **Which is faster, streaming or not?** Total time is the same. Streaming shows the first words after retrieval and any tool calls complete, which is what users perceive. On a small local machine a long turn can take minutes; Subsidia does not time out a local model, so set a generous timeout on your client (300 seconds or more) rather than retrying, because a retry starts a second turn. **Can I use the OpenAI SDK instead?** Yes. Call [`/v1/chat/completions`](https://dev.subsidia.protypa.fr/docs/gateway.md) with `model: "agent:"`. You get the same agent with its memory, documents and tools, a standard streaming protocol, and a proof id in the `x-pulse-proof` header. Use the endpoints on this page when you need citations, `context`, `extraction`, `responseFormat` or the event reference above. **Why is the answer different each time?** Temperature defaults to 0.8 for personas, which keeps them in character. Lower `options.temperature` (0 to 0.3) for factual, repeatable answers. **How do I keep a conversation going across my own users?** Store one `sessionId` per end user in your database. A session can only be continued by the user who created it (here, the owner of the API key), so one key serves your whole application and you map sessions to your users yourself. --- # Sessions > Chat sessions keep the history of a conversation with an agent: create one, continue it, list and replay it. A **session** is the thread of one conversation with an agent: the messages you sent and the answers you got, in order. Sending the same `sessionId` to [Agent chat](https://dev.subsidia.protypa.fr/docs/agent-chat.md) is what makes the second question understand the first. Without it, every call is a fresh conversation. You rarely need to create a session explicitly. A chat call without a `sessionId` creates one and returns its id; keep that id and send it back. The endpoints on this page are for the cases where you want control: naming a session before the first message, listing the conversations a user had with an agent, or replaying a transcript. ## How history is kept - **What is stored.** Each turn adds two messages to the session: yours (`senderType: "user"`) and the agent's (`senderType: "agent"`, with its `agentId`, `agentName` and `agentRole`). The agent message carries a `metadata` object with what happened during the turn: sources used, tool calls, consultations, memories saved, token usage, and the reasoning trace when requested. - **What the agent sees.** For each new message the agent is shown the **20 most recent messages** of the session, not the whole thread. A conversation can grow without limit, but the agent's working context is a sliding window; put what must never be forgotten into [memory](https://dev.subsidia.protypa.fr/docs/agent-memory.md) or into the knowledge base. - **Whose it is.** A session belongs to the user behind the API key that created it. Continuing it with a key of another user is a `404 Session not found`, even inside the same workspace. One application key therefore serves all your end users, and you map your users to session ids on your side. - **Not tied to one agent.** Messages record who spoke, and the history shown to the model prefixes each agent answer with the agent's name. You can send the same `sessionId` to different agents (this is what auto-pick does implicitly) and each one sees the whole exchange. - **Same table as conversations.** Sessions are workspace conversations: they appear in `GET /v1/conversations` and can be removed with `DELETE /v1/conversations/:id` ([Conversations](https://dev.subsidia.protypa.fr/docs/conversations.md)). > **INFO: An empty session is invisible to the list** > `GET /v1/agents/:id/sessions` and `GET /v1/agents/:id/sessions/:sid` only find sessions that contain at least one message **from that agent**. A session you just created and have not chatted in yet is a 404 on the read route until the agent has answered once. ## Typical flow **Multi-turn conversation** _TypeScript_ ```typescript const base = 'https://api.subsidia.protypa.fr/v1/agents/' + agentId const headers = { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', } async function ask(message: string, sessionId?: string) { const res = await fetch(base + '/chat', { method: 'POST', headers, body: JSON.stringify({ message, sessionId }), }) if (!res.ok) throw new Error(res.status + ' ' + (await res.text())) return res.json() } const first = await ask('What is the Q3 invoice total?') const second = await ask('And the previous quarter?', first.sessionId) // "previous" is understood // Replay the whole transcript later. const { session } = await (await fetch(base + '/sessions/' + first.sessionId, { headers })).json() for (const m of session.messages) { console.log(m.senderType === 'user' ? 'You:' : m.agentName + ':', m.content) } ``` _Python_ ```python import os, requests headers = {"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]} base = "https://api.subsidia.protypa.fr/v1/agents/" + agent_id def ask(message, session_id=None): res = requests.post(base + "/chat", headers=headers, json={"message": message, "sessionId": session_id}, timeout=300) res.raise_for_status() return res.json() first = ask("What is the Q3 invoice total?") second = ask("And the previous quarter?", first["sessionId"]) # "previous" is understood session = requests.get(base + "/sessions/" + first["sessionId"], headers=headers).json()["session"] for m in session["messages"]: speaker = "You" if m["senderType"] == "user" else m["agentName"] print(speaker + ":", m["content"]) ``` ## Endpoints All three routes need the `agents` scope on restricted keys and are workspace-scoped: another workspace's session is indistinguishable from a missing one. Creating a session is refused for `readonly` keys. ### POST /v1/agents/:id/sessions Create an empty session for an agent. Creates a session owned by the user of the key and returns it. The agent must exist in the workspace, but the session is not locked to it. Pass the returned `id` as `sessionId` in the chat calls. Nothing is billed. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The agent id. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `title` | `string` | no | `"Chat with "` | A label for the session. Without it, the title is "Chat with" followed by the agent name. (Sessions created implicitly by a chat call are titled with the first 80 characters of the first message.) | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/sessions \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"title": "Moreau - Q3 review"}' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/agents/' + agentId + '/sessions', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ title: 'Moreau - Q3 review' }), }) const { session } = await res.json() console.log(session.id) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/agents/" + agent_id + "/sessions", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={"title": "Moreau - Q3 review"}, ) res.raise_for_status() session_id = res.json()["session"]["id"] ``` #### Responses **201**: The new session. ```json { "session": { "id": "cm2kc4t9p000bqz0fr6s2a8dk", "userId": "cm1u0a0000000qz0fowner001", "workspaceId": "cm1w0b0000000qz0fworksp01", "title": "Moreau - Q3 review", "createdAt": "2026-10-09T09:12:30.004Z", "tokensUsed": 0, "shareToken": null, "isPublic": false, "goalOverride": null } } ``` **404**: No agent with this id in the workspace. ```json { "error": "Agent not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | Unknown agent id. | | 403 | `read_only_key` | The key carries the `readonly` scope. | ### GET /v1/agents/:id/sessions List the sessions in which an agent has spoken. Returns the workspace conversations that contain at least one message from this agent, newest first, each with a `_count.messages`. The list covers the whole workspace, not only your key's sessions. It is not paginated and does not include the messages themselves: fetch a single session for those. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The agent id. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/sessions \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr/v1/agents/" + agent_id + "/sessions", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) for s in res.json()["sessions"]: print(s["id"], s["title"], s["_count"]["messages"], "messages") ``` #### Responses **200**: An object with a `sessions` array. ```json { "sessions": [ { "id": "cm2kc4t9p000bqz0fr6s2a8dk", "userId": "cm1u0a0000000qz0fowner001", "workspaceId": "cm1w0b0000000qz0fworksp01", "title": "Chat with Claire", "createdAt": "2026-10-09T09:12:30.004Z", "tokensUsed": 0, "shareToken": null, "isPublic": false, "goalOverride": null, "_count": { "messages": 4 } } ] } ``` **404**: No agent with this id in the workspace. ```json { "error": "Agent not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | Unknown agent id. | ### GET /v1/agents/:id/sessions/:sid Get a session with its full message history. Returns the session and all its messages in chronological order, with the metadata of each agent message (sources, tool calls, tokens). The session must belong to the workspace and contain a message from the agent in the path; otherwise it is a 404. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The agent id. | | `sid` | `string` | yes | | The session id. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/sessions/$SESSION_ID \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch( 'https://api.subsidia.protypa.fr/v1/agents/' + agentId + '/sessions/' + sessionId, { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY } }, ) if (res.status === 404) throw new Error('Unknown session, or the agent has not answered in it yet') const { session } = await res.json() console.log(session.messages.length, 'messages') ``` #### Responses **200**: The session with a `messages` array. User messages have no metadata; agent messages carry `thoughts`, `memoriesSaved`, `tokenUsage`, `knowledgeUsed`, `contextUsed`, `toolCalls`, `consultations` and `masked` when they apply. ```json { "session": { "id": "cm2kc4t9p000bqz0fr6s2a8dk", "title": "What is the Q3 invoice total?", "createdAt": "2026-10-09T09:12:30.004Z", "messages": [ { "id": "cm2kc4u1x000cqz0f2m9b7e3t", "conversationId": "cm2kc4t9p000bqz0fr6s2a8dk", "userId": null, "senderType": "user", "agentId": null, "agentName": null, "agentRole": null, "content": "What is the Q3 invoice total?", "metadata": null, "createdAt": "2026-10-09T09:12:30.210Z" }, { "id": "cm2kc4y5a000dqz0fk1d4n8vw", "conversationId": "cm2kc4t9p000bqz0fr6s2a8dk", "userId": null, "senderType": "agent", "agentId": "cm2k8x1ab0001qz0f7h3d9t4e", "agentName": "Claire", "agentRole": "Contrôle de gestion", "content": "The Q3 invoice is 12 480 EUR excluding VAT [1].", "metadata": { "tokenUsage": { "promptTokens": 2140, "completionTokens": 14, "totalTokens": 2154 }, "toolCalls": null }, "createdAt": "2026-10-09T09:12:36.774Z" } ] } } ``` **404**: Unknown session, a session of another workspace, or a session in which this agent never spoke. ```json { "error": "Session not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | Unknown session id, wrong workspace, or no message from this agent in it. | ## Deleting a session There is no session-specific delete route. A session is a workspace conversation: delete it with [`DELETE /v1/conversations/:id`](https://dev.subsidia.protypa.fr/docs/conversations.md), which removes the thread and all its messages. Memories the agent wrote during the session are not removed; manage them with [Agent memory](https://dev.subsidia.protypa.fr/docs/agent-memory.md). --- # Tools > Register an HTTP endpoint of yours as a tool an agent can call during a chat, with a signed request, a JSON schema for its arguments and a JSON answer. A **tool** lets an agent reach live data or trigger an action in your systems: a CRM lookup, a stock level, a ticket creation. You register an HTTPS endpoint of your own with a description and a JSON schema for its arguments. When an agent that has the tool linked decides it needs it, **Subsidia calls your endpoint**, signs the request, reads your JSON answer and gives it to the model, which then writes its reply. Your server never talks to the model, and the model never talks to your server: Subsidia is in the middle, which is where masking, limits and logging happen. Tools are the integration point for everything that is not a document. For documents, use [Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md); for facts the agent should simply know, use [memory](https://dev.subsidia.protypa.fr/docs/agent-memory.md). **The agent asks, Subsidia calls. Your endpoint sees a signed HTTP request and returns JSON; the result goes back into the same turn.** Flow: User message -> Agent decides to call a tool -> Subsidia signs and POSTs to your URL -> Your endpoint returns JSON -> Agent writes the answer -> toolCalls in the response ## How a tool is described | Field | Required | What it is | | --- | --- | --- | | `name` | Yes | The function name the model sees. Use `snake_case` letters, digits, `_` and `-`. Any other character run is replaced by `_` before it reaches the model ("CRM lookup!" becomes "CRM_lookup"), so two names that sanitise to the same string collide. The raw name is what your endpoint receives in `X-PulseLabs-Tool`. | | `description` | Yes | What the tool does **and when to call it**. This is the only guidance the model has. Say what comes back and name the situations ("whenever the user asks about a specific customer"). | | `parameters` | Yes | A JSON Schema (`type: "object"`, `properties`, `required`) of the arguments. Describe every property in plain words; the model fills them from the conversation. | | `url` | Yes | The endpoint Subsidia calls. Use HTTPS. See [What Subsidia sends](#what-subsidia-sends). | | `agentIds` | No | On creation, the agents to link the tool to right away. You can also link later. | | `isActive` | Update only | An inactive tool is never offered to a model, without being deleted or unlinked. | > **TIP: A good description is half the integration** > "Look up a customer in the CRM by company name. Returns the account status, the owner and the open invoices. Call it whenever the user asks about a specific customer" gets called at the right time with the right argument. "CRM" gets called at random. Keep arguments few and typed; prefer an `enum` over free text when the set is closed. ## What Subsidia sends Tools registered through the public API are called with **`POST`**. The arguments the model chose, as a JSON object, are the request body. | Part | Value | | --- | --- | | Method | `POST` | | URL | The registered `url`. Placeholders written as `{name}` in the URL are replaced by the URL-encoded value of the argument of the same name, and that argument is then not repeated in the body (`https://api.example.com/clients/{clientId}/status`). | | Body | The arguments as JSON, for example `{"company":"Atelier Moreau","include_invoices":true}`. If every argument was consumed by URL placeholders, or the model passed none, **there is no body and no `Content-Type`**: treat a missing body as `{}`. | | `Content-Type` | `application/json`, when there is a body. | | `X-PulseLabs-Tool` | The tool name as registered. | | `X-PulseLabs-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the string `{"tool":"","arguments":}` (compact JSON, exactly as `JSON.stringify` writes it), keyed with the tool secret. See [Verify the signature](#verify-the-signature). | | `User-Agent` | `PulseLabs-Tools/1.0` | | Timeout | 15 seconds for the whole call. There is no retry. | | Redirects | Not followed: a 3xx is a failure. | ## What your endpoint must return - **Status 2xx** means success. Anything else (4xx, 5xx, a timeout, a network error) is a failure: the model is told the call failed, with your response text as the reason, and answers with what it has. The turn itself does not fail. - **Body: JSON.** It is parsed and handed to the model as structured data. A body that is not JSON is handed over as plain text, which also works for short answers. - **Keep it small and relevant.** Only the first **16 KB** of the response is read; beyond that it is cut (and a cut JSON document is no longer valid JSON). Return what the model needs to answer, not a database row: names, statuses, amounts, dates, in readable keys. The model reads your keys, so `"status": "active"` works better than `"s": 1`. - **Return errors the model can use.** `404` with `{"error": "No customer named Moreau. Did you mean Atelier Moreau?"}` lets the agent ask the user the right question. - **Be quick.** The user is waiting on the answer. Fifteen seconds is a ceiling, not a target. > **WARNING: Reads are easy, writes need care** > A model decides when to call a tool, from text a user typed. Treat every argument as **untrusted input**, as you would a form field: validate it, authorise it against your own rules, and never build SQL or shell commands from it. For tools that change something (create a ticket, send an email), make the call idempotent, return what was done, and consider a two-step design (a tool that prepares, a human who confirms). The agent is told never to claim an action it has no tool result for, but your endpoint is the last line of defence. ## Security - **Verify the signature on every request.** It proves the call comes from your Subsidia workspace and that the arguments were not altered. Compare in constant time. The signature has no timestamp, so it does not by itself stop a captured request from being replayed: for writes, make operations idempotent. - **The secret is shown once.** Registration returns `secret` (`toolsec_` followed by 64 hex characters) in the creation response, and never again; reads omit it. There is no rotation endpoint: to change a secret, create a new tool with the same URL, link it, and delete the old one. - **Use HTTPS** with a certificate from a public authority. Arguments contain what the user said. - **Masking depends on where the tool lives.** A tool on your own network (localhost, private ranges such as `10.x`, `172.16-31.x`, `192.168.x`, link-local addresses, `.local` hosts) receives the arguments **as the model chose them**, with real names and values, because internal automations break otherwise. A tool on the public internet receives the same personal-data shielding as any external model call: personal values in the arguments are replaced by consistent placeholders, and your response is restored before the agent reads it. Design public endpoints to accept placeholders without failing ([Synapse](https://dev.subsidia.protypa.fr/docs/synapse.md)). - **Least privilege.** Give the tool a read-only credential on your side, scoped to what the agent needs. Do not put API keys of your own in the `parameters` schema or the description: both are shown to the model. - **Linking is granting.** A tool linked to an agent can be triggered by anyone who can chat with that agent. Link sensitive tools only to the agents that need them. ## Verify the signature The signature covers the logical call, `{"tool": , "arguments": }`, serialised as compact JSON. To verify it, rebuild that string from the `X-PulseLabs-Tool` header and the parsed body, HMAC it with your secret, and compare with `X-PulseLabs-Signature`. Rebuild with the same compact serialisation that `JSON.stringify` produces (no spaces, keys in the original order, non-ASCII characters left as they are). Parsing and re-serialising keeps key order in both Node.js and Python 3.7+. > **INFO: Floating-point numbers in other languages** > Rebuilding the string in a language that formats numbers differently from JavaScript (very large integers, numbers like `1e21`, trailing `.0`) can produce a different string than the one that was signed. If your tool takes such numbers, declare them as strings in `parameters`. ## Worked example: a CRM lookup Goal: an agent named Claire can answer "is Atelier Moreau up to date on payments?" from your CRM. Three steps: write the endpoint, register it, link it. ### 1. The endpoint **Your server** _Node.js (Express)_ ```typescript import crypto from 'node:crypto' import express from 'express' const SECRET = process.env.SUBSIDIA_TOOL_SECRET! // toolsec_... returned once at registration const app = express() app.use(express.json()) function signatureIsValid(req: express.Request): boolean { const tool = req.header('X-PulseLabs-Tool') ?? '' const received = req.header('X-PulseLabs-Signature') ?? '' // No body at all means no arguments: express.json() leaves {} in that case. const payload = JSON.stringify({ tool, arguments: req.body ?? {} }) const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(payload).digest('hex') const a = Buffer.from(received) const b = Buffer.from(expected) return a.length === b.length && crypto.timingSafeEqual(a, b) } // Stand-in for your real CRM client. async function findCustomer(company: string) { const db: Record = { 'atelier moreau': { company: 'Atelier Moreau', status: 'active', owner: 'S. Bernard', openInvoices: [{ number: 'Q3-114', amountEur: 12480, dueDate: '2026-10-15' }] }, } return db[company.trim().toLowerCase()] ?? null } app.post('/crm-lookup', async (req, res) => { if (!signatureIsValid(req)) return res.status(401).json({ error: 'bad signature' }) const { company, include_invoices } = req.body ?? {} if (typeof company !== 'string' || !company.trim()) { return res.status(400).json({ error: 'company is required' }) } const customer: any = await findCustomer(company) if (!customer) return res.status(404).json({ error: 'No customer named ' + company }) // Return only what the agent needs to answer. const { openInvoices, ...account } = customer res.json(include_invoices ? { ...account, openInvoices } : account) }) app.listen(3000) ``` _Python (FastAPI)_ ```python import hashlib import hmac import json import os from fastapi import FastAPI, HTTPException, Request SECRET = os.environ["SUBSIDIA_TOOL_SECRET"] # toolsec_... returned once at registration app = FastAPI() CUSTOMERS = { "atelier moreau": { "company": "Atelier Moreau", "status": "active", "owner": "S. Bernard", "openInvoices": [{"number": "Q3-114", "amountEur": 12480, "dueDate": "2026-10-15"}], } } def verify(tool: str, arguments: dict, received: str) -> bool: # Compact JSON, non-ASCII kept as is: the same string JSON.stringify produced. payload = json.dumps({"tool": tool, "arguments": arguments}, separators=(",", ":"), ensure_ascii=False) expected = "sha256=" + hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(received, expected) @app.post("/crm-lookup") async def crm_lookup(request: Request): raw = await request.body() arguments = json.loads(raw) if raw else {} # no body means no arguments tool = request.headers.get("x-pulselabs-tool", "") if not verify(tool, arguments, request.headers.get("x-pulselabs-signature", "")): raise HTTPException(status_code=401, detail="bad signature") company = str(arguments.get("company", "")).strip() if not company: raise HTTPException(status_code=400, detail="company is required") customer = CUSTOMERS.get(company.lower()) if customer is None: raise HTTPException(status_code=404, detail="No customer named " + company) account = {k: v for k, v in customer.items() if k != "openInvoices"} if arguments.get("include_invoices"): account["openInvoices"] = customer["openInvoices"] return account ``` ### 2. Register it Call `POST /v1/tools` (reference below) once. Keep the `secret` from the response in your secret manager and give it to the endpoint above as `SUBSIDIA_TOOL_SECRET`. Passing `agentIds` links the tool at the same time. _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/tools \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "crm_lookup", "description": "Look up a customer in the CRM by company name. Returns the account status, the owner and, on request, the open invoices. Call it whenever the user asks about a specific customer.", "parameters": { "type": "object", "properties": { "company": { "type": "string", "description": "Company name as the user wrote it." }, "include_invoices": { "type": "boolean", "description": "Also return the open invoices. Default false." } }, "required": ["company"] }, "url": "https://tools.example.com/crm-lookup", "agentIds": ["'"$AGENT_ID"'"] }' ``` ### 3. Ask a question Chat with the agent normally. Tools are on by default; the call shows up in `toolCalls` (and as `tool_call` / `tool_result` events when streaming). _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/agents/' + agentId + '/chat', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ message: 'Is Atelier Moreau up to date on payments? Show the open invoices.' }), }) const turn = await res.json() console.log(turn.content) console.log(turn.toolCalls) // [{ toolName: 'crm_lookup', arguments: { company: 'Atelier Moreau', include_invoices: true }, // result: { company: 'Atelier Moreau', status: 'active', ... }, success: true, durationMs: 143 }] ``` _Python_ ```python import os, requests turn = requests.post( "https://api.subsidia.protypa.fr/v1/agents/" + agent_id + "/chat", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={"message": "Is Atelier Moreau up to date on payments? Show the open invoices."}, timeout=300, ).json() print(turn["content"]) for call in turn.get("toolCalls", []): print(call["toolName"], call["arguments"], "ok" if call["success"] else call.get("error")) ``` > **TIP: Debugging a tool that never fires** > Check, in order: the tool is `isActive`; it is linked to the agent you are chatting with (`GET /v1/tools` lists `agents` for each tool); the request does not set `tools.enabled` to `false` or a `toolIds` list that excludes it; and the description tells the model when to use it. A tool that is called but fails shows `success: false` and the reason in `error` in `toolCalls`. ## How calls are bounded Within one turn the model can ask for tools up to **three rounds** (a round may contain several calls), then it must answer. Each call has a 15-second timeout and a 16 KB response cap. Tool calls are part of the turn: they are metered with it and recorded in the audit journal of the workspace. ## Endpoints All tool routes need the `agents` scope on restricted keys (`/v1/tools*` and `/v1/agents/*` share it). Create, update, delete and link are refused for `readonly` keys. Tools are scoped to the workspace of the key. ### POST /v1/tools Register a tool. Creates the tool, generates its signing secret and optionally links it to agents. The secret is returned **once**, in this response. Registration does not call your URL, so a typo is only discovered at the first use: test the endpoint yourself first. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | `string` | yes | | Function name shown to the model. Prefer `snake_case`. | | `description` | `string` | yes | | What the tool does and when to call it. | | `parameters` | `object` | yes | | JSON Schema of the arguments (`type: "object"`, `properties`, `required`). | | `url` | `string` | yes | | HTTPS endpoint Subsidia will POST to. | | `agentIds` | `string[]` | no | | Ids of the agents of your workspace to link the tool to. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/tools \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "crm_lookup", "description": "Look up a customer in the CRM by company name. Call it whenever the user asks about a specific customer.", "parameters": {"type":"object","properties":{"company":{"type":"string"}},"required":["company"]}, "url": "https://tools.example.com/crm-lookup" }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/tools', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'crm_lookup', description: 'Look up a customer in the CRM by company name. Call it whenever the user asks about a specific customer.', parameters: { type: 'object', properties: { company: { type: 'string', description: 'Company name as the user wrote it.' } }, required: ['company'], }, url: 'https://tools.example.com/crm-lookup', agentIds: [agentId], }), }) const { tool, secret } = await res.json() // Store "secret" now: it is never returned again. ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/tools", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "name": "crm_lookup", "description": "Look up a customer in the CRM by company name. Call it whenever the user asks about a specific customer.", "parameters": { "type": "object", "properties": {"company": {"type": "string", "description": "Company name as the user wrote it."}}, "required": ["company"], }, "url": "https://tools.example.com/crm-lookup", "agentIds": [agent_id], }, ) res.raise_for_status() body = res.json() tool_id, secret = body["tool"]["id"], body["secret"] # store the secret now ``` #### Responses **201**: The tool, and its signing secret. ```json { "tool": { "id": "cm2kd2m7r000fqz0fa4t6y9ch", "workspaceId": "cm1w0b0000000qz0fworksp01", "name": "crm_lookup", "description": "Look up a customer in the CRM by company name. Returns the account status, the owner and the open invoices. Call it whenever the user asks about a specific customer.", "parameters": { "type": "object", "properties": { "company": { "type": "string", "description": "Company name as the user wrote it." } }, "required": ["company"] }, "url": "https://tools.example.com/crm-lookup", "method": "POST", "paramLocations": null, "isActive": true, "createdAt": "2026-10-09T09:30:12.447Z", "updatedAt": "2026-10-09T09:30:12.447Z" }, "secret": "toolsec_9f2c4b7a1e6d3085c7b1a94f2e0d68b35a7c19e4d2f0836b5c8a1e7d94f203ab" } ``` **400**: A required field is missing. ```json { "error": "name, description, parameters, and url are required" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | name, description, parameters or url is missing. | | 403 | `read_only_key` | The key carries the `readonly` scope. | | 403 | `scope_denied` | A restricted key without the `agents` scope. | ### GET /v1/tools List the tools of the workspace. Newest first. Each tool lists the agents it is linked to (`id` and `name`). The secret is never included. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/tools -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _Python_ ```python import os, requests tools = requests.get( "https://api.subsidia.protypa.fr/v1/tools", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json()["tools"] for t in tools: print(t["name"], "active" if t["isActive"] else "inactive", [a["name"] for a in t["agents"]]) ``` #### Responses **200**: An object with a `tools` array. ```json { "tools": [ { "id": "cm2kd2m7r000fqz0fa4t6y9ch", "workspaceId": "cm1w0b0000000qz0fworksp01", "name": "crm_lookup", "description": "Look up a customer in the CRM by company name. Returns the account status, the owner and the open invoices. Call it whenever the user asks about a specific customer.", "parameters": { "type": "object", "properties": { "company": { "type": "string", "description": "Company name as the user wrote it." } }, "required": ["company"] }, "url": "https://tools.example.com/crm-lookup", "method": "POST", "paramLocations": null, "isActive": true, "createdAt": "2026-10-09T09:30:12.447Z", "updatedAt": "2026-10-09T09:30:12.447Z", "agents": [{ "id": "cm2k8x1ab0001qz0f7h3d9t4e", "name": "Claire" }] } ] } ``` ### GET /v1/tools/:id Get one tool. Returns the tool with its linked agents. The secret is not returned. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The tool id. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/tools/$TOOL_ID -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` #### Responses **200**: The tool. ```json { "tool": { "id": "cm2kd2m7r000fqz0fa4t6y9ch", "workspaceId": "cm1w0b0000000qz0fworksp01", "name": "crm_lookup", "description": "Look up a customer in the CRM by company name. Returns the account status, the owner and the open invoices. Call it whenever the user asks about a specific customer.", "parameters": { "type": "object", "properties": { "company": { "type": "string", "description": "Company name as the user wrote it." } }, "required": ["company"] }, "url": "https://tools.example.com/crm-lookup", "method": "POST", "paramLocations": null, "isActive": true, "createdAt": "2026-10-09T09:30:12.447Z", "updatedAt": "2026-10-09T09:30:12.447Z", "agents": [] } } ``` **404**: Unknown tool. ```json { "error": "Tool not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | No tool with this id in the workspace. | ### PUT /v1/tools/:id Update a tool. Partial update: send only what changes. Use `isActive: false` to take a tool out of service without losing its links. The secret and the linked agents are not changed here. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The tool id. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | `string` | no | | New function name. | | `description` | `string` | no | | New description. | | `parameters` | `object` | no | | New JSON Schema. | | `url` | `string` | no | | New endpoint. | | `isActive` | `boolean` | no | | Enable or disable the tool. | #### Request examples _curl_ ```bash curl -X PUT https://api.subsidia.protypa.fr/v1/tools/$TOOL_ID \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" -H "Content-Type: application/json" \ -d '{"isActive": false}' ``` #### Responses **200**: The updated tool. ```json { "tool": { "id": "cm2kd2m7r000fqz0fa4t6y9ch", "workspaceId": "cm1w0b0000000qz0fworksp01", "name": "crm_lookup", "description": "Look up a customer in the CRM by company name. Returns the account status, the owner and the open invoices. Call it whenever the user asks about a specific customer.", "parameters": { "type": "object", "properties": { "company": { "type": "string", "description": "Company name as the user wrote it." } }, "required": ["company"] }, "url": "https://tools.example.com/crm-lookup", "method": "POST", "paramLocations": null, "isActive": false, "createdAt": "2026-10-09T09:30:12.447Z", "updatedAt": "2026-10-09T09:30:12.447Z" } } ``` **404**: Unknown tool. ```json { "error": "Tool not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | No tool with this id in the workspace. | ### DELETE /v1/tools/:id Delete a tool. Deletes the tool and its links to agents. Agents stop offering it immediately. This also invalidates its secret. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The tool id. | #### Request examples _curl_ ```bash curl -X DELETE https://api.subsidia.protypa.fr/v1/tools/$TOOL_ID -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` #### Responses **204**: Deleted. No body. **404**: Unknown tool. ```json { "error": "Tool not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | No tool with this id in the workspace. | ### POST /v1/agents/:id/tools/:toolId Link a tool to an agent. Makes the tool available to this agent in its next turn. Linking twice is harmless. Returns the agent with the list of its tools (`id` and `name`). - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The agent id. | | `toolId` | `string` | yes | | The tool id. | #### Request examples _curl_ ```bash curl -X POST https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/tools/$TOOL_ID \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/agents/" + agent_id + "/tools/" + tool_id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) res.raise_for_status() print([t["name"] for t in res.json()["agent"]["tools"]]) ``` #### Responses **200**: The agent, with a `tools` array. ```json { "agent": { "id": "cm2k8x1ab0001qz0f7h3d9t4e", "name": "Claire", "tools": [{ "id": "cm2kd2m7r000fqz0fa4t6y9ch", "name": "crm_lookup" }] } } ``` **404**: `Agent not found` or `Tool not found`. ```json { "error": "Tool not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | The agent or the tool does not exist in the workspace. | ### DELETE /v1/agents/:id/tools/:toolId Unlink a tool from an agent. The tool is kept and stays linked to its other agents. The agent stops offering it from its next turn. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `agents` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The agent id. | | `toolId` | `string` | yes | | The tool id. | #### Request examples _curl_ ```bash curl -X DELETE https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/tools/$TOOL_ID \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` #### Responses **204**: Unlinked. No body. **404**: Unknown agent. ```json { "error": "Agent not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | No agent with this id in the workspace. | ## Schema reference `parameters` is a standard JSON Schema object, passed to the model unchanged. Supported in practice: `type` (`string`, `number`, `integer`, `boolean`, `array`, `object`), `properties`, `required`, `enum`, `items`, `description`. Always set `"type": "object"` at the top. Example: _parameters_ ```json { "type": "object", "properties": { "company": { "type": "string", "description": "Company name as the user wrote it." }, "include_invoices": { "type": "boolean", "description": "Also return the open invoices. Default false." } }, "required": ["company"] } ``` --- # Conversations > Multi-agent debates: put a question to all the agents of a workspace, let them argue for several rounds, and get a structured synthesis. A **conversation** is a round table. You create one with a title, then submit a question: the agents of your workspace each give a first position, challenge each other over several rounds, and a synthesis comes back with the points of agreement, the risks, the conditions and a recommended action. It is the endpoint for decisions where one voice is not enough: a go/no-go, a pricing choice, a contract clause with commercial and legal readings. For a dialogue with a single persona, use [Agent chat](https://dev.subsidia.protypa.fr/docs/agent-chat.md) instead. A debate is much heavier: it runs several model calls per agent per round. **One call to POST /v1/conversations/:id/message runs the whole debate and returns once the synthesis is ready.** Flow: Your question -> Initial positions (round 0) -> Debate rounds 1 to 3 -> Convergence check -> Synthesis ## How a debate runs 1. **Who sits at the table.** Up to **five** regular agents of the workspace (any agent not flagged `isDevilsAdvocate`) plus the workspace's **devil's advocate**, who is created automatically on first use and always joins. If the workspace has more than five regular agents, only the first five are seated. The workspace needs at least one regular agent, otherwise the call fails. 2. **Round 0.** Each agent answers the question in character, drawing on its memories and the documents linked to it. 3. **Debate rounds.** Up to **three** rounds in which each agent reads the others' positions and responds. After each round a convergence score is computed; at **0.85 or above** the debate stops early. 4. **Synthesis.** A final pass summarises where the table agrees, what was disputed, what could go wrong, and what to do. `totalRounds` is the number of debate rounds actually run. Agents form memories during a debate like they do in chat ([Agent memory](https://dev.subsidia.protypa.fr/docs/agent-memory.md)), and the same conversation can receive several questions: each new message continues the same table with the earlier exchange as history. > **WARNING: Synchronous and slow** > The message endpoint holds the connection until the synthesis is ready. Expect tens of seconds with a cloud model and minutes on a small local machine. Set a client timeout of at least five minutes, do not retry on timeout (a retry runs a second debate and is billed again), and call it from a background job rather than from a user-facing request. The route is limited to 30 calls per minute. > **INFO: Debates are billed as one workload** > A debate consumes the workspace allowance according to the model usage of all its calls, which is far more than a chat turn. The call is refused before it starts if the allowance cannot cover a minimum debate. See [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md). ## Authentication and scopes Use a developer API key ([Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md)). On a restricted key, the conversation routes need the `conversations` scope in addition to your capability scope. A `readonly` key can list and read conversations but cannot create, run or delete them. Conversations are scoped to the workspace of the key. ## Endpoints ### POST /v1/conversations Create a conversation. Creates an empty conversation (a table with no question yet). The title is shown in lists and is given to the agents as the topic of the debate, so write it as the question or the decision at stake, not as "Session 1". - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `conversations` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `title` | `string` | yes | | Topic of the conversation. At least one character. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/conversations \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"title": "Should we open a second office in Lyon?"}' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/conversations', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ title: 'Should we open a second office in Lyon?' }), }) const { conversation } = await res.json() console.log(conversation.id) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/conversations", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={"title": "Should we open a second office in Lyon?"}, ) res.raise_for_status() conversation_id = res.json()["conversation"]["id"] ``` #### Responses **201**: The created conversation. ```json { "conversation": { "id": "cm2ke7p3b000gqz0fz5c1v8mn", "userId": "cm1u0a0000000qz0fowner001", "workspaceId": "cm1w0b0000000qz0fworksp01", "title": "Should we open a second office in Lyon?", "createdAt": "2026-10-09T10:02:44.318Z", "tokensUsed": 0, "shareToken": null, "isPublic": false, "goalOverride": null } } ``` **400**: `title` is missing or empty. #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | The body has no `title`, or it is empty. | | 403 | `read_only_key` | The key carries the `readonly` scope. | | 403 | `scope_denied` | A restricted key without the `conversations` scope. | ### POST /v1/conversations/:id/message Submit a question and run the debate. Stores your message, runs the full debate (see [How a debate runs](#how-a-debate-runs)) and returns the synthesis. The individual positions are not in this response: they are saved as messages of the conversation, retrieve them with `GET /v1/conversations/:id`. There is no streaming variant on the public API. Put everything the table needs in the message: the numbers, the options, the constraint. Agents read the documents linked to them, but a debate is only as good as its question. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `conversations` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The conversation id. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `content` | `string` | yes | | The question or statement the agents debate. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/conversations/$CONVERSATION_ID/message \ --max-time 600 \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"content": "We have 3 letters of intent in Lyon and 5 months of cash buffer. Open an office now, wait, or hire remotely first?"}' ``` _TypeScript_ ```typescript const res = await fetch( 'https://api.subsidia.protypa.fr/v1/conversations/' + conversationId + '/message', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ content: 'We have 3 letters of intent in Lyon and 5 months of cash buffer. Open an office now, wait, or hire remotely first?', }), signal: AbortSignal.timeout(10 * 60 * 1000), }, ) if (!res.ok) throw new Error(res.status + ' ' + (await res.text())) const { synthesis, totalRounds } = await res.json() console.log(totalRounds, 'rounds') console.log(synthesis.recommendedAction) console.log('Conditions:', synthesis.conditions) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/conversations/" + conversation_id + "/message", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={"content": "We have 3 letters of intent in Lyon and 5 months of cash buffer. Open an office now, wait, or hire remotely first?"}, timeout=600, ) res.raise_for_status() result = res.json() print(result["totalRounds"], "rounds") print(result["synthesis"]["recommendedAction"]) ``` #### Responses **200**: The synthesis, the number of rounds and the token accounting. `synthesis.agentInsights` has one entry per participant, the devil's advocate included; `shifted` is true when that agent changed position during the debate. `confidenceLevel` and `urgency` are short free-text labels (for example "low", "medium", "high"). `biasNotes` and `raw` (the unparsed synthesis text) may also be present. ```json { "synthesis": { "summary": "The table favours opening in Lyon, but only after the second sales hire is confirmed.", "debateEvolution": "Claire moved from opposed to conditional after the cash-flow figures were put on the table.", "devilsAdvocateMainAttack": "A second office doubles fixed costs before the pipeline in Lyon is proven.", "agentInsights": [ { "name": "Claire", "role": "Contrôle de gestion", "keyPoint": "Break-even needs 14 months at current margins.", "stance": "conditional", "shifted": true }, { "name": "Marc", "role": "Directeur commercial", "keyPoint": "Three signed letters of intent in Lyon.", "stance": "for" } ], "consensus": ["The Lyon market is real.", "Do not sign a lease before the hire."], "riskPoints": ["Fixed-cost exposure", "Management attention split across two sites"], "recommendedAction": "Hire the Lyon sales lead first; revisit the lease in six months.", "conditions": ["Sales lead hired", "Cash reserve above three months"], "confidenceLevel": "medium", "urgency": "low", "roundCount": 2, "raw": "..." }, "totalRounds": 2, "tokenUsage": { "totalTokensUsed": 41230, "remainingBalance": 958770 } } ``` **400**: `content` is missing or empty. **404**: No conversation with this id in the workspace. ```json { "error": "Conversation not found" } ``` **500**: The debate could not run. The message says why: no agents in the workspace (`No agents found. Please create agents first.`), an exhausted allowance, or a model failure. ```json { "error": "No agents found. Please create agents first." } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | Empty or missing `content`. | | 404 | | Unknown conversation, or it belongs to another workspace. | | 500 | | No regular agent in the workspace, insufficient allowance, or model failure. Unlike chat, an exhausted allowance is not a 402 here: read the error message. | | 429 | `rate_limited` | More than 30 calls per minute on this route, or the key's own limit. | #### Notes The first message of a conversation uses the conversation title as the topic when the title was set by you. Reuse a conversation to ask follow-ups on the same decision ("and if we delay the lease by six months?"): the table remembers the earlier exchange. ### GET /v1/conversations List the conversations of the workspace. Newest first, each with `_count.messages`. This list is the whole workspace table: it contains debates **and** the sessions created by [Agent chat](https://dev.subsidia.protypa.fr/docs/sessions.md), since both are conversations. It is not paginated. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `conversations` #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/conversations -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr/v1/conversations", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) for c in res.json()["conversations"]: print(c["id"], c["title"], c["_count"]["messages"]) ``` #### Responses **200**: An object with a `conversations` array. ```json { "conversations": [ { "id": "cm2ke7p3b000gqz0fz5c1v8mn", "userId": "cm1u0a0000000qz0fowner001", "workspaceId": "cm1w0b0000000qz0fworksp01", "title": "Should we open a second office in Lyon?", "createdAt": "2026-10-09T10:02:44.318Z", "tokensUsed": 0, "shareToken": null, "isPublic": false, "goalOverride": null, "_count": { "messages": 9 } } ] } ``` ### GET /v1/conversations/:id Get a conversation with its messages and debate rounds. Returns the full transcript: every message in chronological order and the debate rounds with their convergence scores. Message `senderType` is one of `user`, `agent`, `devils-advocate` and `synthesizer`. Agent messages carry `metadata.phase` (`initial` for round 0, `debate` for later rounds), `metadata.roundNumber` and `metadata.isDevilsAdvocate`; the `synthesizer` message holds the synthesis as `content` and its parsed fields in `metadata`. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `conversations` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The conversation id. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/conversations/$CONVERSATION_ID \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/conversations/' + conversationId, { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const { conversation } = await res.json() for (const m of conversation.messages) { if (m.senderType === 'agent' || m.senderType === 'devils-advocate') { console.log('[round ' + m.metadata?.roundNumber + '] ' + m.agentName + ': ' + m.content) } } console.log(conversation.debates.map((d: any) => d.roundNumber + ': ' + d.convergenceScore)) ``` #### Responses **200**: The conversation with `messages` and `debates`. ```json { "conversation": { "id": "cm2ke7p3b000gqz0fz5c1v8mn", "title": "Should we open a second office in Lyon?", "messages": [ { "id": "cm2ke8a1c000hqz0f3k2m9d7x", "senderType": "user", "content": "We have 3 letters of intent in Lyon...", "createdAt": "2026-10-09T10:03:01.002Z" }, { "id": "cm2ke8d4e000iqz0fp8w6n1ba", "senderType": "agent", "agentId": "cm2k8x1ab0001qz0f7h3d9t4e", "agentName": "Claire", "agentRole": "Contrôle de gestion", "content": "Break-even needs 14 months at current margins...", "metadata": { "phase": "initial", "roundNumber": 0, "isDevilsAdvocate": false }, "createdAt": "2026-10-09T10:03:19.554Z" } ], "debates": [ { "id": "cm2ke9z8f000jqz0fq4t1h6cy", "roundNumber": 1, "convergenceScore": 0.62, "createdAt": "2026-10-09T10:03:40.101Z" }, { "id": "cm2kea5ng000kqz0fb7r3e9wk", "roundNumber": 2, "convergenceScore": 0.88, "createdAt": "2026-10-09T10:04:02.667Z" } ] } } ``` **404**: Unknown conversation. ```json { "error": "Not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | No conversation with this id in the workspace. | ### DELETE /v1/conversations/:id Delete a conversation and its messages. Removes the conversation and its messages. It works for chat sessions. Memories the agents formed along the way are not removed. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `conversations` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The conversation id. | #### Request examples _curl_ ```bash curl -X DELETE https://api.subsidia.protypa.fr/v1/conversations/$CONVERSATION_ID \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` #### Responses **204**: Deleted. No body. **404**: Unknown conversation. ```json { "error": "Conversation not found" } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 404 | | No conversation with this id in the workspace. | | 403 | `read_only_key` | The key carries the `readonly` scope. | #### Notes The route deletes the messages and the conversation row only. A conversation that has run a debate also has debate-round records attached, which this route does not remove, so deleting such a conversation can fail with a 500. Chat sessions have no such records. ## Working with debates - **Seat the right people.** The cast is the first five regular agents of the workspace, in creation order. Keep a workspace for debates lean and deliberately diverse (finance, sales, legal, operations), and rely on the built-in devil's advocate rather than creating one. - **Read the dissent, not just the summary.** `devilsAdvocateMainAttack`, `riskPoints` and `conditions` are where a debate earns its cost. A `consensus` reached in one round with a high convergence score on a weak question is a sign that your agents are too similar. - **Store the transcript.** `GET /v1/conversations/:id` is the record of who said what in which round. Keep the conversation id next to the decision it informed. - **A debate informs, a human decides.** The synthesis is a structured opinion, not a verdict. --- # Postes > Run a poste (a job-role agent such as the Auditeur de pièces) on one dossier and get a structured verdict, RAS or a list of findings, each tied to a piece. A **poste** is an agent configured as a job description rather than as a conversation partner: a bounded task, run on a precise input, answering with a precise output. It is what a firm gives to "the person who rereads the file before it leaves". Two postes ship with Subsidia, built for accounting, legal and notarial practices; a firm can also define its own ("maison" postes) in the console. The defining rule of a poste is that it **signals, it does not conclude**. It reports what is missing or contradictory and names the piece each finding comes from; a member of the firm decides and signs. That makes it safe to put in a pipeline: it never gives advice, never rewrites, never contacts anyone, and never invents a piece, an amount or a date. ## Why a separate endpoint A poste is stored as an agent, but [Agent chat](https://dev.subsidia.protypa.fr/docs/agent-chat.md) refuses it: free conversation breaks the rules that make a poste reliable (chat encourages answering from general knowledge when documents are silent, exactly what a poste must never do). The only way in is `POST /v1/postes/:key/run`, which runs the poste against **one dossier** and returns a fixed JSON contract. It is the same contract the `agent.run` node of [Reflex](https://dev.subsidia.protypa.fr/docs/reflex.md) reads, so a flow and an external caller can never disagree about what a poste said. To wire it into an external pipeline (n8n, LangChain, a cron job, a document-management system), call this endpoint when a piece arrives and branch on `status`. ## Available postes | Key | Name | What it does | Woken by | | --- | --- | --- | --- | | `auditeur-pieces` | Auditeur de pièces | Compares the pieces present in a dossier with what that kind of dossier should contain (using the control list set for it) and reports what is **missing** and what **contradicts itself** from one piece to another: a date, an amount or a name that differs. | A piece arriving in a watched dossier. | | `relecteur-avant-envoi` | Relecteur avant envoi | Takes a draft letter and the pieces of the dossier, lists every figure, date, amount and name of the draft, and reports those that **do not appear** in the pieces. A figure found in the pieces is not a finding. It never rewrites the draft or comments on tone. | A draft produced before it is sent. | | `` | A "maison" poste | A poste your firm created in the console. Its key is the agent id (see [`GET /v1/agents`](https://dev.subsidia.protypa.fr/docs/agents.md): postes have empty `personality` and `decisionStyle`). | Defined by the firm. | > **INFO: Install before you call** > A shipped poste only exists in your workspace once someone has installed it from **Light, Les postes** in the Subsidia app. The API does not install postes. Calling the key of a poste that is not installed is a `404` that tells you which one to install. ## Inputs - **`collectionId`** (required): the dossier, a Cortex collection of the workspace ([Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md)), that the poste reads. The poste only sees pieces of that dossier. If the poste has its own dossier scope in the console, the run is limited to the overlap: the scope can narrow what is read, never widen it. The usual access rules of the workspace apply on top. - **`message`** (optional): a custom task instruction. Without it, the poste runs its standard pass ("carry out your mission on this dossier"). Use it to point at a piece ("check the draft saved as lettre-client-v3") or to narrow the pass; you cannot change the output format. What a run does **not** use: memory (a poste neither reads nor writes any, so two runs on the same dossier do not influence each other), tools, and colleague consultation. Only the dossier's documents and the poste's own rules. ## Output | Field | Type | Meaning | | --- | --- | --- | | `status` | `"RAS"` or `"signalements"` | `RAS` ("rien à signaler"): nothing to report. `signalements`: at least one finding. | | `signalements` | object[] | The findings; empty when `status` is `RAS`. Each has `constat` (one sentence: what is missing or contradictory) and `piece` (the piece or draft concerned). | | `posteId`, `posteName` | string | The agent that ran. | | `sessionId` | string | The session that holds this run (see [Sessions](https://dev.subsidia.protypa.fr/docs/sessions.md)). | | `knowledgeUsed` | object[] | The passages the poste read to reach its verdict, with `sourceName`, `page` and `chunkPreview`, for traceability. | | `tokenUsage` | object | `promptTokens`, `completionTokens`, `totalTokens` of the run. | > **WARNING: RAS means the poste found nothing, not that the dossier is right** > A poste checks what its rules and control list tell it to check, against the pieces it can read. `RAS` is a statement about that pass. A poste with no control list can see what is there but not what is missing, which is why the list is edited in the console and is business knowledge, not technical setup. Findings are written in French because the shipped postes are French-language. If the model returns something that is not the expected JSON, the run still succeeds: `status` is `signalements` with a single finding whose `constat` is the raw text and whose `piece` is empty, so a malformed answer is never silently read as `RAS`. ## Authentication, scopes and billing Use a developer API key ([Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md)). The postes route belongs to no capability scope: a **legacy** key (no capability scope) can call it; a **restricted** key (one that carries a capability scope such as `engine`) is refused with `403 scope_denied`; and `readonly` keys are refused because a run writes a session. Create a dedicated unrestricted key for the pipeline that runs postes. The route is limited to 30 calls per minute. A run counts against the workspace allowance of questions, like a chat turn. An exhausted allowance is a `402` before the model runs. ## Endpoint ### POST /v1/postes/:key/run Run an installed poste on one dossier. Executes the poste on the dossier and returns the structured verdict. The call is synchronous: it returns when the poste has read the dossier and answered, which takes seconds with a cloud model and can take minutes on a small local machine. Set a client timeout of five minutes or more. - **Authentication:** API key (Bearer or x-api-key) #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `key` | `string` | yes | | A shipped poste key (`auditeur-pieces`, `relecteur-avant-envoi`) or the id of a "maison" poste. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `collectionId` | `string` | yes | | The dossier (Cortex collection) to run the poste against. | | `message` | `string` | no | | Replaces the default task instruction. Omit for the standard checklist pass. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/postes/auditeur-pieces/run \ --max-time 600 \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"collectionId": "'"$COLLECTION_ID"'"}' ``` _TypeScript_ ```typescript type Finding = { constat: string; piece: string } type PosteRun = { status: 'RAS' | 'signalements'; signalements: Finding[]; posteName: string; sessionId: string } async function runPoste(key: string, collectionId: string, message?: string): Promise { const res = await fetch('https://api.subsidia.protypa.fr/v1/postes/' + key + '/run', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ collectionId, message }), signal: AbortSignal.timeout(10 * 60 * 1000), }) if (!res.ok) throw new Error(res.status + ' ' + (await res.text())) return res.json() } const run = await runPoste('auditeur-pieces', process.env.COLLECTION_ID!) if (run.status === 'RAS') { console.log(run.posteName + ': rien à signaler') } else { for (const f of run.signalements) console.log('- ' + f.piece + ': ' + f.constat) process.exitCode = 1 // hold the dossier for a human } ``` _Python_ ```python import os, requests def run_poste(key, collection_id, message=None): body = {"collectionId": collection_id} if message: body["message"] = message res = requests.post( "https://api.subsidia.protypa.fr/v1/postes/" + key + "/run", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json=body, timeout=600, ) res.raise_for_status() return res.json() run = run_poste("auditeur-pieces", os.environ["COLLECTION_ID"]) if run["status"] == "RAS": print(run["posteName"], ": rien à signaler") else: for f in run["signalements"]: print("-", f["piece"] + ":", f["constat"]) ``` #### Responses **200**: The verdict. With nothing to report, `status` is `RAS` and `signalements` is an empty array. ```json { "posteId": "cm2kf3r6k000lqz0fy8a2c5dp", "posteName": "Auditeur de pièces", "status": "signalements", "signalements": [ { "constat": "Le diagnostic amiante, attendu pour un compromis de vente, ne figure pas au dossier.", "piece": "Compromis de vente.pdf" }, { "constat": "Le prix de vente diffère entre le compromis (412 000 EUR) et le projet d'acte (421 000 EUR).", "piece": "Projet d'acte.docx" } ], "sessionId": "cm2kf5b2m000mqz0fd3v7j9tq", "knowledgeUsed": [ { "ref": 1, "sourceId": "cm2kf0c9h000nqz0fu6x4s1ge", "sourceName": "Compromis de vente.pdf", "section": null, "page": 3, "chunkPreview": "Prix : quatre cent douze mille euros ...", "similarity": 0.81, "chunkId": "ck_2a9e61b7" } ], "tokenUsage": { "promptTokens": 5120, "completionTokens": 240, "totalTokens": 5360 } } ``` **400**: `collectionId` is missing. ```json { "error": "collectionId is required" } ``` **402**: The workspace has no questions left. Nothing was consumed. **404**: The poste is not installed in this workspace (shipped keys) or does not exist (agent id). ```json { "error": "Poste non installé : installez « Auditeur de pièces » depuis Light → Les postes avant de l'appeler." } ``` **422**: The key is the id of an ordinary agent, not a poste. ```json { "error": "« Claire » n'est pas un poste — utilisez /v1/agents/cm2k8x1ab0001qz0f7h3d9t4e/chat." } ``` **500**: The model call failed. #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | `collectionId` missing. | | 402 | `API_KEY_BUDGET_EXCEEDED` | The key reached its own monthly question ceiling. | | 403 | `scope_denied` | The key is restricted by capability scopes: the postes route is outside every scope. | | 403 | `read_only_key` | The key carries the `readonly` scope. | | 404 | | Poste not installed, or no such "maison" poste. | | 422 | | The id given is not a poste. | | 429 | `rate_limited` | More than 30 calls per minute, or the key's own limit. Honour `retry-after`. | ## Wiring a poste into a pipeline 1. **Trigger on arrival** Call the endpoint when a piece lands in the dossier (a webhook of your document system, a folder watcher, a scheduled sweep). Pass the dossier's `collectionId`. 2. **Branch on status** `RAS` ends the flow quietly. `signalements` creates a task or a notification for a person, with the `piece` and the `constat` of each finding. Do not auto-act on findings: the poste signals, a human decides. 3. **Keep the trace** Store `sessionId` with the dossier. [`GET /v1/agents/:id/sessions/:sid`](https://dev.subsidia.protypa.fr/docs/sessions.md) replays the run, and `knowledgeUsed` shows which passages the verdict rested on. > **TIP: Need a no-model check instead?** > To check a text you already have (a letter, a note produced elsewhere) claim by claim against the dossier with deterministic verdicts and no model call, use [Le Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md). Postes read a dossier and report gaps; the Vérificateur grades a given text. --- # 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. | > **INFO: Why contradicted is deliberately shy** > Saying "contradicted" wrongly destroys trust faster than any silence, so the rule is narrow. It only fires when the anchor is numeric **and has a unit**, the passage shares at least two content words with the claim, the passage carries another number of the same unit, and the passage does not also contain the announced value (a table listing both lines is not in conflict with itself). Dates are excluded on purpose: a file legitimately holds many neighbouring dates. When in doubt the verdict is `unsupported`, never `contradicted`. ## 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. | > **INFO: Walls hold** > In Cortex mode, a collection you name but your user may not read is dropped from the scope, and the report says what it actually covered. You cannot bypass a restricted collection by naming it. ## 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](https://dev.subsidia.protypa.fr/docs/partner.md)). > **INFO: Pricing** > At the time of writing, controls are counted and shown on statements but no price is charged for them. This page will state the price when it is set. ## 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](https://dev.subsidia.protypa.fr/docs/authentication.md). 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. 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. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `proof` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `text` | `string` | yes | | 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. | | `title` | `string` | no | | 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. | | `passages` | `object[]` | no | | What to check against: 1 to 1,500 passages, each up to 20,000 characters, 3,000,000 characters in total. | | `passages.source` | `string` | yes | | The document the passage comes from (up to 300 characters). Shown in the report. | | `passages.content` | `string` | yes | | The passage text. | | `passages.id` | `string` | no | | Your own passage or chunk id. Defaults to `p1`, `p2`... Must be unique in the request. | | `passages.sourceId` | `string` | no | | Your document id. Defaults to `source`. | | `passages.page` | `integer` | no | | Page number, returned in the evidence. | | `passages.section` | `string` | no | | Section label, returned in the evidence. | | `collectionIds` | `string[]` | no | | Cortex mode: the collections to check against. Ignored if `passages` is sent without it; sending both is a 400. | #### Request examples _curl_ ```bash 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." } ] }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/v1/verify', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ title: 'Synthese bail commercial', text: answerFromMyRag, // the passages your own retrieval returned for that answer passages: hits.map((h) => ({ id: h.id, source: h.documentName, page: h.page, content: h.text, })), }), }) if (res.status !== 201) throw new Error('verify failed: ' + res.status) const { report, certificate } = await res.json() const bad = report.claims.filter((c) => c.verdict === 'contradicted' || c.verdict === 'unsupported') console.log(report.counts, 'proof:', report.proofId, 'to review:', bad.length) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/v1/verify", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "title": "Synthese bail commercial", "text": answer_from_my_rag, "passages": [ {"id": h["id"], "source": h["document"], "page": h.get("page"), "content": h["text"]} for h in hits ], }, ) res.raise_for_status() data = res.json() print(data["report"]["counts"], data["report"]["proofId"]) ``` #### Responses **201**: 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. ```json { "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" } ``` **400**: Invalid request. `code` tells which rule failed. ```json { "error": "passages[0].source is required (the document the passage comes from)", "code": "invalid_passages" } ``` **413**: Text longer than 240,000 characters (`text_too_large`), or request body over 4 MB. #### Errors | Status | Code | When | | --- | --- | --- | | 400 | `text_required` | `text` is missing or blank. | | 400 | `invalid_passages` | `passages` 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. | | 400 | `ambiguous_scope` | Both `passages` and `collectionIds` were sent. | | 401 | | Missing, invalid, revoked or expired key. | | 403 | `scope_denied` | The key does not carry the `proof` scope. | | 413 | `text_too_large` | The text exceeds 240,000 characters. | | 429 | `rate_limited` | More 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. Returns summaries only (no text, no claims). Fetch one report to get the verdicts. - **Authentication:** API key - **Scopes:** `proof` #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `page` | `integer` | no | `1` | Page number, starting at 1. | | `pageSize` | `integer` | no | `20` | Between 1 and 100. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/v1/verify?page=1&pageSize=20" -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const { reports, total } = await fetch('https://api.subsidia.protypa.fr/v1/verify?pageSize=50', { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }).then((r) => r.json()) ``` _Python_ ```python import os, requests data = requests.get( "https://api.subsidia.protypa.fr/v1/verify", params={"pageSize": 50}, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json() print(data["total"], [r["id"] for r in data["reports"]]) ``` #### Responses **200**: A page of report summaries. ```json { "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 | Status | Code | When | | --- | --- | --- | | 401 | | Missing or invalid key. | | 403 | `scope_denied` | The key does not carry the `proof` scope. | ### GET /v1/verify/usage Controls used this calendar month (UTC). 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. - **Authentication:** API key - **Scopes:** `proof` #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/verify/usage -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _Python_ ```python import os, requests usage = requests.get( "https://api.subsidia.protypa.fr/v1/verify/usage", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json() print(usage["month"], usage["controls"]) ``` #### Responses **200**: The month and its control count. ```json { "month": "2026-10", "controls": 128 } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 403 | `scope_denied` | The key does not carry the `proof` scope. | ### GET /v1/verify/:id Get one report with its human reviews. 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](https://dev.subsidia.protypa.fr/docs/registre.md): who read the report, and what they decided. - **Authentication:** API key - **Scopes:** `proof` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The report `id` returned by `POST /v1/verify`. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/verify/$REPORT_ID -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const { report, reviews } = await fetch('https://api.subsidia.protypa.fr/v1/verify/' + reportId, { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }).then((r) => r.json()) ``` _Python_ ```python import os, requests data = requests.get( "https://api.subsidia.protypa.fr/v1/verify/" + report_id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json() print(data["report"]["counts"], len(data["reviews"])) ``` #### Responses **200**: The report and its reviews. A review has `decision` (`accepted`, `modified` or `rejected`), `reviewerEmail`, `note` and `createdAt`. ```json { "report": { "id": "cm2k9x3a40001abcd1234efgh", "title": "Synthese bail commercial", "counts": { "claims": 2, "verified": 1, "unsupported": 0, "contradicted": 1, "unverifiable": 0 }, "proofId": "vr_cm2k9x3a40001abcd1234efgh" }, "reviews": [] } ``` **404**: No such report in this workspace. ```json { "error": "Report not found" } ``` ### GET /v1/verify/:id/certificate A report's certificate, and its server-side verification. 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. - **Authentication:** API key - **Scopes:** `proof` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The report id (not the proof id). | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/verify/$REPORT_ID/certificate -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _Python_ ```python import os, requests data = requests.get( "https://api.subsidia.protypa.fr/v1/verify/" + report_id + "/certificate", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ).json() assert data["verification"]["valid"] ``` #### Responses **200**: `valid` is true only when both the signature and the chain link hold. ```json { "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" } ``` **404**: Unknown report, or the report was saved unsigned (`code: "unsigned"`). ### GET /v1/verify/:id/export The control dossier, as a zip. 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. - **Authentication:** API key - **Scopes:** `proof` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The report id. | #### Request examples _curl_ ```bash curl https://api.subsidia.protypa.fr/v1/verify/$REPORT_ID/export \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" -o controle.zip ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr/v1/verify/" + report_id + "/export", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) open("controle.zip", "wb").write(res.content) ``` #### Responses **200**: `application/zip`, with `Content-Disposition: attachment; filename="controle-.zip"`. **404**: No such report in this workspace. ## 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](https://dev.subsidia.protypa.fr/docs/gateway.md#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. | > **WARNING: A "not checkable" claim is not a safe claim** > `unverifiable` means the engine found nothing to compare, because the sentence contains no number, date, reference or name. It does not mean the sentence is true. Do not present a report as "the text is correct": present it as "these values were or were not found in these passages". ## 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](https://dev.subsidia.protypa.fr/docs/registre.md). --- # Le Registre > The register of AI usage in your workspace (model, data, date, reviewer, human change), listable through the API and exportable as a signed zip whose attestation commits to the filters. The AI Act expects an organisation that deploys AI to be able to say, for any output: **which model, on which data, when, validated by whom, and with which human changes**. Le Registre assembles that answer from what Subsidia already records, and exports it as a file an inspector, an insurer or a client can open. It does not add a second system of record. Four of the five mentions come from data that exists anyway (every model call, every Gateway certificate, the access journal). The fifth, human review, is captured where it is honest to capture it: at the bottom of a [Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md) report, in the app. ## The five mentions | Mention | Where it comes from | Field in a row | | --- | --- | --- | | Which model | Every model call writes a log line: provider, model, task, tokens, estimated cost. | `provider`, `model`, `taskType`, `promptTokens`, `completionTokens`, `totalTokens`, `estimatedCostUsd` | | On which data | For calls through the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md), the proof certificate commits to a hash of every passage the answer drew on. | `proofId`, `grounding` (number of passages committed) | | When, by whom | The log line carries the time, the user and the surface; personal data masked before leaving is counted. | `at`, `userId`, `userEmail`, `source`, `piiCount` | | Validated by whom | Human reviews recorded in the app: decision `accepted`, `modified` or `rejected`, reviewer and note. | `reviews[].decision`, `reviewerEmail`, `note`, `at` | | Which human changes | The `modified` decision and its note. | `reviews[].decision = "modified"`, `reviews[].note` | > **WARNING: What the register does not claim** > "On which data" is complete only for calls that went through the Gateway, whose certificate hashes each passage. For calls made inside the app the register knows the surface (`source`) and the turn, not the list of documents read, and `proofId` and `grounding` are `null`. The attestation says so in plain text. A register that pretended otherwise would be worse than none. ## Access, plan and scope The routes need an API key with the **`proof`** scope (keys created before scopes existed also work). The register is a plan feature: it is included in the **Cabinet** plan, and a workspace on another plan gets `402` with `code: "PLAN_UPGRADE_REQUIRED"`; the key does not bypass it. Every export is written to the workspace access journal. Within the app, reading the register is a right of every member of the workspace, for the same reason as the access journal: a register only the manager can read is not a counterweight. ## Endpoints ### GET /v1/registre List register rows, newest first. One row per model call of the workspace, with the proof reference and the human reviews attached. Filters combine with AND. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `proof` #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `from` | `string` | no | | Start of the period, ISO 8601 (`2026-09-01` or a full timestamp). Inclusive. | | `to` | `string` | no | | End of the period, ISO 8601. Inclusive. A bare date means midnight at the start of that day. | | `model` | `string` | no | | Exact model name, for example `gpt-4o-mini`. | | `provider` | `string` | no | | Exact provider name, for example `openai` or `ollama`. | | `source` | `string` | no | | The surface that made the call, as recorded in the register (the `source` field of a row). | | `review` | `string` | no | | Keep only rows that have, or have not, at least one human review. Applied to the page after it is read: `total` still counts all rows matching the other filters. One of: `reviewed`, `unreviewed`. | | `page` | `integer` | no | `1` | Page number, from 1. | | `pageSize` | `integer` | no | `50` | Between 1 and 500. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/v1/registre?from=2026-09-01&to=2026-10-01&provider=openai&pageSize=100" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/registre' + '?from=2026-09-01&to=2026-10-01&provider=openai&pageSize=100', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/v1/registre" + "?from=2026-09-01&to=2026-10-01&provider=openai&pageSize=100", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: A page of rows. ```json { "rows": [ { "id": "slog_7c1e90", "at": "2026-09-18T14:02:11.000Z", "provider": "openai", "model": "gpt-4o-mini", "taskType": "chat", "source": "gateway", "userId": "usr_21a", "userEmail": "claire@cabinet.example", "promptTokens": 1840, "completionTokens": 312, "totalTokens": 2152, "estimatedCostUsd": 0.000738, "piiCount": 3, "turnId": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10", "proofId": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10", "grounding": 4, "reviews": [ { "decision": "modified", "reviewerEmail": "paul@cabinet.example", "note": "Montant corrige", "at": "2026-09-18T15:40:00.000Z" } ] } ], "total": 1284, "page": 1, "pageSize": 100 } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 401 | | Missing or invalid key. | | 402 | `PLAN_UPGRADE_REQUIRED` | The workspace plan does not include the proof journal. | | 403 | `scope_denied` | The key does not carry the `proof` scope. | #### Notes The `source` strings are the surfaces recorded by the platform (for example `agent_chat` for agent conversations). Read them from your own rows rather than hard-coding a list. ### GET /v1/registre/summary Totals for the same filters. What a supervisor reads first: how many calls, how many went to an external provider, how much personal data was masked, how many reviews exist. `externalCalls` counts calls to `openai`, `anthropic`, `mistral`, `groq` and `azure`; everything else ran on the machine. - **Authentication:** API key - **Scopes:** `proof` #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `from` | `string` | no | | Start of the period, ISO 8601 (`2026-09-01` or a full timestamp). Inclusive. | | `to` | `string` | no | | End of the period, ISO 8601. Inclusive. A bare date means midnight at the start of that day. | | `model` | `string` | no | | Exact model name, for example `gpt-4o-mini`. | | `provider` | `string` | no | | Exact provider name, for example `openai` or `ollama`. | | `source` | `string` | no | | The surface that made the call, as recorded in the register (the `source` field of a row). | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/v1/registre/summary?from=2026-09-01&to=2026-10-01" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/registre/summary' + '?from=2026-09-01&to=2026-10-01', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/v1/registre/summary" + "?from=2026-09-01&to=2026-10-01", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: The summary. ```json { "summary": { "calls": 1284, "totalTokens": 3150012, "estimatedCostUsd": 1.92, "piiMasked": 407, "externalCalls": 212, "byModel": [ { "model": "qwen2.5:7b", "provider": "ollama", "calls": 1072, "totalTokens": 2480110 }, { "model": "gpt-4o-mini", "provider": "openai", "calls": 212, "totalTokens": 669902 } ], "bySource": [ { "source": "gateway", "calls": 900 }, { "source": "agent_chat", "calls": 384 } ], "reviewed": 96 } } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 402 | `PLAN_UPGRADE_REQUIRED` | The workspace plan does not include the proof journal. | | 403 | `scope_denied` | The key does not carry the `proof` scope. | #### Notes `reviewed` counts human reviews recorded in the period, not rows. ### GET /v1/registre/export The signed export, as a zip. The file to hand to an inspector. It holds up to 5,000 rows matching the filters (narrow the period for larger volumes and export in several parts: each part carries its own filters in its attestation). - **Authentication:** API key - **Scopes:** `proof` #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `from` | `string` | no | | Start of the period, ISO 8601 (`2026-09-01` or a full timestamp). Inclusive. | | `to` | `string` | no | | End of the period, ISO 8601. Inclusive. A bare date means midnight at the start of that day. | | `model` | `string` | no | | Exact model name, for example `gpt-4o-mini`. | | `provider` | `string` | no | | Exact provider name, for example `openai` or `ollama`. | | `source` | `string` | no | | The surface that made the call, as recorded in the register (the `source` field of a row). | | `review` | `string` | no | | Keep only reviewed or unreviewed rows. One of: `reviewed`, `unreviewed`. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/v1/registre/export?from=2026-09-01&to=2026-10-01" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -o registre.zip ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/registre/export' + '?from=2026-09-01&to=2026-10-01', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const buffer = Buffer.from(await res.arrayBuffer()) await writeFile('registre.zip', buffer) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/v1/registre/export" + "?from=2026-09-01&to=2026-10-01", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) open('registre.zip', 'wb').write(res.content) ``` #### Responses **200**: `application/zip`, named `registre-usage-ia-.zip`, with the four files described below. #### Errors | Status | Code | When | | --- | --- | --- | | 402 | `PLAN_UPGRADE_REQUIRED` | The workspace plan does not include the proof journal. | | 403 | `scope_denied` | The key does not carry the `proof` scope. | ## What is in the zip | File | Content | | --- | --- | | `registre.csv` | One row per call. Semicolon-separated with a UTF-8 byte-order mark so a French Excel opens it with accents intact. Columns: `date`, `fournisseur`, `modele`, `tache`, `surface`, `utilisateur`, `jetons_entree`, `jetons_sortie`, `jetons_total`, `cout_usd`, `entites_masquees`, `certificat`, `passages_engages`, `relecture`, `relu_par`, `note`. | | `registre.json` | The same rows as machine-readable JSON, with the filters that produced them and the summary. | | `attestation.txt` | The attestation in plain French for a reader who does not know what a hash is: period, row count, external calls, masked entities, reviews, and what the register does and does not establish. | | `attestation.json` | The signed record: `payload`, `payloadHash`, `signature`. | > **INFO: If the signature could not be issued** > The zip is still produced, but `attestation.txt` says that the extraction is **not signed** and there is no `attestation.json`. The data is accurate; nothing proves it was not altered afterwards. ## The attestation and why it commits to the filters The attestation is signed with the same Ed25519 key and chained in the same hash chain as [Gateway proof certificates](https://dev.subsidia.protypa.fr/docs/proofs.md): each one embeds the hash of the previous record, so removing one leaves a visible hole. Its payload commits to the exact fingerprint of `registre.csv` and `registre.json` (`csvSha256`, `jsonSha256`), to the row count, to a short summary, **and to the filters** (`from`, `to`, `model`, `provider`, `source`, `review`). The filters are in the signed part on purpose. Without them, an extract limited to September, to one model, or to reviewed rows only could be presented as the whole register. With them, the recipient can read in a signed document exactly what the extract is a slice of. **attestation.json (abridged)** ```json { "payload": { "v": 1, "id": "rg_3f9a1c0b7d2e4a6851be90cd", "issuer": "subsidia-registre", "kind": "usage-register", "issuedAt": "2026-10-09T09:05:00.000Z", "chainIndex": 58, "prevHash": "e07b...", "workspaceId": "ws_...", "issuedBy": "usr_21a", "filters": { "from": "2026-09-01T00:00:00.000Z", "to": "2026-10-01T00:00:00.000Z", "model": null, "provider": null, "source": null, "review": null }, "rowCount": 1284, "summary": { "calls": 1284, "totalTokens": 3150012, "externalCalls": 212, "piiMasked": 407, "reviewed": 96 }, "csvSha256": "5c1d...", "jsonSha256": "b82e..." }, "payloadHash": "sha256(canonical-json(payload))", "signature": "base64 Ed25519 over payloadHash" } ``` ## Verify an export Three checks, none of which needs an account. Fetch the public key once from `GET /v1/gateway/public-key` (open route). 1. The SHA-256 of each file equals `csvSha256` and `jsonSha256`: the files were not edited. 2. The SHA-256 of the canonical JSON of `payload` equals `payloadHash`: the attestation was not edited. 3. The Ed25519 signature over `payloadHash` verifies with the public key: Subsidia issued it. Then read `payload.filters` and `payload.rowCount` to know what the extract covers. _Node.js_ ```typescript import crypto from 'node:crypto' import { readFileSync } from 'node:fs' function canonicalJson(v: unknown): string { if (v === null || typeof v !== 'object') return JSON.stringify(v) if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']' const entries = Object.entries(v as Record) .filter(([, x]) => x !== undefined) .sort(([a], [b]) => (a < b ? -1 : 1)) return '{' + entries.map(([k, x]) => JSON.stringify(k) + ':' + canonicalJson(x)).join(',') + '}' } const sha256 = (s: string) => crypto.createHash('sha256').update(s, 'utf8').digest('hex') // files extracted from the zip into the current folder const csv = readFileSync('registre.csv', 'utf8') const json = readFileSync('registre.json', 'utf8') const att = JSON.parse(readFileSync('attestation.json', 'utf8')) const publicKeyPem = readFileSync('public-key.pem', 'utf8') // from GET /v1/gateway/public-key const filesOk = sha256(csv) === att.payload.csvSha256 && sha256(json) === att.payload.jsonSha256 const payloadOk = sha256(canonicalJson(att.payload)) === att.payloadHash const signatureOk = crypto.verify( null, Buffer.from(att.payloadHash, 'utf8'), crypto.createPublicKey(publicKeyPem), Buffer.from(att.signature, 'base64'), ) console.log({ filesOk, payloadOk, signatureOk, filters: att.payload.filters, rows: att.payload.rowCount }) ``` _Python_ ```python import base64, hashlib, json from cryptography.hazmat.primitives.serialization import load_pem_public_key def canonical(v): if isinstance(v, dict): items = sorted(v.items()) return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonical(x) for k, x in items) + "}" if isinstance(v, list): return "[" + ",".join(canonical(x) for x in v) + "]" return json.dumps(v, ensure_ascii=False, separators=(",", ":")) def sha256(s: str) -> str: return hashlib.sha256(s.encode("utf-8")).hexdigest() csv_text = open("registre.csv", encoding="utf-8", newline="").read() json_text = open("registre.json", encoding="utf-8", newline="").read() att = json.load(open("attestation.json", encoding="utf-8")) public_key = load_pem_public_key(open("public-key.pem", "rb").read()) files_ok = sha256(csv_text) == att["payload"]["csvSha256"] and sha256(json_text) == att["payload"]["jsonSha256"] payload_ok = sha256(canonical(att["payload"])) == att["payloadHash"] public_key.verify(base64.b64decode(att["signature"]), att["payloadHash"].encode("utf-8")) # raises if invalid print(files_ok, payload_ok, att["payload"]["filters"], att["payload"]["rowCount"]) ``` > **TIP: Read files without altering them** > The hash covers the exact characters of the files, byte-order mark included. Read them as UTF-8 text without newline translation and do not open and re-save them in a spreadsheet before checking. ## Recording a human review Reviews are recorded in the app, at the bottom of a Vérificateur report, with the reviewer taken from the session and never from a request field: a review that can be signed in someone else's name is worthless. They are returned by `GET /v1/verify/:id` and appear in the `reviews` array of register rows and in the export. There is no API route to write a review. ## Frequently asked **Is the register the same as the access journal?** No. The access journal records who or what read which client file. The register records AI calls: model, volume, masking, proof, review. They share the same philosophy (open to every member, AI reads and human reads distinguished) but answer different questions. **Does the register contain the prompts or answers?** No. It holds metadata and references: the proof id and the number of committed passages, not their content, and not the question or the answer. **Why are token counts and estimated cost in it?** They are what the platform measured for the call and are useful to an auditor to size usage. They are not billing amounts: customers are billed in questions, see [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md). --- # Webhooks > Receive signed HTTP callbacks when a document is ingested, a flow finishes or an approval is waiting, and manage endpoints, deliveries and replays through the API. Webhooks let Subsidia call your server when something happens, so you do not have to poll. You register an HTTPS endpoint and a list of events; each time one of them occurs, Subsidia sends a signed `POST` with a small JSON body. Payloads carry **identifiers and status, never content**. A document's text, an answer or the question an approval asks stay in Subsidia behind the person's session, because a webhook receiver is a third party. Use the identifier to fetch what you need through the API. ## How a delivery looks Each delivery is a `POST` to your URL with `Content-Type: application/json`. The body is the event payload itself, with no wrapper object: the event name travels in a header. | Header | Value | | --- | --- | | `X-PulseLabs-Event` | The event name, for example `source.ingested`. | | `X-PulseLabs-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the **raw request body**, keyed with the endpoint secret. | | `X-PulseLabs-Delivery` | A unique id for this delivery. It stays the same across retries and replays: use it to deduplicate. | | `User-Agent` | `PulseLabs-Webhooks/1.0` | Respond with any `2xx` status within 10 seconds to acknowledge. Any other status, a timeout or a connection error counts as a failure and is retried. Redirects are not followed. > **INFO: Header names** > The headers still carry the former product prefix (`X-PulseLabs-...`). They are stable; do not look for `X-Subsidia-...`. ## Event catalogue | Event | Sent when | Payload | | --- | --- | --- | | `source.ingested` | A document finished ingestion and is searchable. | `sourceId`, `name`, `chunkCount`, `factCount`, `contradictionCount`, `qaCount` | | `source.failed` | A document could not be ingested. | `sourceId`, `name`, `error` | | `knowledge.contradiction_detected` | A new document contradicts a fact already in the knowledge base. | `contradictionId`, `severity`, `newFact: { id, source }`, `existingFact: { id, source }` | | `knowledge.stale` | Facts past their validity date were found. | `count`, `facts: [{ id, source, validUntil }]` (at most 20 facts) | | `debate.synthesized` | A multi-agent debate produced its synthesis. | `conversationId`, `synthesis` | | `flow.run.finished` | A [Reflex](https://dev.subsidia.protypa.fr/docs/reflex.md) run ended. | `runId`, `flowId`, `flowName`, `status`, `actionable` | | `flow.approval.requested` | A Reflex run is paused on an approval. | `runId`, `flowId`, `flowName`, `nodeKey`, `approvalId` | | `webhook.test` | You called the test endpoint. Delivered to the endpoint you test, whatever its subscriptions. | `event`, `webhookId`, `message`, `timestamp` | Two further event names, `simulation.tick.completed` and `simulation.report.ready`, are accepted at registration. They belong to a module that is not documented here. **Who receives what.** Knowledge and debate events go to the endpoints registered by the user whose action caused them. Reflex events (`flow.run.finished`, `flow.approval.requested`) have no single author, since a scheduled flow can finish at 4 am, so they go to every active endpoint of the workspace that subscribed. **Example bodies** _source.ingested_ ```json { "sourceId": "src_8f3a1c", "name": "Bail commercial 2024.pdf", "chunkCount": 42, "factCount": 17, "contradictionCount": 1, "qaCount": 8 } ``` _knowledge.contradiction_detected_ ```json { "contradictionId": "ctr_41b9", "severity": "high", "newFact": { "id": "fct_203", "source": "Avenant 2026.pdf" }, "existingFact": { "id": "fct_077", "source": "Bail commercial 2024.pdf" } } ``` _flow.run.finished_ ```json { "runId": "run_5d20", "flowId": "flw_7a11", "flowName": "Relance des factures", "status": "succeeded", "actionable": true } ``` _flow.approval.requested_ ```json { "runId": "run_5d20", "flowId": "flw_7a11", "flowName": "Relance des factures", "nodeKey": "send_mail", "approvalId": "apr_9c02" } ``` _webhook.test_ ```json { "event": "webhook.test", "webhookId": "whk_3e5b", "message": "This is a test delivery from Subsidia", "timestamp": "2026-10-09T08:30:00.000Z" } ``` ## Verify the signature Anyone who knows your URL can post to it, so check the signature before acting on a payload. Compute the HMAC-SHA256 of the **exact bytes received** (not of a re-serialised JSON object) with the endpoint secret, prefix it with `sha256=`, and compare in constant time. **Receiver with signature check and deduplication** _Node.js (Express)_ ```typescript import crypto from 'node:crypto' import express from 'express' const app = express() const seen = new Set() // use a database or Redis in production function isValid(rawBody: Buffer, header: string | undefined, secret: string): boolean { if (!header) return false const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex') const a = Buffer.from(expected) const b = Buffer.from(header) return a.length === b.length && crypto.timingSafeEqual(a, b) } // express.raw keeps the body as received: do not use express.json() on this route app.post('/hooks/subsidia', express.raw({ type: '*/*' }), (req, res) => { if (!isValid(req.body, req.header('x-pulselabs-signature'), process.env.WEBHOOK_SECRET!)) { return res.status(401).send('bad signature') } const deliveryId = req.header('x-pulselabs-delivery')! if (seen.has(deliveryId)) return res.status(200).send('duplicate') seen.add(deliveryId) const event = req.header('x-pulselabs-event') const payload = JSON.parse(req.body.toString('utf8')) console.log(event, payload) res.status(200).send('ok') }) ``` _Python (Flask)_ ```python import hashlib import hmac import os from flask import Flask, abort, request app = Flask(__name__) SECRET = os.environ["WEBHOOK_SECRET"].encode() seen = set() # use a database or Redis in production def is_valid(raw_body: bytes, header: str) -> bool: expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header or "") @app.post("/hooks/subsidia") def receive(): # request.get_data() is the raw body; do not rebuild it from request.json if not is_valid(request.get_data(), request.headers.get("X-PulseLabs-Signature")): abort(401) delivery_id = request.headers["X-PulseLabs-Delivery"] if delivery_id in seen: return "duplicate", 200 seen.add(delivery_id) event = request.headers["X-PulseLabs-Event"] payload = request.get_json() print(event, payload) return "ok", 200 ``` _Shell (debugging)_ ```bash # Recompute the signature of a body you saved to body.json printf '%s' "$(cat body.json)" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" # Compare the hex digest with the part after "sha256=" in X-PulseLabs-Signature ``` ## Retries, delivery log and idempotency A delivery is first attempted three times in a row (immediately, after 1 second, after 5 seconds). If all three fail, Subsidia retries later on a widening schedule: after **5 minutes, 30 minutes, 2 hours and 12 hours**. After the last retry the delivery stays in the log as failed. A retry sweep runs every minute, and only endpoints that are still active are retried. Every attempt is recorded. `GET /v1/webhooks/:id/deliveries` returns the latest 50, with the status code your server answered, the beginning of its response body, the retry count and the time of the next retry. You can send any stored delivery again with the replay endpoint. **Delivery is at least once.** A retry or a replay sends the same body with the same `X-PulseLabs-Delivery` id, so a receiver that stores the ids it has handled can drop repeats. Answer `2xx` quickly and do the work afterwards; a slow handler that times out will be retried while it is still running. ## Managing endpoints Endpoints can be managed in the Subsidia console and through the API below. The API needs a key with the **`webhooks`** scope (keys created before scopes existed also work). A read-only key can list and read but not create, change, delete, test or replay. Endpoints are private to the user who created them within a workspace. An endpoint URL must be a valid `http` or `https` URL with no credentials in it. In the cloud it must resolve to a public address (private ranges, loopback and internal names are refused). An on-premise installation also reaches its own private network, but never link-local addresses. Use HTTPS in production. ### POST /v1/webhooks Register a webhook endpoint. Creates the endpoint and returns its signing secret **once**. Store it immediately: later reads only show a masked preview, and there is no route to read it again. - **Authentication:** API key (Bearer or x-api-key) - **Scopes:** `webhooks` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `url` | `string` | yes | | Where deliveries are posted. | | `events` | `string[]` | yes | | At least one event name from the catalogue above. | | `description` | `string` | no | | A label for your own use. | #### Request examples _curl_ ```bash curl -X POST "https://api.subsidia.protypa.fr/v1/webhooks" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -H "Content-Type: application/json" \n -d '{ "url": "https://example.com/hooks/subsidia", "events": [ "source.ingested", "source.failed", "flow.approval.requested" ], "description": "Back office" }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "url": "https://example.com/hooks/subsidia", "events": [ "source.ingested", "source.failed", "flow.approval.requested" ], "description": "Back office" }), }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr" + "/v1/webhooks", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "url": "https://example.com/hooks/subsidia", "events": [ "source.ingested", "source.failed", "flow.approval.requested" ], "description": "Back office" }, ) print(res.status_code, res.json()) ``` #### Responses **201**: The endpoint, with its `secret` (starts with `whsec_`). ```json { "webhook": { "id": "whk_3e5b", "url": "https://example.com/hooks/subsidia", "events": ["source.ingested", "source.failed", "flow.approval.requested"], "description": "Back office", "isActive": true, "secret": "whsec_9d2c...e41f", "createdAt": "2026-10-09T08:20:00.000Z", "updatedAt": "2026-10-09T08:20:00.000Z" } } ``` **400**: A validation error. For unknown events the body also lists `validEvents`. ```json { "error": "Invalid event types: invoice.paid", "validEvents": ["debate.synthesized", "source.ingested"] } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 400 | | `url` or `events` missing or empty, an unknown event name, an invalid URL, or a URL the server refuses to call. | | 401 | | Missing or invalid key. | | 403 | `scope_denied` | The key does not carry the `webhooks` scope. | | 403 | `read_only_key` | The key is read-only. | ### GET /v1/webhooks List your webhook endpoints. Newest first. The secret is never returned, only a masked `secretPreview`. - **Authentication:** API key - **Scopes:** `webhooks` #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/v1/webhooks" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/v1/webhooks", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: The endpoints. ```json { "webhooks": [ { "id": "whk_3e5b", "url": "https://example.com/hooks/subsidia", "events": ["source.ingested", "source.failed"], "description": "Back office", "isActive": true, "secretPreview": "whsec_9d2c...e41f", "createdAt": "2026-10-09T08:20:00.000Z", "updatedAt": "2026-10-09T08:20:00.000Z" } ] } ``` ### GET /v1/webhooks/:id Get one webhook endpoint. - **Authentication:** API key - **Scopes:** `webhooks` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The endpoint id. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/v1/webhooks/$ID" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks/' + id, { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/v1/webhooks/" + id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: The endpoint, in the shape of the list items. **404**: Unknown id, or the endpoint belongs to another user. ```json { "error": "Webhook not found" } ``` ### PUT /v1/webhooks/:id Change a webhook endpoint. Send only the fields to change. Set `isActive` to `false` to pause deliveries without losing the endpoint or its secret. The secret cannot be rotated: to change it, create a new endpoint and delete the old one. - **Authentication:** API key - **Scopes:** `webhooks` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The endpoint id. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `url` | `string` | no | | New destination, checked like at creation. | | `events` | `string[]` | no | | Replaces the whole subscription list. | | `description` | `string` | no | | New label. | | `isActive` | `boolean` | no | | Pause or resume deliveries. | #### Request examples _curl_ ```bash curl -X PUT "https://api.subsidia.protypa.fr/v1/webhooks/$ID" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -H "Content-Type: application/json" \n -d '{ "isActive": false }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks/' + id, { method: 'PUT', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "isActive": false }), }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.put( "https://api.subsidia.protypa.fr" + "/v1/webhooks/" + id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "isActive": False }, ) print(res.status_code, res.json()) ``` #### Responses **200**: The updated endpoint. **400**: Invalid URL or unknown event name. **404**: Unknown id. ### DELETE /v1/webhooks/:id Delete a webhook endpoint and its delivery log. - **Authentication:** API key - **Scopes:** `webhooks` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The endpoint id. | #### Request examples _curl_ ```bash curl -X DELETE "https://api.subsidia.protypa.fr/v1/webhooks/$ID" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks/' + id, { method: 'DELETE', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) console.log(res.status) ``` _Python_ ```python import os, requests res = requests.delete( "https://api.subsidia.protypa.fr" + "/v1/webhooks/" + id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code) ``` #### Responses **204**: Deleted. The body is empty. **404**: Unknown id. ### POST /v1/webhooks/:id/test Send a test event to an endpoint. Posts a signed `webhook.test` event to the endpoint, whatever its subscriptions, and records it in the delivery log. A single attempt, no retry. Use it to check your signature code end to end. - **Authentication:** API key - **Scopes:** `webhooks` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The endpoint id. | #### Request examples _curl_ ```bash curl -X POST "https://api.subsidia.protypa.fr/v1/webhooks/$ID/test" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks/' + id + '/test', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr" + "/v1/webhooks/" + id + "/test", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: `success` is true when your server answered `2xx`. `statusCode` is `null` if it could not be reached. ```json { "success": true, "statusCode": 200 } ``` **404**: Unknown id. ### GET /v1/webhooks/:id/deliveries Delivery log: the latest 50 attempts. Use it to find out why your receiver is not getting events: the status code and the first part of your server's response body are kept. - **Authentication:** API key - **Scopes:** `webhooks` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The endpoint id. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/v1/webhooks/$ID/deliveries" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks/' + id + '/deliveries', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/v1/webhooks/" + id + "/deliveries", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: Newest first. `nextRetryAt` is `null` once the delivery succeeded or all retries were used. ```json { "deliveries": [ { "id": "d1f0c9a2b3", "event": "source.ingested", "statusCode": 503, "responseBody": "Service Unavailable", "success": false, "retryCount": 2, "deliveredAt": null, "nextRetryAt": "2026-10-09T08:35:07.000Z", "createdAt": "2026-10-09T08:30:02.000Z" } ] } ``` **404**: Unknown endpoint id. ### POST /v1/webhooks/:id/deliveries/:deliveryId/replay Send one stored delivery again, now. Re-sends the stored payload with the **same** `X-PulseLabs-Delivery` id, so a receiver that deduplicates sees a repeat, and cancels any pending automatic retry for it. - **Authentication:** API key - **Scopes:** `webhooks` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The endpoint id. | | `deliveryId` | `string` | yes | | A delivery `id` from the log. | #### Request examples _curl_ ```bash curl -X POST "https://api.subsidia.protypa.fr/v1/webhooks/$ID/deliveries/$DELIVERYID/replay" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks/' + id + '/deliveries/' + deliveryId + '/replay', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr" + "/v1/webhooks/" + id + "/deliveries/" + deliveryId + "/replay", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: The outcome of the new attempt. ```json { "success": true, "statusCode": 200 } ``` **404**: Unknown endpoint (`Webhook not found`) or delivery (`Delivery not found`). ## Frequently asked **Why is there no event name in the body?** The body is the payload only. Read the event from the `X-PulseLabs-Event` header. The one exception is `webhook.test`, whose payload repeats it. **Are deliveries ordered?** No. Events are sent as they happen and retries arrive later, so an old event can land after a newer one. Use the identifiers in the payload and fetch the current state if order matters. **My receiver was down for a day. Did I lose events?** Possibly. Retries stop after about 14 hours (5 minutes, 30 minutes, 2 hours, 12 hours after the first round). Read the delivery log, then replay what is still marked failed, or reconcile through the API. **Can I send document content in a webhook?** No, by design. Fetch it with the identifier from the payload, using an API key that has the right scope. --- # Reflex > Flows that run on their own: triggers, a catalogue of steps, a human approval before anything leaves the building, and an API to build, start and follow them. Synapse routes, Cortex knows, **Reflex acts**. A flow is a small graph of steps that runs without anyone asking: a night ingestion, a 7 am sweep of deadlines, a review every Friday, three follow-up emails prepared for you to approve. Each run is recorded step by step, with what it read, what it concluded and what it did. Three rules are enforced by the engine rather than suggested to the model: - **Nothing leaves without a human.** A step that sends something out of the building needs an approval in front of it. - **A missed slot fires once.** A machine that was off for three days runs the flow once when it wakes up, not once per period skipped. - **Silence is a result.** A run whose steps found nothing worth saying notifies nobody. > **WARNING: Local mode only** > The scheduler runs inside an on-premise (local mode) Subsidia installation, in a long-lived process. The cloud deployment does not start it, so scheduled flows do not fire there. `GET /engine/reflex/status` returns `enabled: false` when the scheduler is not running in the instance you are talking to. Flows are built and edited the same way in both. ## Triggers | Trigger node | Starts the flow when | Notes | | --- | --- | --- | | `schedule` | The time expression is due. | Grammar below. At least 5 minutes between runs. | | `manual` | Someone presses Run, or `POST /engine/flows/:id/run` is called. | Never fires by itself. | | `webhook` | An outside system posts to the flow URL. | Needs a shared secret. See below. | | `folder.changed` | A document arrives or changes in a watched folder. | The folder is configured in the Cortex synchronisation settings. | | `mail.received` | A message arrives in a connected mailbox. | Optional filter on the sender address or domain. | **Schedule grammar.** No cron strings: a flow nobody can read at a glance is one nobody dares change. | Expression | Meaning | |---|---| | `daily@08:30` | Every day at 08:30 in the flow's time zone (default `Europe/Paris`). | | `weekly@fri@17:00` | Every Friday at 17:00. Days: `mon tue wed thu fri sat sun` (French abbreviations also accepted). | | `every@30m`, `every@2h` | Every N minutes or hours. Minimum 5 minutes. | | `once@2026-11-03T09:00` | One time, then the trigger switches itself off. | A schedule has **active hours** (`windowHours`, default `7,21`): outside them the run waits instead of firing. And a **misfire policy**: `fire-once` (default) catches up once, `skip` forgets what was missed. ### Catch-up and idempotency Every scheduled run carries an idempotency key made of the flow and the slot it was scheduled for, and the key is unique. A Subsidia installation that wakes up after three days offline computes the slots it missed and the uniqueness collapses them into **one** run instead of 144. A one-shot (`once@...`) that was missed is still fired when the machine is back, because losing it is worse than running it late. The flow's `concurrency` decides what happens when a trigger fires while a run is in progress: `skip` (default) drops the new one, `queue` waits for the previous, `parallel` runs both. Runs beyond the global ceiling wait in line; they are never dropped. ## Steps (node catalogue) Each step has a **key** (stable) and a **type**. A step's effect class decides how the engine treats it: `read` has no side effect, `local-write` stays on the machine, `outbound` leaves the building. The catalogue is data: `GET /engine/node-types` returns the full list with each step's configuration fields, which is what the editor itself uses. | Type | Category | Effect | What it does | | --- | --- | --- | --- | | `schedule` | trigger | read | Starts on a time expression. | | `manual` | trigger | read | Starts when someone presses Run. | | `webhook` | trigger | read | Starts when an outside system posts to the flow URL. | | `folder.changed` | trigger | read | Starts when a document lands in a watched folder. | | `mail.received` | trigger | read | Starts when mail arrives in a connected mailbox. | | `agent.run` | agent | read | One full agent turn: its documents, memory and (optionally) tools. A silence word (default `RAS`) means nothing to report. | | `agent.debate` | agent | read | The workspace panel argues a question and converges on an answer. | | `cortex.search` | cortex | read | Passages that answer a question, with their sources. | | `cortex.read` | cortex | read | The text of one document, by id. | | `cortex.inventory` | cortex | read | The pieces a dossier contains, as a plain list with no model. It is what lets a flow say what is missing. | | `cortex.ingest` | cortex | local-write | Files a text from a previous step (or the trigger document) in a Cortex dossier. Same content means same piece: nothing is duplicated. | | `cortex.deadlines` | cortex | read | Reads the date columns of your registers (CSV, XLSX) and returns the approaching deadlines, exact rows, no model. | | `light.ask` | cortex | read | Asks [Light](https://dev.subsidia.protypa.fr/docs/light.md): answers strictly from the documents with citations, or an honest refusal. | | `discovery.night` | cortex | read | Re-reads dossiers under watch overnight and prepares the morning questions: missing pieces, overdue deadlines, contradictions. | | `gate` | logic | read | Stops the branch when the previous step had nothing worth saying (modes `actionable`, `covered`, `uncovered`, `always`). | | `branch` | logic | read | Sends the flow one way or the other; the conditions are on the links. | | `delay` | logic | read | Waits 1 to 240 minutes. | | `notify` | effect | local-write | Tells you, through a configured channel or the in-app inbox. | | `file.write` | effect | local-write | Saves a result to a folder on the machine. | | `mail.draft` | effect | local-write | Writes an email and stops. Nothing is sent. | | `http.call` | effect | **outbound** | Calls an external URL. A GET reads, anything else writes. | | `connection.call` | effect | **outbound** | Calls a service registered under Connections, with its stored credentials. | | `mail.send` | effect | **outbound** | Sends an email. | | `mail.reply` | effect | **outbound** | Replies to the sender of the mail that triggered the flow. Self-approving: the run always pauses on the exact message before anything is sent. | | `ask.human` | human | read | Asks you a question with context. Your answer is filed in the dossier, then the run resumes. | | `approval` | human | read | Parks the run in your inbox until you approve or reject. | **Passing a result to the next step.** Any text field of a step can reference an earlier step by its key: `{{http_call.output.status}}`, `{{agent_run.summary}}`, `{{trigger.output}}`, plus `{{date}}`, `{{time}}`, `{{flow}}`. The reference uses the step key, which never changes when the label does. An unknown reference is left in the text as written, so a mistake is visible instead of silently blank. Every step returns `output`, a one-line `summary` and `actionable`. The last one is the silence rule made structural: a `gate` stops the branch when it is false, and a run whose final steps are all non-actionable notifies nobody (for flows set to `only-if-actionable`). ## The approval rule for outbound steps Anything that stays on the machine runs unattended. Anything that leaves (`mail.send`, `http.call`, `connection.call`, `mail.reply`) is prepared, proposed and waits for a person. The graph validator enforces it: an outbound step with no `approval` step directly feeding it is an `unguarded-outbound` error, and **a flow with a blocking error cannot be switched on or run**. Two things relax it, and only these two: - the step is flagged `requiresApproval` in its own configuration, which pauses the run on it; - the flow has `allowUnattendedOutbound: true`, an explicit waiver that is off by default and written to the access journal every time it changes. `mail.reply` is self-approving: it always pauses with the exact message, even with no approval step in the graph and even with the waiver on. > **DANGER: An API key cannot approve its own work** > Deciding an approval (`POST /engine/approvals/:id/decide`), turning on `allowUnattendedOutbound`, and managing notification channels and connection credentials all require a **signed-in person**. An API key can build, start and follow flows, but calls to those routes are refused (for the waiver, `403 session_required`). Otherwise the rule that a human decides before anything leaves would be a formality. ## Authentication and scope Reflex lives under `/engine/*`. Developer API keys reach it with the **`reflex`** scope (keys created before scopes existed also work). Send `Authorization: Bearer sk_live_...` or `x-api-key: sk_live_...`. Every flow, run and approval is scoped to the workspace of the key. A read-only key can list and read, not create, change or run. ## Endpoints ### GET /engine/reflex/status Is the scheduler running, and how many flows are there. - **Authentication:** API key or session - **Scopes:** `reflex` #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/engine/reflex/status" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/reflex/status', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/engine/reflex/status", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: `enabled` is false on a cloud instance or when Reflex has been halted. The scheduler snapshot fields are added alongside. ```json { "enabled": true, "flows": 6, "active": 4, "pendingApprovals": 2 } ``` ### GET /engine/node-types The step catalogue, with each step configuration fields. The same definitions the validator, the executor and the editor read. Use it to build a valid `config` for a step, or to render your own form. `minIntervalMinutes` is the floor of recurring schedules. - **Authentication:** API key or session - **Scopes:** `reflex` #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/engine/node-types" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/node-types', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/engine/node-types", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: The catalogue. ```json { "nodeTypes": [ { "type": "schedule", "category": "trigger", "effect": "read", "label": "On a schedule", "isTrigger": true, "fields": [{ "key": "expr", "type": "schedule", "required": true, "default": "daily@08:30" }] } ], "minIntervalMinutes": 5 } ``` ### GET /engine/flows List the flows of the workspace. - **Authentication:** API key or session - **Scopes:** `reflex` #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/engine/flows" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/engine/flows", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: Most recently updated first. `schedule` is a readable label of the schedule trigger. ```json { "flows": [ { "id": "flw_7a11", "name": "Echeances de la semaine", "description": "", "enabled": true, "nodeCount": 4, "schedule": "Tous les jours a 08:30", "nextRunAt": "2026-10-10T06:30:00.000Z", "lastRun": { "id": "run_5d20", "status": "succeeded", "createdAt": "2026-10-09T06:30:01.000Z", "actionable": true }, "updatedAt": "2026-10-08T17:02:11.000Z" } ] } ``` ### POST /engine/flows Create a flow. Creates a flow with two starter steps (a daily 08:30 schedule feeding a `notify`), disabled. Replace the graph with `PUT /engine/flows/:id/graph`, then enable it. - **Authentication:** API key or session - **Scopes:** `reflex` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | `string` | yes | | Display name. | | `description` | `string` | no | | Free text. | | `timezone` | `string` | no | `Europe/Paris` | IANA time zone used to read schedule expressions. | #### Request examples _curl_ ```bash curl -X POST "https://api.subsidia.protypa.fr/engine/flows" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -H "Content-Type: application/json" \n -d '{ "name": "Echeances de la semaine", "timezone": "Europe/Paris" }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "name": "Echeances de la semaine", "timezone": "Europe/Paris" }), }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr" + "/engine/flows", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "name": "Echeances de la semaine", "timezone": "Europe/Paris" }, ) print(res.status_code, res.json()) ``` #### Responses **201**: The flow in its full shape: `id`, `name`, `enabled`, `version`, `timezone`, `concurrency`, `notifyPolicy`, `allowUnattendedOutbound`, `dailyTokenBudget`, `nodes`, `edges`, `triggers`. **400**: No name. ```json { "error": "A flow needs a name" } ``` ### GET /engine/flow-templates Ready-made flows you can start from. Each template has an `id`, a name, what it is for (`why`), what it `needs` configured and its `stepCount`. Current ids include `deadlines-watch`, `night-watch`, `piece-check`, `new-client-document`, `client-mail-triage`, `subcontractor-replies`, `weekly-review`, `follow-ups` and `service-watch`. - **Authentication:** API key or session - **Scopes:** `reflex` #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/engine/flow-templates" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flow-templates', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/engine/flow-templates", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: The templates (`templates` array). ### POST /engine/flows/from-template Create a flow from a template. The new flow is returned with its `issues`: a template deliberately leaves workspace-specific fields (which agent, which mailbox, which folder) empty, and those steps come back flagged. Fill them with `PUT /engine/flows/:id/graph`. - **Authentication:** API key or session - **Scopes:** `reflex` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `templateId` | `string` | yes | | An `id` from `GET /engine/flow-templates`. | #### Request examples _curl_ ```bash curl -X POST "https://api.subsidia.protypa.fr/engine/flows/from-template" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -H "Content-Type: application/json" \n -d '{ "templateId": "deadlines-watch" }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/from-template', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "templateId": "deadlines-watch" }), }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr" + "/engine/flows/from-template", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "templateId": "deadlines-watch" }, ) print(res.status_code, res.json()) ``` #### Responses **201**: The flow and its validation issues (`flow`, `issues`). **404**: Unknown template. ```json { "error": "Unknown template" } ``` ### GET /engine/flows/:id Get a flow with its graph, triggers and issues. `issues` lists what is wrong with the graph. Each has a `level` (`error` or `warning`), a `nodeKey`, a `code` (`missing-config`, `orphan`, `unguarded-outbound`, `cycle`, `no-trigger`...), `blocks` (`save` or `run`) and a `message`. Errors that block `run` stop you enabling or starting the flow. - **Authentication:** API key or session - **Scopes:** `reflex` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The flow id. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/engine/flows/$ID" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/' + id, { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/engine/flows/" + id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: `{ flow, issues }`. **404**: No such flow in this workspace. ```json { "error": "Flow not found" } ``` ### PATCH /engine/flows/:id Update flow settings: name, enabled, concurrency, notification policy. Send only the fields to change. Enabling a flow with a blocking issue is refused with the list of issues, because a failure knowable at 4 pm should not wait until 4 am. - **Authentication:** API key or session - **Scopes:** `reflex` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The flow id. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | `string` | no | | New name. | | `description` | `string` | no | | New description. | | `timezone` | `string` | no | | IANA time zone. | | `enabled` | `boolean` | no | | Switch the flow on or off. | | `concurrency` | `string` | no | | What to do when a trigger fires during a run. One of: `skip`, `queue`, `parallel`. | | `notifyPolicy` | `string` | no | | Whether a run that found nothing notifies anyone. One of: `always`, `only-if-actionable`. | | `dailyTokenBudget` | `number` | no | | Daily ceiling, `null` for none. | | `allowUnattendedOutbound` | `boolean` | no | | Waive the approval rule. **Session only**: an API key gets `403 session_required`. | #### Request examples _curl_ ```bash curl -X PATCH "https://api.subsidia.protypa.fr/engine/flows/$ID" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -H "Content-Type: application/json" \n -d '{ "enabled": true, "notifyPolicy": "only-if-actionable" }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/' + id, { method: 'PATCH', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "enabled": true, "notifyPolicy": "only-if-actionable" }), }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.patch( "https://api.subsidia.protypa.fr" + "/engine/flows/" + id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "enabled": True, "notifyPolicy": "only-if-actionable" }, ) print(res.status_code, res.json()) ``` #### Responses **200**: `{ flow, issues }`. **400**: The flow cannot be enabled yet. ```json { "error": "Fix the flow before switching it on", "issues": [{ "level": "error", "nodeKey": "send", "code": "unguarded-outbound", "blocks": "run", "message": "This step leaves the building. Put an approval in front of it, or allow unattended sending in the flow settings." }] } ``` **403**: An API key tried to change `allowUnattendedOutbound`. ```json { "error": "session_required", "message": "Allowing a flow to send without approval is a decision for a signed-in person, not an API key." } ``` **404**: No such flow. ### PUT /engine/flows/:id/graph Replace the graph of a flow. Saves all nodes and edges in one go (the previous graph is replaced, not merged). Work in progress is accepted: unwired steps and empty fields come back as `issues` that block switching the flow on, not saving. Only a graph the executor could not read back is refused: a cycle, an edge to a node that does not exist, a node feeding itself, two nodes sharing a key, an unknown type, a trigger with an input. - **Authentication:** API key or session - **Scopes:** `reflex` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The flow id. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `nodes` | `object[]` | yes | | The steps. | | `nodes.key` | `string` | yes | | Stable identifier, unique in the flow. Used in `{{key.output}}` references and edges. | | `nodes.type` | `string` | yes | | A type from `GET /engine/node-types`. | | `nodes.label` | `string` | no | | Display label. | | `nodes.config` | `object` | no | | The step fields, as listed by the catalogue. | | `nodes.x` | `number` | no | | Canvas position. | | `nodes.y` | `number` | no | | Canvas position. | | `nodes.timeoutSec` | `integer` | no | | Hard timeout of the step. Default 900. | | `nodes.retryMax` | `integer` | no | | Retries with exponential backoff. | | `nodes.requiresApproval` | `boolean` | no | | Pause the run on this step until a person approves it. | | `edges` | `object[]` | yes | | The links. | | `edges.fromKey` | `string` | yes | | Key of the upstream step. | | `edges.toKey` | `string` | yes | | Key of the downstream step. | | `edges.label` | `string` | no | | Display label. | | `edges.condition` | `object` | no | | Condition for a link leaving a `branch` step. | #### Request examples _curl_ ```bash curl -X PUT "https://api.subsidia.protypa.fr/engine/flows/$ID/graph" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -H "Content-Type: application/json" \n -d '{ "nodes": [ { "key": "trigger", "type": "schedule", "config": { "expr": "weekly@fri@17:00", "windowHours": "7,21", "misfire": "fire-once" } }, { "key": "deadlines", "type": "cortex.deadlines", "config": { "horizonDays": 14 } }, { "key": "gate", "type": "gate", "config": { "mode": "actionable" } }, { "key": "tell", "type": "notify", "config": { "channel": "all", "title": "Echeances", "body": "{{deadlines.summary}}" } } ], "edges": [ { "fromKey": "trigger", "toKey": "deadlines" }, { "fromKey": "deadlines", "toKey": "gate" }, { "fromKey": "gate", "toKey": "tell" } ] }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/' + id + '/graph', { method: 'PUT', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "nodes": [ { "key": "trigger", "type": "schedule", "config": { "expr": "weekly@fri@17:00", "windowHours": "7,21", "misfire": "fire-once" } }, { "key": "deadlines", "type": "cortex.deadlines", "config": { "horizonDays": 14 } }, { "key": "gate", "type": "gate", "config": { "mode": "actionable" } }, { "key": "tell", "type": "notify", "config": { "channel": "all", "title": "Echeances", "body": "{{deadlines.summary}}" } } ], "edges": [ { "fromKey": "trigger", "toKey": "deadlines" }, { "fromKey": "deadlines", "toKey": "gate" }, { "fromKey": "gate", "toKey": "tell" } ] }), }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.put( "https://api.subsidia.protypa.fr" + "/engine/flows/" + id + "/graph", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "nodes": [ { "key": "trigger", "type": "schedule", "config": { "expr": "weekly@fri@17:00", "windowHours": "7,21", "misfire": "fire-once" } }, { "key": "deadlines", "type": "cortex.deadlines", "config": { "horizonDays": 14 } }, { "key": "gate", "type": "gate", "config": { "mode": "actionable" } }, { "key": "tell", "type": "notify", "config": { "channel": "all", "title": "Echeances", "body": "{{deadlines.summary}}" } } ], "edges": [ { "fromKey": "trigger", "toKey": "deadlines" }, { "fromKey": "deadlines", "toKey": "gate" }, { "fromKey": "gate", "toKey": "tell" } ] }, ) print(res.status_code, res.json()) ``` #### Responses **200**: `{ flow, issues }` after the save. **400**: The graph cannot be stored. ```json { "error": "The graph loops back on itself.", "issues": [{ "level": "error", "nodeKey": null, "code": "cycle", "blocks": "save", "message": "The graph loops back on itself." }] } ``` **404**: No such flow. ### DELETE /engine/flows/:id Delete a flow with its history. - **Authentication:** API key or session - **Scopes:** `reflex` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The flow id. | #### Request examples _curl_ ```bash curl -X DELETE "https://api.subsidia.protypa.fr/engine/flows/$ID" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/' + id, { method: 'DELETE', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) console.log(res.status) ``` _Python_ ```python import os, requests res = requests.delete( "https://api.subsidia.protypa.fr" + "/engine/flows/" + id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code) ``` #### Responses **204**: Deleted. **404**: No such flow. ### POST /engine/flows/:id/run Run a flow now. Queues a manual run and returns immediately; follow it with the run endpoints or the live stream. A manual run is subject to the same rules as any other: blocking issues refuse it, and an outbound step still waits for its approval. It works whether or not the flow is enabled. - **Authentication:** API key or session - **Scopes:** `reflex` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The flow id. | #### Request examples _curl_ ```bash curl -X POST "https://api.subsidia.protypa.fr/engine/flows/$ID/run" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/' + id + '/run', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr" + "/engine/flows/" + id + "/run", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **202**: Queued. ```json { "runId": "run_5d20" } ``` **400**: The flow has a blocking issue. ```json { "error": "Fix the flow first", "issues": [] } ``` **404**: No such flow. **409**: A run for this slot already exists. ### POST /engine/flows/:id/webhook Start a flow from an outside system (webhook trigger). The only Reflex route without an API key: the caller is a business application or a controller with no session, so the **shared secret of the trigger** is the credential. The flow needs a `webhook` trigger step with a secret; without one the route refuses (`403 webhook_secret_required`) rather than treating the URL as public. The secret is compared in constant time. The JSON body you post becomes the trigger output, available to later steps as `{{trigger.output}}`. Send `x-reflex-event-id` to make a retry of the same fact start one run instead of two. Active hours, concurrency and the approval rule apply as for any trigger: this route opens no back door. - **Authentication:** Header x-reflex-secret (the trigger secret), no API key #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The flow id. | #### Headers | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `x-reflex-secret` | `string` | yes | | The shared secret configured on the webhook trigger step. | | `x-reflex-event-id` | `string` | no | | Your own id for the event (up to 200 characters). A second call with the same id starts nothing. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `(any JSON)` | `object` | no | | Free-form payload handed to the flow. | #### Request examples _curl_ ```bash curl -X POST https://api.subsidia.protypa.fr/engine/flows/$ID/webhook \ -H "x-reflex-secret: $REFLEX_SECRET" \ -H "x-reflex-event-id: invoice-2026-0412" \ -H "Content-Type: application/json" \ -d '{"invoice": "2026-0412", "client": "Durand"}' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/engine/flows/' + flowId + '/webhook', { method: 'POST', headers: { 'x-reflex-secret': process.env.REFLEX_SECRET!, 'x-reflex-event-id': 'invoice-2026-0412', 'Content-Type': 'application/json', }, body: JSON.stringify({ invoice: '2026-0412', client: 'Durand' }), }) console.log(res.status, await res.json()) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr/engine/flows/" + flow_id + "/webhook", headers={ "x-reflex-secret": os.environ["REFLEX_SECRET"], "x-reflex-event-id": "invoice-2026-0412", }, json={"invoice": "2026-0412", "client": "Durand"}, ) print(res.status_code, res.json()) ``` #### Responses **202**: A run was started. ```json { "started": 1 } ``` **401**: Wrong secret. ```json { "error": "Bad secret" } ``` **403**: The trigger has no secret (`webhook_secret_required`). **404**: No such flow, or it has no webhook trigger. **409**: The flow is not active, or nothing started (`no_run_started`): it is busy with concurrency `skip`, the call is outside active hours, or the `x-reflex-event-id` was already delivered. ### GET /engine/flows/:id/runs Run history of a flow, newest first. - **Authentication:** API key or session - **Scopes:** `reflex` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The flow id. | #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `limit` | `integer` | no | `30` | Up to 100. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/engine/flows/$ID/runs?limit=10" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/flows/' + id + '/runs' + '?limit=10', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/engine/flows/" + id + "/runs" + "?limit=10", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: Run summaries. `status` is one of `queued`, `running`, `awaiting-approval`, `succeeded`, `failed`, `cancelled`, `stalled`. ```json { "runs": [ { "id": "run_5d20", "status": "succeeded", "triggeredBy": "schedule", "actionable": true, "tokensSpent": 3120, "scheduledFor": "2026-10-09T06:30:00.000Z", "startedAt": "2026-10-09T06:30:01.000Z", "finishedAt": "2026-10-09T06:30:19.000Z", "error": null, "createdAt": "2026-10-09T06:30:00.000Z" } ] } ``` **404**: No such flow. ### GET /engine/runs/:runId One run with the result of every step. The step-by-step record: for each attempt of each step, its status, input, output, logs, duration and tokens, plus the approvals the run raised. - **Authentication:** API key or session - **Scopes:** `reflex` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `runId` | `string` | yes | | The run id returned when the run was started. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/engine/runs/$RUNID" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/runs/' + runId, { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/engine/runs/" + runId, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: `{ run }`, with `nodeRuns`, `approvals` and `flow: { id, name }`. **404**: No such run in this workspace. ```json { "error": "Run not found" } ``` ### POST /engine/runs/:runId/resume Resume a run that ended badly. Puts a `failed`, `cancelled` or `stalled` run back in the queue. Steps that already succeeded are not run again (their recorded output is replayed), so a three-hour ingestion that died on its last step does not start over. - **Authentication:** API key or session - **Scopes:** `reflex` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `runId` | `string` | yes | | The run id returned when the run was started. | #### Request examples _curl_ ```bash curl -X POST "https://api.subsidia.protypa.fr/engine/runs/$RUNID/resume" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/runs/' + runId + '/resume', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr" + "/engine/runs/" + runId + "/resume", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: Queued again. ```json { "ok": true } ``` **400**: The run is not in a state that can be resumed. **404**: No such run. ### POST /engine/runs/:runId/cancel Cancel a queued, running or waiting run. - **Authentication:** API key or session - **Scopes:** `reflex` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `runId` | `string` | yes | | The run id returned when the run was started. | #### Request examples _curl_ ```bash curl -X POST "https://api.subsidia.protypa.fr/engine/runs/$RUNID/cancel" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/runs/' + runId + '/cancel', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr" + "/engine/runs/" + runId + "/cancel", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: Cancelled. ```json { "ok": true } ``` **404**: No such run, or it is already finished (`Nothing to cancel`). ### GET /engine/runs/:runId/stream Follow a run live (Server-Sent Events). Events: `run.started`, `node.started`, `node.log`, `node.finished` (with `status`, `summary`, `actionable`, `durationMs`, `tokens`), `run.finished` and `approval.requested`. Each is sent as a `data:` line holding JSON. A `: ping` comment is sent every 15 seconds. Read it with `fetch` and a stream reader: `EventSource` cannot send the Authorization header. - **Authentication:** API key or session - **Scopes:** `reflex` - **Streaming:** yes, Server-Sent Events when `stream: true` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `runId` | `string` | yes | | The run id returned when the run was started. | #### Request examples _curl_ ```bash curl -N https://api.subsidia.protypa.fr/engine/runs/$RUNID/stream \ -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr/engine/runs/' + runId + '/stream', { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const reader = res.body!.getReader() const decoder = new TextDecoder() let buffer = '' for (;;) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop()! for (const line of lines) { if (line.startsWith('data: ')) console.log(JSON.parse(line.slice(6))) } } ``` _Python_ ```python import json, os, requests with requests.get( "https://api.subsidia.protypa.fr/engine/runs/" + run_id + "/stream", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, stream=True, ) as res: for line in res.iter_lines(decode_unicode=True): if line and line.startswith("data: "): print(json.loads(line[6:])) ``` #### Responses **200**: `text/event-stream`. The stream only carries events emitted while you are connected; read the run with `GET /engine/runs/:runId` for what happened before. **404**: No such run. ### GET /engine/approvals Approvals waiting for a decision. Everything parked in the inbox: what the step wants to do (`summary`), its exact `payload` (for an email, the message that would be sent), and when it expires. An API key can read the list but cannot decide: a person approves or rejects in the app, from the link in a notification, or through `POST /engine/approvals/:id/decide` with a signed-in session. - **Authentication:** API key or session - **Scopes:** `reflex` #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/engine/approvals" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/approvals', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/engine/approvals", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: Pending approvals, newest first. ```json { "approvals": [ { "id": "apr_9c02", "runId": "run_5d20", "flowId": "flw_7a11", "flowName": "Relance des factures", "nodeKey": "send_mail", "kind": "approval", "summary": "Send 3 reminder emails", "payload": {}, "expiresAt": "2026-10-12T06:30:00.000Z", "createdAt": "2026-10-09T06:30:15.000Z" } ] } ``` ### POST /engine/approvals/:id/decide Approve or reject a waiting step (signed-in person only). Refused for API keys by design. The decision is recorded with the person who made it. The same decision logic serves the in-app panel and the links sent in emails and push notifications. - **Authentication:** User session (JWT). API keys are refused. #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The approval id. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `decision` | `string` | yes | | What to do. One of: `approve`, `reject`. | | `note` | `string` | no | | Optional comment. | #### Request examples _curl_ ```bash curl -X POST https://api.subsidia.protypa.fr/engine/approvals/$ID/decide \ -H "Authorization: Bearer $USER_JWT" \ -H "x-workspace-id: $WORKSPACE_ID" \ -H "Content-Type: application/json" \ -d '{"decision": "approve"}' ``` #### Responses **200**: Recorded; the run continues (approve) or stops that branch (reject). ```json { "ok": true } ``` **400**: `decision` is not `approve` or `reject`. **409**: Nothing to decide: already decided or expired. The body says why. #### Errors | Status | Code | When | | --- | --- | --- | | 401 | | No user session. An API key cannot reach this route. | ### GET /engine/connections List the services registered for flows. Connections are external services a person registered once in the app (Reflex, Connections) with a base URL and credential headers, used by the `connection.call` step. Header values are never returned. Creating, changing, testing and deleting connections requires a signed-in person, because they hold credentials; a step can only supply a path, so a flow cannot send the credentials anywhere else. - **Authentication:** API key or session - **Scopes:** `reflex` #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/engine/connections" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/engine/connections', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/engine/connections", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: The connections, without their secret header values. ## Getting notified instead of polling Two [webhook events](https://dev.subsidia.protypa.fr/docs/webhooks.md) are pushed to your own system so you do not have to poll: `flow.run.finished` (run id, flow, status, whether it was actionable) and `flow.approval.requested` (run id, step key, approval id). Both carry identifiers only; fetch the run for details. ## Frequently asked **Can a flow send emails on its own?** Only if you waive the approval, which requires a signed-in person, is off by default and is logged. With the default setting every `mail.send`, `http.call` and `connection.call` needs an `approval` in front of it, and `mail.reply` always stops on the exact message. **My machine was off over the weekend. What runs on Monday?** One catch-up run per flow (policy `fire-once`), not one per missed slot. Use `skip` on a trigger if a late run is useless. **Does a run cost questions?** Background work does not consume your question allowance; it is metered internally. A flow can still have a `dailyTokenBudget` as a guard rail. **Can I run Reflex in the cloud?** Not today. Flows can be edited through the API, but the scheduler only runs on local-mode installations. --- # Partner API > For approved integrators: create client workspaces, issue each client a restricted API key, and read the usage and statements you re-invoice from, all over the API. The Partner API is for integrators who run Subsidia for several client organisations from one account. It is not the general developer API: it manages the **client workspaces you own**, not your own data. Everything here is also available in the app (Partner space); each step exists over the API so a client can be provisioned without a browser. For the bigger picture (architecture, one key per client, what to hand to a client's legal team) read [Building on Subsidia for your clients](https://dev.subsidia.protypa.fr/docs/integrators.md). ## Two gates Every `/partner/*` route is behind both: 1. **The account must be partner-enabled** by Protypa. Until then these routes answer `403` with `code: "PARTNER_NOT_ENABLED"`. Contact Protypa to join the programme. 2. **A key must carry the `partner` scope explicitly.** A key without it is refused with `403 scope_denied`, even on a partner-enabled account, and so is a legacy full-access key created before scopes existed (otherwise the desktop app's device key could create clients). A signed-in session in the app does not need a scope. Create the partner key in the Subsidia console with the `partner` scope only: it manages your fleet and nothing else. > **WARNING: Keep the partner key out of client software** > The partner key can create workspaces, mint keys and read every client's statement. Keep it on your own back end. What you hand to a client's application is a **client key** (below): restricted, ceilinged, and unable to carry the `partner` scope. ## The model **A client workspace is owned by you. Its usage counts against your account: one bill from Protypa to you, one bill from you to each client.** Flow: Protypa -> Your partner account -> Client workspace -> Client key -> Client's application A client workspace is separate from the others: every read is scoped to it, and a dossier can be reserved to named people. You own it, so you can reach its documents, its access journal and its keys. Every member of a client workspace sees a notice that you run it, and a client administrator can acknowledge it; the acknowledgement date is visible to you in the client list (`managedAckAt`). ## Billing model | Direction | How it is computed | Where you see it | | --- | --- | --- | | **Protypa to you (wholesale)** | Per active seat across all your client workspaces, plus questions consumed that month, priced on a marginal volume grid (each tier at its own rate, like income tax), with an optional monthly platform minimum. Questions are weighted by model class. Verifier checks are counted as **controls**, apart from questions. | `GET /partner/wholesale` (the live grid is in the response), `GET /partner/invoices` | | **You to your client (resale)** | Whatever you set per client: a rate per 100 questions, an optional price per seat, and optional questions included per seat. Subsidia never bills your client; you do. | `GET /partner/clients`, `GET /partner/clients/:id/statement`, `GET /partner/usage.csv` | | **Margin** | Your resale total minus the wholesale price for the same seats and questions. | `marginCents` in a client statement | Amounts are in euro cents, excluding tax. An invoice left unpaid past the grace period (7 days) suspends your clients' access (nothing is deleted) until it is paid. Customer-facing copy talks about questions, not tokens: a question is a unit of model usage with a fixed ceiling per answer, see [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md). ## Endpoints ### GET /partner/status Is this account a partner? The only partner route open to a key that has the scope on a non-partner account. Use it to check your setup before anything else. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/partner/status" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/status', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/partner/status", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: Whether the programme is enabled. ```json { "enabled": true } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 403 | `scope_denied` | The key does not carry the `partner` scope. | ### GET /partner/clients List your client workspaces with this month usage. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/partner/clients" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/partner/clients", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: One entry per client, oldest first, and totals. `monthAmountCents` is `null` while a client has no resale rate; `totals.monthAmountCents` is `null` unless every client has one. ```json { "clients": [ { "id": "ws_9d41", "name": "Cabinet Durand", "createdAt": "2026-08-12T09:00:00.000Z", "members": 4, "pendingInvitations": 1, "apiKeys": 2, "monthCalls": 812, "monthQuestions": 640, "monthCostUsd": 0.91, "lastActivityAt": "2026-10-09T07:58:00.000Z", "ratePer100Cents": 500, "seatRateCents": 1900, "includedQuestionsPerSeat": 400, "seats": 5, "monthAmountCents": 9500, "managedAckAt": "2026-08-14T13:20:00.000Z", "suspendedAt": null, "unpaidMonths": [] } ], "totals": { "clients": 1, "monthCalls": 812, "monthQuestions": 640, "monthCostUsd": 0.91, "monthAmountCents": 9500 } } ``` #### Errors | Status | Code | When | | --- | --- | --- | | 403 | `PARTNER_NOT_ENABLED` | The account is not a partner. | | 403 | `scope_denied` | The key does not carry the `partner` scope. | ### POST /partner/clients Create a client workspace. Creates a workspace owned by you. With `adminEmail`, that person is invited as the client's administrator; without it the workspace exists but nobody there can sign in yet (your own API keys still work through the keys you mint). - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | `string` | yes | | 1 to 80 characters. | | `adminEmail` | `string` | no | | Email of the client manager to invite as administrator. | | `ratePer100Cents` | `integer` | no | | Your resale rate per 100 questions, in euro cents (0 to 1,000,000), or `null`. | #### Request examples _curl_ ```bash curl -X POST "https://api.subsidia.protypa.fr/partner/clients" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -H "Content-Type: application/json" \n -d '{ "name": "Cabinet Durand", "adminEmail": "direction@cabinet-durand.example", "ratePer100Cents": 500 }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "name": "Cabinet Durand", "adminEmail": "direction@cabinet-durand.example", "ratePer100Cents": 500 }), }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr" + "/partner/clients", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "name": "Cabinet Durand", "adminEmail": "direction@cabinet-durand.example", "ratePer100Cents": 500 }, ) print(res.status_code, res.json()) ``` #### Responses **201**: The client, and the invitation if one was sent. ```json { "client": { "id": "ws_9d41", "name": "Cabinet Durand", "createdAt": "2026-10-09T09:00:00.000Z" }, "invitation": { "email": "direction@cabinet-durand.example", "role": "admin" } } ``` **400**: No name, or an invalid admin email. ```json { "error": "A client needs a name (and a valid admin email, if given)" } ``` ### PATCH /partner/clients/:id Rename a client, set its resale terms, or suspend it. Send at least one field. Money fields are non-negative whole numbers (euro cents or questions); `null` clears one. `suspended: true` refuses every API key of the client with `403 client_suspended` without revoking any, and `false` restores them: a pause button for a client who has not paid. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The client workspace id returned when the client was created. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | `string` | no | | New name, 1 to 80 characters. | | `ratePer100Cents` | `integer` | no | | Your price per 100 questions beyond the included ones. | | `seatRateCents` | `integer` | no | | Your price per active seat per month (up to 1,000,000). | | `includedQuestionsPerSeat` | `integer` | no | | Questions included per seat before the per-100 rate applies. | | `suspended` | `boolean` | no | | Cut off, or restore, all API keys of this client. | #### Request examples _curl_ ```bash curl -X PATCH "https://api.subsidia.protypa.fr/partner/clients/$ID" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -H "Content-Type: application/json" \n -d '{ "seatRateCents": 1900, "includedQuestionsPerSeat": 400, "ratePer100Cents": 500 }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id, { method: 'PATCH', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "seatRateCents": 1900, "includedQuestionsPerSeat": 400, "ratePer100Cents": 500 }), }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.patch( "https://api.subsidia.protypa.fr" + "/partner/clients/" + id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "seatRateCents": 1900, "includedQuestionsPerSeat": 400, "ratePer100Cents": 500 }, ) print(res.status_code, res.json()) ``` #### Responses **200**: The updated client. ```json { "client": { "id": "ws_9d41", "name": "Cabinet Durand" } } ``` **400**: Nothing valid to update. ```json { "error": "Nothing valid to update" } ``` **404**: Not a client of yours. ```json { "error": "Client not found" } ``` ### POST /partner/clients/:id/keys Issue an API key to a client application. A restricted key for the client's own software. It must name at least one capability scope (`engine`, `knowledge`, `reflex`, `light`, `webhooks`, `proof`), may add `readonly`, and can never carry `partner`. Optional ceilings protect you from a runaway integration: `monthlyQuestionLimit` answers `402 API_KEY_BUDGET_EXCEEDED` once reached (counted from usage logs, so it can lag a burst by a few seconds), `ratePerMinute` answers `429` with `Retry-After`. The raw key is in the response **once**. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The client workspace id returned when the client was created. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | `string` | yes | | 1 to 80 characters, for your own reference. | | `scopes` | `string[]` | yes | | Non-empty. At least one capability scope; not `partner`. | | `monthlyQuestionLimit` | `integer` | no | | Calendar-month ceiling in questions. Verifier controls do not count. | | `ratePerMinute` | `integer` | no | | Requests per minute, up to 100,000. | | `expiresAt` | `string` | no | | ISO 8601 date-time after which the key stops working. | #### Request examples _curl_ ```bash curl -X POST "https://api.subsidia.protypa.fr/partner/clients/$ID/keys" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -H "Content-Type: application/json" \n -d '{ "name": "Portail Durand", "scopes": [ "engine", "knowledge", "proof" ], "monthlyQuestionLimit": 2000, "ratePerMinute": 60 }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/keys', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "name": "Portail Durand", "scopes": [ "engine", "knowledge", "proof" ], "monthlyQuestionLimit": 2000, "ratePerMinute": 60 }), }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/keys", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "name": "Portail Durand", "scopes": [ "engine", "knowledge", "proof" ], "monthlyQuestionLimit": 2000, "ratePerMinute": 60 }, ) print(res.status_code, res.json()) ``` #### Responses **201**: The key (`key`) and its settings. ```json { "key": "sk_live_3f9a...c2", "id": "key_71ab", "name": "Portail Durand", "prefix": "sk_live_3f9a1c07", "scopes": ["engine", "knowledge", "proof"], "monthlyQuestionLimit": 2000, "ratePerMinute": 60, "expiresAt": null, "note": "Store this key now — it will not be shown again." } ``` **400**: Invalid body or scopes. ```json { "error": "a client key cannot carry the partner scope" } ``` **404**: Not a client of yours. ### GET /partner/clients/:id/keys List the active keys of a client. Never the secret. `monthQuestions` is the count the ceiling is enforced on, so the gauge and a `402` never disagree. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The client workspace id returned when the client was created. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/partner/clients/$ID/keys" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/keys', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/keys", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: The client and its active keys. ```json { "client": { "id": "ws_9d41", "name": "Cabinet Durand", "createdAt": "2026-08-12T09:00:00.000Z" }, "keys": [ { "id": "key_71ab", "name": "Portail Durand", "prefix": "sk_live_3f9a1c07", "scopes": ["engine", "knowledge", "proof"], "monthlyQuestionLimit": 2000, "ratePerMinute": 60, "usageCount": 1840, "lastUsedAt": "2026-10-09T07:58:00.000Z", "createdAt": "2026-08-12T09:10:00.000Z", "expiresAt": null, "monthQuestions": 640 } ] } ``` **404**: Not a client of yours. ### DELETE /partner/clients/:id/keys/:keyId Revoke a client key. The client application loses access at its next call. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The client workspace id returned when the client was created. | | `keyId` | `string` | yes | | The key `id`. | #### Request examples _curl_ ```bash curl -X DELETE "https://api.subsidia.protypa.fr/partner/clients/$ID/keys/$KEYID" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/keys/' + keyId, { method: 'DELETE', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) console.log(res.status) ``` _Python_ ```python import os, requests res = requests.delete( "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/keys/" + keyId, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code) ``` #### Responses **204**: Revoked. **404**: Unknown key or client. ```json { "error": "Key not found" } ``` ### GET /partner/clients/:id/usage Monthly usage of a client, oldest first. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The client workspace id returned when the client was created. | #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `months` | `integer` | no | `6` | How many calendar months, 1 to 24, empty months included. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/partner/clients/$ID/usage?months=3" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/usage' + '?months=3', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/usage" + "?months=3", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: Calls, questions and estimated provider cost in USD per month. ```json { "client": { "id": "ws_9d41", "name": "Cabinet Durand", "createdAt": "2026-08-12T09:00:00.000Z" }, "months": [ { "month": "2026-08", "calls": 120, "questions": 98, "costUsd": 0.12 }, { "month": "2026-09", "calls": 701, "questions": 566, "costUsd": 0.77 }, { "month": "2026-10", "calls": 812, "questions": 640, "costUsd": 0.91 } ] } ``` **404**: Not a client of yours. ### GET /partner/clients/:id/statement A client monthly statement: what to bill, and your margin. The document you re-invoice from. It computes the client total at **your** resale terms (seats, included questions, questions beyond them), next to Protypa's wholesale price for the same usage and the resulting margin. `controls` is the number of Verifier checks that month, shown apart from questions. For a closed month, seats are counted at month end; for the running month, as they stand now (`seatsBasis`). - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The client workspace id returned when the client was created. | #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `month` | `string` | no | `current month` | Calendar month as `YYYY-MM`, for example `2026-09`. Anything else is a 400. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/partner/clients/$ID/statement?month=2026-09" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/statement' + '?month=2026-09', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/statement" + "?month=2026-09", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: The statement. Fields that depend on a resale rate you have not set are `null`. ```json { "month": "2026-09", "clientName": "Cabinet Durand", "issuer": { "name": "Integrator SAS", "email": "billing@integrator.example" }, "seatsBasis": "month-end", "seats": 5, "seatRateCents": 1900, "seatAmountCents": 9500, "questions": 566, "controls": 128, "includedQuestions": 2000, "billableQuestions": 0, "ratePer100Cents": 500, "questionAmountCents": 0, "totalCents": 9500, "wholesale": { "seatCents": 1000, "questionCents100": 449, "seatAmountCents": 5000, "questionAmountCents": 2541, "totalCents": 7541 }, "marginCents": 1959 } ``` **400**: Bad month. ```json { "error": "month must look like 2026-08" } ``` **404**: Not a client of yours. #### Notes The statement also carries a `usage` breakdown object. The wholesale figures in the example are illustrative: the live grid is returned by `GET /partner/wholesale`. ### GET /partner/clients/:id/payments Which months a client has paid you. Your own ledger, newest first: the statement total of each month, whether it is due (finished, with something to collect) and when you marked it paid. Subsidia never bills your client; this only records what you tell it. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The client workspace id returned when the client was created. | #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `months` | `integer` | no | `6` | 1 to 24. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/partner/clients/$ID/payments" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/payments', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/payments", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: `{ months: [...] }`, one entry per month. **404**: Not a client of yours. ### PUT /partner/clients/:id/payments/:month Mark a month paid or unpaid. Not allowed for a month that has not started. Send `amountCents` only when what you received differs from the statement. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The client workspace id returned when the client was created. | | `month` | `string` | yes | | `YYYY-MM`. | #### Request body | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `paid` | `boolean` | yes | | True to mark paid, false to undo. | | `amountCents` | `integer` | no | | Amount actually received, in euro cents. | #### Request examples _curl_ ```bash curl -X PUT "https://api.subsidia.protypa.fr/partner/clients/$ID/payments/$MONTH" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -H "Content-Type: application/json" \n -d '{ "paid": true }' ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/payments/' + month, { method: 'PUT', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "paid": true }), }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.put( "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/payments/" + month, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, json={ "paid": True }, ) print(res.status_code, res.json()) ``` #### Responses **200**: The recorded payment (`payment`). **400**: Bad month, a future month, or an invalid body. **404**: Not a client of yours. ### GET /partner/usage.csv One line per client for a month, to re-invoice from. Semicolon-separated with a UTF-8 byte-order mark and CRLF line ends, so a French spreadsheet opens it in columns. A total line closes the file. Columns: `mois`, `client`, `appels`, `questions`, `tarif_pour_100_questions_eur`, `montant_ht_eur`, `sieges`, `tarif_par_siege_eur`, `montant_sieges_ht_eur`, `questions_incluses`, `questions_facturables`, `montant_questions_ht_eur`. `montant_ht_eur` is the client total (seats plus billable questions). - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `month` | `string` | no | `previous month` | Calendar month as `YYYY-MM`, for example `2026-09`. Anything else is a 400. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/partner/usage.csv?month=2026-09" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -o clients-2026-09.csv ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/usage.csv' + '?month=2026-09', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const buffer = Buffer.from(await res.arrayBuffer()) await writeFile('clients-2026-09.csv', buffer) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/partner/usage.csv" + "?month=2026-09", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) open('clients-2026-09.csv', 'wb').write(res.content) ``` #### Responses **200**: `text/csv; charset=utf-8`, attachment `clients-.csv`. **400**: Bad month. ### GET /partner/wholesale What this account owes Protypa. Current seats across every workspace you run and the month's questions, priced on the standing wholesale grid. The response includes the live `grid` (marginal tiers) and the weights per `modelClasses`, so you can forecast. `controls` and `controlAmountCents` show Verifier checks apart from questions. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `month` | `string` | no | `current month` | Calendar month as `YYYY-MM`, for example `2026-09`. Anything else is a 400. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/partner/wholesale?month=2026-10" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/wholesale' + '?month=2026-10', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/partner/wholesale" + "?month=2026-10", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: The wholesale statement (abridged). ```json { "month": "2026-10", "seats": 12, "seatCents": 1000, "seatAmountCents": 12000, "questions": 4120, "questionCents100": 449, "questionAmountCents": 18499, "controls": 128, "controlAmountCents": 0, "minimumCents": 0, "totalCents": 30499, "tiers": [{ "from": 0, "upTo": 50000, "cents100": 449, "questions": 4120, "amountCents": 18499 }], "grid": [{ "upTo": 50000, "cents100": 449 }, { "upTo": null, "cents100": 49 }] } ``` **400**: Bad month. #### Notes Illustrative numbers. The grid is set by Protypa and can change: read it from this endpoint rather than hard-coding it. ### GET /partner/invoices Your wholesale invoices and what is outstanding. Every invoice Protypa sent you, the outstanding amount (sent, not yet paid), whether automatic debit is on, whether your clients are currently suspended for non-payment, and the grace period in days. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/partner/invoices" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/invoices', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/partner/invoices", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: Invoices and billing state (the invoice objects are abridged here). ```json { "invoices": [], "outstandingCents": 0, "autoDebit": true, "autoDebitSince": "2026-09-02T10:00:00.000Z", "suspended": false, "graceDays": 7, "platformFeeCents": 0 } ``` ### GET /partner/devices Every paired desktop install across your clients. A support view: which machines run Subsidia at your clients, their state (`ok`, `attention`, `offline`, `expiring`, `never-seen`, `revoked`) and version, without asking each client. A desktop is paired by signing the app in with your account and choosing the client workspace. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/partner/devices" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/devices', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/partner/devices", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: The fleet. ### DELETE /partner/devices/:id Remove a paired install. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The device id from `GET /partner/devices`. | #### Request examples _curl_ ```bash curl -X DELETE "https://api.subsidia.protypa.fr/partner/devices/$ID" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/devices/' + id, { method: 'DELETE', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) console.log(res.status) ``` _Python_ ```python import os, requests res = requests.delete( "https://api.subsidia.protypa.fr" + "/partner/devices/" + id, headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code) ``` #### Responses **204**: Removed. **404**: Unknown device. ```json { "error": "Device not found" } ``` ### Paying Protypa Paying the wholesale invoice goes through Stripe Checkout, so these routes return a URL to open in a browser rather than a receipt. The two `confirm` routes are what your redirect page calls after Stripe sends the person back; they are idempotent and do not wait for Stripe's own notification. ### POST /partner/invoices/:id/pay Open a Checkout session for one invoice. Charges the invoice's exact amount. Refused if the invoice is already paid or void. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The invoice id from `GET /partner/invoices`. | #### Request examples _curl_ ```bash curl -X POST "https://api.subsidia.protypa.fr/partner/invoices/$ID/pay" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/invoices/' + id + '/pay', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr" + "/partner/invoices/" + id + "/pay", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: Open `url` in a browser. ```json { "sessionId": "cs_live_a1...", "url": "https://checkout.stripe.com/c/pay/cs_live_a1..." } ``` **400**: Already paid, void, or the session could not start. `error` says which. ### GET /partner/invoices/:id/confirm Confirm a payment after the Stripe redirect. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Path parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | `string` | yes | | The invoice id. | #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `session_id` | `string` | yes | | The Checkout session id Stripe appended to your return URL. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/partner/invoices/$ID/confirm?session_id=cs_live_a1" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/invoices/' + id + '/confirm' + '?session_id=cs_live_a1', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/partner/invoices/" + id + "/confirm" + "?session_id=cs_live_a1", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: The payment state after verification. **400**: `session_id` missing, or the payment could not be verified. ### POST /partner/auto-debit Authorise automatic debit of the monthly invoice. Opens a Stripe Checkout session in setup mode where you save a card or SEPA account once. Each monthly wholesale invoice is then debited at its exact amount. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Request examples _curl_ ```bash curl -X POST "https://api.subsidia.protypa.fr/partner/auto-debit" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/auto-debit', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.post( "https://api.subsidia.protypa.fr" + "/partner/auto-debit", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: Open `url` in a browser. ```json { "sessionId": "cs_live_b2...", "url": "https://checkout.stripe.com/c/setup/cs_live_b2..." } ``` **400**: The authorisation could not start. ### GET /partner/auto-debit/confirm Confirm the debit authorisation after the Stripe redirect. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Query parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `session_id` | `string` | yes | | The Checkout session id. | #### Request examples _curl_ ```bash curl "https://api.subsidia.protypa.fr/partner/auto-debit/confirm?session_id=cs_live_b2" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/auto-debit/confirm' + '?session_id=cs_live_b2', { method: 'GET', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) const data = await res.json() console.log(res.status, data) ``` _Python_ ```python import os, requests res = requests.get( "https://api.subsidia.protypa.fr" + "/partner/auto-debit/confirm" + "?session_id=cs_live_b2", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code, res.json()) ``` #### Responses **200**: The authorisation is saved. **400**: `session_id` missing or not verifiable. ### DELETE /partner/auto-debit Stop the automatic debit. Forgets the saved payment method. Invoices are then paid by hand from Checkout. - **Authentication:** API key with the partner scope, or session - **Scopes:** `partner` #### Request examples _curl_ ```bash curl -X DELETE "https://api.subsidia.protypa.fr/partner/auto-debit" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" ``` _TypeScript_ ```typescript const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/auto-debit', { method: 'DELETE', headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY }, }) console.log(res.status) ``` _Python_ ```python import os, requests res = requests.delete( "https://api.subsidia.protypa.fr" + "/partner/auto-debit", headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}, ) print(res.status_code) ``` #### Responses **204**: Forgotten. ## Provision a client end to end **Create, key, check** _TypeScript_ ```typescript const base = 'https://api.subsidia.protypa.fr' const headers = { Authorization: 'Bearer ' + process.env.SUBSIDIA_PARTNER_KEY, // sk_live_... with the partner scope 'Content-Type': 'application/json', } // 1. A workspace for the client, with its manager invited as administrator const { client } = await fetch(base + '/partner/clients', { method: 'POST', headers, body: JSON.stringify({ name: 'Cabinet Durand', adminEmail: 'direction@cabinet-durand.example', ratePer100Cents: 500 }), }).then((r) => r.json()) // 2. A restricted key for the client's own application (shown once) const { key } = await fetch(base + '/partner/clients/' + client.id + '/keys', { method: 'POST', headers, body: JSON.stringify({ name: 'Portail Durand', scopes: ['engine', 'proof'], monthlyQuestionLimit: 2000, ratePerMinute: 60 }), }).then((r) => r.json()) // hand "key" to the client's application: it can call the Gateway and the Verifier, nothing else // 3. At month end const statement = await fetch(base + '/partner/clients/' + client.id + '/statement?month=2026-09', { headers }).then((r) => r.json()) console.log(statement.totalCents, statement.controls, statement.marginCents) ``` _Python_ ```python import os, requests base = "https://api.subsidia.protypa.fr" headers = {"Authorization": "Bearer " + os.environ["SUBSIDIA_PARTNER_KEY"]} # partner scope client = requests.post( base + "/partner/clients", headers=headers, json={"name": "Cabinet Durand", "adminEmail": "direction@cabinet-durand.example", "ratePer100Cents": 500}, ).json()["client"] key = requests.post( base + "/partner/clients/" + client["id"] + "/keys", headers=headers, json={"name": "Portail Durand", "scopes": ["engine", "proof"], "monthlyQuestionLimit": 2000, "ratePerMinute": 60}, ).json()["key"] # shown once statement = requests.get( base + "/partner/clients/" + client["id"] + "/statement", headers=headers, params={"month": "2026-09"}, ).json() print(statement["totalCents"], statement["controls"], statement["marginCents"]) ``` ## Frequently asked **Why does my key get scope_denied on a partner account?** The key was created without the `partner` scope, or before scopes existed. Create a new key and tick `partner`. A client key can never carry it. **What does suspending a client do?** Every API key of that workspace answers `403 client_suspended` until you set `suspended: false`. Nothing is revoked or deleted, so it is reversible with no re-issuing of keys. **Does the client see what I charge?** No. Statements and rates are yours. The client sees the usage its own keys and members generate. **Are Verifier checks billed?** They are counted per client as controls, apart from questions, and appear on statements. At the time of writing no price is attached to them. --- # Building on Subsidia for your clients > A guide for integrators who put Subsidia behind their own software for several client organisations: architecture, one workspace and one key per client, billing, and what to tell a client legal team. You keep your stack: your models, your retrieval, your automations, your interface. Subsidia adds the layer your clients' legal team asks for, one call at a time: personal data masked before it reaches an external provider, usage metered per client, and a signed proof for what the AI did. This page is the map. The [Partner API](https://dev.subsidia.protypa.fr/docs/partner.md) is the reference for provisioning and billing; the other pages are linked where they apply. ## Four things you can hand to your clients | Brick | In one sentence | How | | --- | --- | --- | | **Gateway** | Every model call goes through Subsidia and comes back with a signed proof certificate. Without a Subsidia prefix in the model name it is a faithful passthrough. | Change one base URL. [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md) | | **Le Vérificateur** | Gives every claim of an answer a verdict against the passages it should rest on (yours or Subsidia's), with zero model calls, and signs the report. | `POST /v1/verify`. [Le Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md) | | **Le Registre** | The AI Act record of what the AI did for a client, exported as a signed zip its legal team can file. | `GET /v1/registre/export`. [Le Registre](https://dev.subsidia.protypa.fr/docs/registre.md) | | **Certificate check** | Anyone can check a certificate without an account: paste it on the public verification page, or verify it offline with the public key. | `GET /v1/gateway/public-key`. [Proof certificates](https://dev.subsidia.protypa.fr/docs/proofs.md) | A model call is counted in **questions**. A Vérificateur check calls no model and is counted as a **control**, separately. Both appear on each client statement. ## Architecture **Your application talks to Subsidia with the client key. You administer the fleet with your partner key, from your own back end.** Flow: Client's users -> Your application -> Client key -> Subsidia (client workspace) -> Model providers 1. **One workspace per client** Create a client workspace for each organisation you serve with `POST /partner/clients`. Reads and writes are scoped to the workspace, a dossier can be reserved to named people, and usage, documents, journal and keys never mix between clients. Never run two clients in the same workspace to save a seat: you would lose the statement, the register and the isolation. 2. **One restricted key per client application** Mint a key for each client's application with `POST /partner/clients/:id/keys`, naming only the capabilities it needs and setting a monthly question ceiling and a rate limit. If a key leaks, you revoke that one key; if the client stops paying, you suspend the workspace without touching the keys. 3. **Call Subsidia from your back end with that key** Put the Gateway base URL in your OpenAI or Anthropic client, or call the Cortex and Vérificateur endpoints directly. Keep the partner key out of this path entirely. 4. **Read the statement at month end** `GET /partner/clients/:id/statement?month=YYYY-MM` gives the client total at your resale terms, the wholesale price and your margin. `GET /partner/usage.csv` gives one line per client for a spreadsheet. ## Keys: scopes, ceilings, read-only | Scope | Reaches | | --- | --- | | `engine` | `/v1/chat/completions`, `/v1/messages`, `/v1/models`, `/v1/ai/*`, `/v1/proofs`, `/v1/gateway/*` | | `knowledge` | `/v1/knowledge*` (sources, query, insights) | | `reflex` | `/engine/*`: flows, runs, approvals list | | `light` | `/light/*` | | `proof` | `/v1/verify*` and `/v1/registre*` | | `webhooks` | `/v1/webhooks*` | | `partner` | `/partner/*`. Partner keys only; a client key can never carry it. | | `readonly` | Not a family: the key can read and ask questions (chat, query, verify), and never change anything. | A key that names at least one capability is restricted to those routes; anything else answers `403 scope_denied`. Keys created before scopes existed keep their full access, which is why a key for a client application must be created through the partner API, which requires a capability. Two optional ceilings protect you from a runaway integration: - `monthlyQuestionLimit` refuses the key with `402 API_KEY_BUDGET_EXCEEDED` once the calendar month's questions are used. It is counted from usage logs, so it can lag a burst by a few seconds: a guard rail, not a meter. Verifier controls do not count. - `ratePerMinute` answers `429` with `Retry-After`. The counter is shared across server instances. Change either later with `PATCH /developer/api-keys/:id`. See [Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md) and [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md). ## Verify your own RAG answers If your product already answers from a retrieval pipeline, the fastest win for a regulated client is to check each answer against the passages that retrieval returned, before showing it. You send the answer and the passages; you get a verdict per claim and a signed report, and Cortex is not involved. _curl_ ```bash curl -X POST https://api.subsidia.protypa.fr/v1/verify \ -H "Authorization: Bearer $CLIENT_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Le préavis est de 30 jours.", "passages": [{ "source": "Bail.pdf", "page": 3, "content": "Le préavis est de 90 jours." }] }' ``` The report says `coverage.mode: "provided"`: it attests that this text, against these passages, gave these verdicts. It does not attest that the passages come from your client's documents, because Subsidia did not see those documents. Say so in your own product copy; the report already does. Details in [Le Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md). ## Automations through the API A key with the `reflex` scope can create, edit, run and follow [flows](https://dev.subsidia.protypa.fr/docs/reflex.md). Three things stay with a signed-in person on purpose: deciding an approval, registering notification channels and connections (they hold credentials), and switching on unattended outbound sending. An outbound step (an email, a POST to a service) therefore always waits for a person, unless a person has allowed that flow to run unattended. To let a flow call a client's software, a person registers the service once in the app (Reflex, Connections) with its base URL and credential headers. The stored values are never shown again and a step can only supply a path, so a flow cannot send the credentials anywhere else. In the cloud, private addresses and redirects are refused. Read `GET /engine/approvals` and push approvals to your own notification tooling with the `flow.approval.requested` [webhook](https://dev.subsidia.protypa.fr/docs/webhooks.md). The scheduler itself runs on on-premise installations, not in the cloud. ## Webhooks for your own system Instead of polling, subscribe to `source.ingested`, `source.failed`, `knowledge.contradiction_detected`, `knowledge.stale`, `flow.run.finished` and `flow.approval.requested`. Payloads carry identifiers and status, never document or answer content. A failed delivery is retried three times quickly, then after 5 minutes, 30 minutes, 2 hours and 12 hours. Signature checking, the delivery log and replay are in [Webhooks](https://dev.subsidia.protypa.fr/docs/webhooks.md). In the cloud, webhook URLs must be public addresses. ## Installs at your clients For clients who want Subsidia on their own machine, sign the desktop app in with your partner account and pick the client's workspace. The machine is paired and appears in `GET /partner/devices`. It renews its certificate daily and keeps working offline for the certificate's lifetime; the state reads `offline` after 3 days without contact and `expiring` shortly before the offline tolerance ends. A machine whose version is behind the latest release is flagged. ## What to tell a client legal team > **INFO: Working note, October 2026** > This is a summary for your conversations with a client's legal department or data protection officer, not a contract. Figures such as delays, notice periods, retention and liability are fixed in the contract with the client at pilot time. Ask Protypa for the full legal note. **Who is who.** The client is the data controller. You, the integrator, are its processor: you design and operate the application. Protypa, which publishes, hosts and administers Subsidia, is your sub-processor, and the sub-processor relationship is to be covered in the data processing agreement between you and the client. **Where data goes.** Documents, conversations, accounts, journals and certificates are stored in France on hosted servers administered by Protypa. On each question, names, emails, phone numbers, addresses and IBANs are replaced by aliases before anything is sent to an **external** model provider, and the answer is restored in clear for the user. Each client has its own list of authorised model providers; to keep every question inside the European Union, authorise only an EU provider or run a local model on a dedicated machine. A question sent to an authorised provider is processed by that provider after masking. **What Subsidia can prove and control.** - One signed certificate per answer, chained to the previous one, with fingerprints only and no plain content. It can be verified offline without trusting Protypa. - Isolation per client workspace, and dossiers that can be reserved to named people. - An access journal: who consulted what, with human reads and AI reads distinguished. - The AI Act usage register, exportable and signed ([Le Registre](https://dev.subsidia.protypa.fr/docs/registre.md)). - Access keys per client, with a ceiling, revocable immediately; a client can be suspended and restored. - No automatic outbound sending (email, message, service call) without a person's approval, enforced by the engine rather than left to the model. **What Subsidia does not claim.** - No certification (ISO 27001, SOC 2, HDS) and no independent penetration test to date. - Masking greatly reduces the risk of using an external provider; it is not anonymisation in the GDPR sense. - French hosting covers stored data, not the processing a provider does on a masked question you chose to authorise. - Subsidia does not sell data, does not train models on clients' data and claims no right over it or over your tools. **Transparency to the client's staff.** A client workspace is owned by you. Every member sees a notice that you run it and can reach its documents, its access journal and its keys, and a client administrator can acknowledge that notice. The acknowledgement is visible to you in the client list (`managedAckAt` in `GET /partner/clients`). Do not rely on this being invisible: a client team that finds out later will not trust the rest. ## Frequently asked **Can I use one workspace and one key for all my clients?** Technically yes, but you would lose per-client statements, per-client registers, per-client ceilings and isolation. One workspace and one key per client is the supported model. **Who is billed for a client workspace?** You. A client workspace is owned by you and its usage counts against your account: one wholesale bill from Protypa to you. You bill your client at your own terms; Subsidia never bills your client. See [Partner API](https://dev.subsidia.protypa.fr/docs/partner.md). **Can a client use the register without me?** A client administrator who signs in to the workspace sees the register in the app, subject to the plan. Through the API, it is available to any key with the `proof` scope. **How do I test before provisioning real clients?** Create a client workspace named for testing with a low `monthlyQuestionLimit` on its key, run your integration against it, and suspend the workspace (`PATCH /partner/clients/:id` with `suspended: true`) when you are done.