RiskRouter evidence log
Endpoint MCP hébergé pour un journal de preuves inviolable. Sans clé, lisez les en-têtes signés, les preuves d'inclusion et de cohérence et les points de contrôle ; avec une clé, enregistrez le SHA-256 salé d'un enregistrement de décision. L'enregistrement lui-même ne vous quitte jamais.
Serveur MCP hébergé
npx add-mcp 'https://api.riskrouter.eu/mcp'S’installe dans Claude Code, Codex, Cursor et plus
Documentation
There is no SDK to install and nothing to license. You hash a record you keep, send us only the digest, and get back an entry and a proof that anyone can check without us. Everything below runs against the live validation build right now.
Record a fingerprint and prove it, in three steps
You keep the record. We receive its salted SHA-256 and a short kind, never the record, and answer with an entry in the log. The proof that the entry is in the log, under a head we signed, is checked without us. Recording needs a key: take a sandbox key below (one click, no account), then:
export RISKROUTER_API_KEY=rr_sandbox_...
# 1. Fingerprint a record you keep. Salt it, or anyone who can guess the record can confirm it.
SALT=$(openssl rand -hex 16)
DIGEST=$( (printf '%s' "$SALT"; cat decision.json) | sha256sum | cut -d' ' -f1) # macOS: shasum -a 256
# 2. Record the digest. Only "recorded": true means there is an entry.
curl -s https://api.riskrouter.eu/api/v2/evidence \
-H "Authorization: Bearer $RISKROUTER_API_KEY" -H 'Content-Type: application/json' \
-d "{\"record_digest\":\"$DIGEST\",\"kind\":\"ai.decision\"}" | tee entry.json
# 3. Fetch the entry's proof and check it offline, with the public verifier.
LEAF=$(python3 -c 'import json; print(json.load(open("entry.json"))["entry"]["leaf_index"])')
curl -s "https://api.riskrouter.eu/api/v2/evidence/proof?leaf_index=$LEAF" \
-H "Authorization: Bearer $RISKROUTER_API_KEY" > proof.json
git clone https://github.com/aniljangrabe-dev/riskrouter-verify
python3 riskrouter-verify/tools/python/riskrouter_verify.py proof.json --key riskrouter-verify/anchors/
The verifier prints two lines: that the entry is in the tree under the root the proof names, and that we signed that head. It uses no network and no key of ours beyond the public one, in a repository that shares no code with this service. Keep decision.json, the salt and entry.json together: without the record, nobody, including us, can show what it said.
From a Node backend
import { createHash, randomBytes } from 'node:crypto';
import { writeFileSync } from 'node:fs';
// Your record stays with you. Only its salted digest is sent.
const record = JSON.stringify({ case: 'dec-2292', outcome: 'refer', model: 'credit-v7' });
const salt = randomBytes(16).toString('hex');
const record_digest = createHash('sha256').update(salt + record).digest('hex');
const res = await fetch('https://api.riskrouter.eu/api/v2/evidence', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: \`Bearer ${process.env.RISKROUTER_API_KEY}\` },
body: JSON.stringify({ record_digest, kind: 'ai.decision' })
});
const out = await res.json();
// 201 with recorded: true is the only success. A refused write is a 503 with recorded: false.
if (res.status !== 201 || out.recorded !== true) throw new Error(out.error?.code ?? \`HTTP ${res.status}\`);
writeFileSync(\`evidence-${out.entry.leaf_index}.json\`, JSON.stringify({ record, salt, entry: out.entry }));
From Python
import hashlib, json, os, secrets, urllib.request
# Your record stays with you. Only its salted digest is sent.
record = json.dumps({"case": "dec-2292", "outcome": "refer", "model": "credit-v7"})
salt = secrets.token_hex(16)
digest = hashlib.sha256((salt + record).encode()).hexdigest()
req = urllib.request.Request(
"https://api.riskrouter.eu/api/v2/evidence",
data=json.dumps({"record_digest": digest, "kind": "ai.decision"}).encode(),
headers={"Content-Type": "application/json",
"Authorization": "Bearer " + os.environ["RISKROUTER_API_KEY"]},
)
with urllib.request.urlopen(req, timeout=10) as r: # raises on 401, 429 and 503
out = json.load(r)
assert out["recorded"] is True
with open("evidence-%d.json" % out["entry"]["leaf_index"], "w") as f:
json.dump({"record": record, "salt": salt, "entry": out["entry"]}, f)
For a record someone else must be able to re-hash byte for byte, use the canonical form the regime packs define: they also say which fields to record for IDD, MiFID II, the AI Act, the GDPR, DORA and others. The recorders do the canonical form, the salt and the journal for you. Nothing to integrate yet? Seal a file in the browser.
Record and verify in five minutes, without writing to anyone
A sandbox key records entries in the real log, exactly like a key we issue by hand. You get it on this page, once. We ask for nothing about you and store nothing about you: no account, no email, no name. Only a SHA-256 digest of the key is kept.
Is your key recording, and does your evidence check out?
Paste a key and the number of an entry you recorded with it (the leaf_index the log answered with). This page fetches that entry’s proof and checks it here: that the key is recognised, that the entry is yours, that it sits in the log under the head the proof carries, and that we signed that head. If the key has also recorded quotes through the pricing example, their export is checked too. The key is sent only to api.riskrouter.eu, is not stored, and is gone when you leave the page.
Reading is open. Recording is attributed.
Every entry is a leaf of an RFC 6962 Merkle tree, and every head of that tree is signed and can be saved by anyone. That is what makes an entry worth something to an auditor, and it is also why the write path cannot be open: an entry is permanent, and the distributor whose key recorded it is named inside the leaf, so who recorded it cannot be rewritten later.
| You send | You get | Log |
|---|---|---|
| No key | Signed heads, checkpoints, consistency proofs and public keys: everything needed to check a proof | Nothing written. A write answers 401 NOT_AUTHENTICATED |
| A valid key | Your entry, and the proof of any entry you recorded | Recorded against your distributor, inside the leaf |
| An invalid, revoked or suspended key | HTTP 401 | Nothing written |
A bad key is refused outright and never quietly treated as no key. The alternative looks friendlier and is far worse: a firm whose key was mistyped or revoked would keep sending digests for months, believing it was building evidence, and would find out during the audit that nothing had been written. Loud now beats silent then.
We store the SHA-256 of your key, never the key. That means we cannot show it to you again, cannot email it to you, and cannot recover it if you lose it; we can only revoke it and issue another. It also means a breach of our database does not hand anyone the ability to record in your name.
Getting your evidence back out
Each entry has its own proof, fetched with the same key. Fetch it when you record, and keep it with the record and the salt: the three together are the evidence, and they verify without us. Now and then, save a signed head as well; it needs no key. Every later head must be consistent with it, and GET /api/v2/evidence/consistency proves that it is, so a log rewritten after you saved the head cannot pass.
curl -s "https://api.riskrouter.eu/api/v2/evidence/proof?leaf_index=$LEAF" \
-H "Authorization: Bearer $RISKROUTER_API_KEY" > "proof-$LEAF.json"
curl -s https://api.riskrouter.eu/api/v2/evidence/head > "head-$(date +%F).json"
Both verifiers, standard-library Python and Node, are in github.com/aniljangrabe-dev/riskrouter-verify, a separate, public repository that shares no code with this one. Keep them with your proofs. A proof you saved last year still verifies after we have stopped answering the phone, which is the only honest answer to what happens to my evidence if you go under. It is also why there is no SDK and nothing to license: a product that is hard to leave is a product that has to be, and we would rather not need that.
Handle these six before you ship
Each one is cheap to handle now and expensive to discover in an audit.
| What happens | What to do |
|---|---|
A 503 with recorded: false The log refused the write: an outage, or a fault on our side. | Nothing was recorded. Keep the record and its digest on your side, send it again later, and never mark it recorded until a 201 says so. The recorders keep a journal and do this for you. |
A 429 EVIDENCE_CEILING for your key or for the whole log, or too many requests from your address. | Nothing was recorded. Back off and send it again. A sandbox key records at most 50 entries a day; see Limits. |
| A 401 where a 201 used to come back Your key was revoked, or your distributor was suspended. | Treat it as an outage and page someone. Do not drop the key and carry on: from that moment nothing you send is evidence. |
| A timeout, and you send it again The first attempt may have been recorded before the connection dropped. | Number your records in a completeness chain (chain_tag and chain_seq beside the digest). A position already taken is refused with 409 CHAIN_OUT_OF_TURN and the next free position, so a retry cannot record twice and tells you the first attempt landed. Without a chain, a retry can leave the same digest twice: harmless, but it says you recorded it twice. |
| The record or the salt is lost The digest alone shows nothing to anyone. | Store the record, the salt and the entry together, before you send the digest, and back them up like any record you must keep. We cannot give any of them back. |
| A digest of an unsalted record A short or predictable record can be guessed and the guess confirmed from its digest. | Always hash a random salt of at least 16 bytes in front of the record, and keep it with the record. |
What the log refuses
| Limit | Value | On breach |
|---|---|---|
| Request body | 16 KB | 413 PAYLOAD_TOO_LARGE |
One batch (/api/v2/evidence/batch) | 500 entries, 128 KB | Refused whole: nothing in it is written |
| Entries, whole log | 600 / min | 429 EVIDENCE_CEILING, nothing recorded |
| Entries, one self-serve sandbox key | 50 / day | 429 EVIDENCE_CEILING, nothing recorded |
| Entries, all self-serve sandbox keys together | 30 / min | 429 EVIDENCE_CEILING, nothing recorded |
| Requests from one address | 60 / min | 429 |
The entry ceilings are enforced by the database, not by this page or the Worker. The log refuses deletion, so a flood would be permanent damage, and a cap that no caller can talk its way around is worth more than a generous one that can be. The sandbox ceilings are lower so that a key we issue by hand can always record.
Working code in the language your product is written in
Each example records a salted fingerprint of a demands-and-needs note and proves that entry, and runs the pricing example too: it prices a quote, records it with an idempotency key, retries without recording twice and exports the evidence. Standard library only, no SDK to install, and every one is run on every change against the real service, with its output checked by two independent verifiers.
- Python: riskrouter.py, signer.py (sign your own claims), demo.py
- PHP: RiskRouter.php, demo.php
- C# (.NET 8): RiskRouter.cs, Program.cs, RiskRouterDemo.csproj
- Java: RiskRouterDemo.java
- Reference app: app.py, a one-file broker desk that keeps the customer’s note on your side and produces an evidence pack an inspector can check offline with check_pack.py.
- Verifier: riskrouter_verify.py, standard-library Python, independent of everything else here. How to use the kit: README.
- A verification page in your own brand: verifier-page.mjs writes one HTML file, with your name and colour, that checks a receipt or a disclosure in your customer’s browser and whose security policy forbids it any network request. example-brand.json
Evidence from where your decisions are already logged
If your system already logs its decisions, through Python’s logging, through OpenTelemetry spans, or through a function that returns a decision, a recorder turns that into evidence without an integration project. It canonicalises each record, salts it and keeps it, with its salt, in a journal on your side; only the digest and the kind are sent, in batches, to this log, to your own instance, or to none. A record is written to the journal before its digest is sent, so an outage never loses one, and it is marked recorded only when the log says so. No dependencies, and never a condition of using the API.
# Python: a log line with extra={"evidence": record} becomes evidence
log.addHandler(EvidenceHandler(Recorder(api_key=KEY, journal="evidence.jsonl")))
log.info("referred", extra={"evidence": {"kind": "ai.decision", "record_id": "dec-2292", "outcome": "refer"}})
// Node: OpenTelemetry spans with evidence.kind become evidence
provider.addSpanProcessor(new EvidenceSpanProcessor(createRecorder({ apiKey: KEY, journal: './evidence.jsonl' })));
- Python (3.8+, standard library):
pip install riskrouter-recorder, or the files: riskrouter_recorder, with alogginghandler, a decorator and an OpenTelemetry span processor, and agents.py. README - Node (20+, no dependencies):
npm install riskrouter-recorder, or the files: recorder.mjs, withwithEvidenceand an OpenTelemetry span processor; agents.mjs and mcp-server.mjs. README
AI agents. The same packages carry adapters for the frameworks agents are built with: a callback handler for LangChain and LangGraph (Python and JavaScript) and a tracing processor for the OpenAI Agents SDK (both languages). Each agent run becomes one record, with its tool calls, model calls, handoffs, guardrails and graph nodes as steps; each step keeps a SHA-256 of the text it handled, not the text. An MCP server gives any MCP client a record_decision tool; what it records is the agent’s own statement, and says so.
# LangGraph: every run of the graph becomes one record
graph.invoke(state, config={"callbacks": [EvidenceCallbackHandler(recorder, fields={"system_ref": "claims-agent"})]})
// OpenAI Agents SDK (JavaScript)
addTraceProcessor(new EvidenceTracingProcessor(recorder, { fields: { system_ref: 'claims-agent' } }));
bundle(record_digest) gives you the record, its salt and the proof, which a supervisor checks offline with either verifier. Both recorders are tested on every change against the real engine and the real OpenTelemetry, LangChain, OpenAI Agents and MCP SDKs. They are licensed Apache-2.0, like the verifiers, the conformance suite and the integration kit above. Both are published as riskrouter-recorder, on PyPI and npm; the files above are the same code, if you would rather copy them.
Nothing left out. Give a recorder a chain secret and every record goes in a numbered completeness chain: the recorder adds the chain fields itself and carries on from its journal after a restart, and completeness() returns the log’s signed list of the chain with every record in it, ready for an auditor to check that none is missing.
recorder = Recorder(chain_secret=os.environ["RISKROUTER_CHAIN_SECRET"], chain="kyc-desk")
At the OpenTelemetry Collector. otlp-sealer.mjs takes OTLP/HTTP JSON from a Collector like any other backend, and turns spans and log records that carry evidence.kind into records, with no library inside the application. A batch is answered only once it is in the journal, so the Collector retries anything not kept.
RISKROUTER_API_KEY=... node otlp-sealer.mjs --port 4319 --journal /var/lib/evidence/journal.jsonl --chain otel
Files kept elsewhere. A record can name a file it does not contain, such as a call recording, an invoice or a release: digest_file(path) in Python, digestFile(path) in Node, streams its SHA-256 and the file stays where it is.
Security logs. logseal.mjs seals any log stream (journalctl, tail -F, a syslog relay) segment by segment, so after an incident you can show that no sealed segment was changed or removed. The segments stay in your own archive. Sealed security logs
Connect an AI agent over MCP
The evidence log speaks the Model Context Protocol, so an AI assistant or agent framework can read the log, fetch proofs and record digests without anyone writing an integration. Add this server to any MCP client:
https://api.riskrouter.eu/mcp
{ "mcpServers": { "riskrouter": {
"type": "http", "url": "https://api.riskrouter.eu/mcp",
"headers": { "Authorization": "Bearer <your key, only to record>" } } } }
Reading needs no key. record_evidence takes the SHA-256 of a record you keep and a short kind, never the record, and needs your key; a sandbox key takes a minute. Each tool is the same route as in the API reference, with the same checks. There is no pricing tool. To record from inside the agent’s own process instead, with the record kept in your journal, use the local MCP server and the framework adapters above. Every client’s setup in one place: AI agents and assistants.
The pricing engine: a deterministic engine whose every answer can be recorded
RiskRouter began as a pricing engine for micro-insurance. It stays as a worked example for developers, not as an offer: a deterministic engine whose quotes can be recorded in a hash-chained ledger, the older cousin of the evidence log above. Its rating matrix is in the API reference, and the console drives it. Nothing above depends on it.
Price a configuration
No key: a real price, and nothing written. With a valid key, the quote is also recorded against your distributor, and attribution.recorded says whether it landed. An invalid key is refused with 401, never priced as anonymous.
curl -X POST https://api.riskrouter.eu/api/v1/quote \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $RISKROUTER_API_KEY" \
-H "Idempotency-Key: order-4711" \
-d '{ "active_vertical": "mobility", "selected_components": { "commuter_roadside": true } }'
// Two different questions. Ask both.
const priced = quote.ok === true; // is this number real
const recorded = quote.attribution.recorded === true; // is it in the ledger
Ask the engine what it charges
Do not hardcode the price list. GET /api/v1/verticals publishes every vertical, its mandatory base layer and its optional modules, with the prices the engine will apply. Build an interface from that response and a price change never desynchronises it from the quote.
What to check on every quote
persistence.persisted: never say a quote is on file unless it is literallytrue.persistence.attemptedtells no key apart from a write the ledger refused.ignored_components: anything in it is a component the engine does not price, so a deployment mismatch. Alert on it.- An
Idempotency-Keyon every recorded quote, reused on every retry of that quote: the same key records at most one entry, and a retry gets it back withpersistence.replayed: true. - Never show a number your own code calculated, and never fall back to one when the engine does not answer.
- Limits: a 16 KB body, a 5-second ledger write and 120 ledger writes a minute across all callers. Past either of the last two the quote still returns, marked not persisted.
Export the quote ledger
curl -s -H "Authorization: Bearer $RISKROUTER_API_KEY" \
https://api.riskrouter.eu/api/v1/ledger/export > "ledger-$(date +%F).json"
node riskrouter-verify/tools/verify-attestation.mjs ledger-2026-09-20.json # we signed this head
node riskrouter-verify/tools/verify-ledger.mjs ledger-2026-09-20.json # your entries produce it
You get your entries in full, the digest skeleton of the whole chain they sit inside, and the signed attestation for the head. /verify checks the same export in the browser.
Run it yourself
The service is one Worker and one PostgreSQL schema, with no proprietary runtime. A deployment kit runs that same code in your own account: on your own Cloudflare and Supabase, or anywhere containers run. Your instance signs its heads with its own key, holds its own data and calls nothing of ours: no licence server, no telemetry. The verifiers, the recorders and the conformance suite work against it exactly as they work against ours, and a sandbox key from your instance carries your instance’s address in its next steps.
The service’s source is in a private repository today (the verifiers, the recorders and the kits are what is published), so ask us for it; if running it without us is a condition of your pilot, agree how you would get it before the pilot starts. See the continuity and exit plan.
We would rather you could leave than have you locked in. An integration you can walk away from is one you can commit to.
What you bring
RiskRouter is evidence infrastructure. An entry shows that a record with that digest existed no later than the entry’s time and has not changed since. It does not show that the record is true or complete, and it cannot stand in for the things a supervisor expects you to hold.
- The records themselves, and their salts, kept and backed up on your side for as long as your rules require
- Your own decision about what goes in a record. The regime packs map fields to articles as orientation, not as legal advice
- Your own authorisation where your activity needs one: an FSMA registration, or its equivalent in your member state
- Your own terms with your customers, and your own data-protection analysis of what you record
We supply the log, the proofs and the verifiers. The regulatory position explains where the boundary sits and why it is drawn there.