API reference
Every endpoint, and no personal data in anything the API stores. Pricing needs no authentication; recording a quote in the audit ledger needs a key, and you can get a sandbox key right now. The server prices every quote and discards any price a client sends.
Base URL
https://api.riskrouter.eu
Machine-readable: openapi.json (OpenAPI 3.1). Point your client generator or coding assistant at it; a test in the repository keeps it identical to the routes the Worker actually answers.
Or import riskrouter.postman_collection.json
into Postman, Insomnia, Bruno or Hoppscotch. It is generated from the same spec on every build.
Its first request takes a sandbox key and stores it, so everything after it works without
copying anything; the recording request sends a fresh Idempotency-Key each time.
Or skip the tooling and try it on this page, or start from working code in
Python, PHP, C# or Java in the integration kit.
Browsers may call this API only from the console origin. Server-to-server callers are unaffected, because the browser enforces that rule rather than the API.
Try it here
Real requests to api.riskrouter.eu, sent from this page. Pricing needs no key.
To record, paste a sandbox key (take one in a few seconds);
it stays in this tab’s memory, is sent only to our API, and is gone when you close the tab.
Anything you record is permanent, so record test configurations, never personal data.
Prices a configuration, and appends an audit record when a distributor key is presented. The only endpoint that writes to the ledger.
Authentication
Optional, and what it changes is whether the quote is recorded rather than whether it is priced. The ledger is tamper-evident, so an entry cannot be cleaned up later without breaking every digest after it. That is why writes are attributed and reads of the price are not.
| Header | Response | Ledger |
|---|---|---|
| Absent | 200 with a real price | Nothing written. attribution.mode is ANONYMOUS |
Authorization: Bearer <key> | 200 with a real price | Recorded against that distributor, inside the hash chain |
| A key that does not resolve | 401 INVALID_API_KEY | Nothing written |
An unresolvable key is refused rather than downgraded to an anonymous quote. Downgrading would let a distributor with a mistyped or revoked key collect 200s for months and discover at audit that nothing had been recorded. We store only the SHA-256 of a key, so a lost key can be revoked and replaced but never recovered.
Retries: Idempotency-Key
Optional, and worth sending on every recorded quote. The same key from the same distributor records
at most one ledger entry, ever; a retry with it gets that entry back with
persistence.replayed: true and an Idempotent-Replayed: true header, and nothing
new is written. The check and the write happen in one database transaction, so two retries racing each
other still produce one entry. Use 1–255 visible ASCII characters with no spaces; your own order or
session reference is ideal. Only its SHA-256 is stored, never the key, so nothing you put in it can end
up in a table that is never deleted; a random UUID is still the better habit.
Reusing a key for a different quote is refused with 409 IDEMPOTENCY_KEY_REUSED and
records nothing. “Different” means different in what would be recorded — vertical,
components the engine actually priced, premium — so a retry that adds a component the engine
ignores is still the same request. On an anonymous quote the header is ignored, because nothing is
written.
Request
Send active_vertical as one of the four keys below. selected_components is
optional; leave it out to price the base layer alone. Only an explicit true enables a
component, so the string "false" selects nothing.
curl -X POST https://api.riskrouter.eu/api/v1/quote \
-H 'Content-Type: application/json' \
-d '{
"active_vertical": "urban_rental",
"selected_components": {
"deposit_liquidity_swap": true,
"smart_home_iot": true
}
}'
Response
selected_components comes back normalised to what was actually sold. The mandatory base
layer is always present and always true. Anything the engine did not recognise appears in
ignored_components, so a client that has drifted out of step shows up in testing.
{
"ok": true,
"quote": {
"quote_reference": "…",
"active_vertical": "urban_rental",
"currency": "EUR",
"billing_interval": "MONTHLY",
"selected_components": { "tenant_fire_water": true, … },
"ignored_components": [],
"line_items": [ … ],
"totals": {
"base_layer_premium": "3.50",
"add_on_premium": "6.00",
"total_monthly_premium": "9.50",
"indicative_annual_premium": "114.00",
"active_add_on_count": 2
}
},
"compliance": { "status": "MOCK_ROUTING_VERIFIED", "binding_state": "NOT_BOUND. Indicative simulation only, …" },
"persistence": { "attempted": true, "persisted": true, "record_id": "…", "latency_ms": 41 },
"attribution": { "recorded": true, "mode": "ATTRIBUTED", "distributor": "…", "environment": "sandbox" }
}
Reading persistence
A 200 does not prove an audit record exists. The write is reported separately, and a
failed write degrades the response instead of failing it, because a ledger fault should not take pricing
down with it. Treat a record as stored only when persistence.persisted is literally
true. persistence.attempted separates the two reasons it can be false:
no key was sent, or the write was tried and did not confirm. The first is a configuration choice,
the second is an incident.
Issues a sandbox key on the spot, with no account and no one to write to first. The key records quotes in the ledger exactly like one we issue by hand, so the whole evaluation — price, record, export, verify — can be done in a few minutes. Or use the form on the integration page, which calls this for you.
curl -s -X POST https://api.riskrouter.eu/api/v1/sandbox/keys \
-H "Content-Type: application/json" \
-d '{"acknowledge_permanent": true}'
acknowledge_permanent is required and must be the boolean true. Entries you
record are written to an append-only ledger and can never be deleted, and you should not find that
out afterwards. Nothing else is read from the body: no name, no email, nothing about you is stored.
The response carries the key once, its prefix, and runnable next steps with the key already in them. Only a SHA-256 digest of the key is kept, so a lost key cannot be recovered — ask for another. Limits are enforced by the database itself: 50 recorded entries per key per day, and a shared allowance of new keys per hour. Sandbox only; a production key is issued by a person.
Publishes the rating matrix, so a client renders exactly what the engine prices instead of carrying a copy that can drift. The console loads this on startup.
The entry count, the head hash, and the canonical form the digest is computed over. Record the head with a date and any later history that does not reproduce it has been altered. Nothing about any customer is exposed: the attestation commits to the ledger without disclosing it.
Your own entries in full, plus (chain_index, row_hash, prev_hash) for every entry
in the ledger, plus the signed attestation. Requires your API key: an export is scoped to one
distributor, and an anonymous caller has nothing to export.
Together those three pieces let you rebuild each of your digests from its own content and follow the chain to the head we signed — with no credential of ours and no call to us at verification time. The skeleton carries digests only, never another distributor’s content.
curl -s -H "Authorization: Bearer $RISKROUTER_API_KEY" \
https://api.riskrouter.eu/api/v1/ledger/export > export.json
node tools/verify-attestation.mjs export.json # we signed this head
node tools/verify-ledger.mjs export.json # your entries produce it
A ledger with more than 25 000 entries is refused with LEDGER_TOO_LARGE rather
than truncated. A partial skeleton cannot reach the head, so it would verify as broken and
send you hunting for tampering that never happened.
Rate limited twice, because two different people abuse it. By address before your key is resolved, so a stranger cannot spend a database round trip per request just by asking; then six a minute per distributor, keyed to you rather than to your address. An export is a periodic record, not a polling endpoint — take one a month and keep it.
One export proves consistency. Several, saved months apart, prove history — see verify for why that distinction is the whole point.
The public half of the attestation signing key, and its key id. Useful for a quick check, but
not the copy to trust: verifying a signature against a key we hand you at the moment you
verify proves nothing if we are the thing in question. The copy that counts is
anchors/signing-key.json in the public repository, which stays there across
rotations so an attestation saved years ago is still checkable.
Serves this site’s own contact form. It is listed because an undocumented public write endpoint is a thing an auditor is right to ask about, not because it is part of the integration surface — there is no reason to call it from your own code.
It writes to contact_enquiries, which unlike the ledger is deletable by design:
it holds personal data and must stay erasable under GDPR Article 17. The
data protection page sets out what is kept and for how long.
Counts one arrival from a tagged campaign link, for this site’s own measurement; there is no
reason to call it from an integration. The body is {"src": "linkedin-sept", "path": "/integrate"}
and nothing else is read or stored: no IP address, no identifier, no browser details. The result is a
daily count per campaign and page, described on the data protection page.
Anyone can inflate a count, so the numbers are marketing signals, never evidence. Answers 204.
v2 · Evidence log
Record that any regulated decision existed, without sending it: a suitability assessment, a credit decision, an AI system’s log line, a quote. You hash your own record and send only the digest; we never see the record, so it may contain whatever your own obligations require. Needs your key. Entries are leaves of an RFC 6962 Merkle tree, so one entry is proven against a signed head with a handful of hashes, and nothing about any other entry is revealed. The format is in the open specification.
# Salt the record, or anyone who can guess it can confirm it from the digest.
SALT=$(openssl rand -hex 16)
DIGEST=$( (printf '%s' "$SALT"; cat decision.json) | sha256sum | cut -d' ' -f1)
curl -s -X POST 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\"}"
Two fields only: record_digest (lowercase hex SHA-256) and kind (a tag you
choose: a–z, 0–9, dots and hyphens, at most 40, such as mifid.suitability).
Anything else is refused, never coerced. The answer is 201 with the entry
(leaf_index, created_at, leaf_hash) and recorded: true;
a refused write answers 503 with recorded: false, never a success. Keep your
record, your salt and the entry: without your record nobody, including us, can show what it said.
Not sure what to put in the record? The regime packs say, for IDD, MiFID II, the AI Act, GDPR Article 22, DORA and NIS2, which fields to record and which article each one speaks to, with a canonical form so someone else can re-hash your record exactly.
Optionally, sign the claim yourself. Send signer_key_id, claimed_at
(your own timestamp, YYYY-MM-DDTHH:MM:SS.ffffffZ) and client_signature (ECDSA P-256 over
riskrouter-evidence-claim|v3|kind|record_digest|signer_key_id|claimed_at, raw r || s in base64)
with the two fields above, all three or none. The signature is verified against a key you registered before
anything is written, and then sits inside the leaf: a v3 leaf, whose proof shows that you asserted the
decision, not only that we sealed it. A signature that does not verify records nothing. The
specification has the exact forms; the Python kit has a
standard-library signer.
Register the public half of the key you sign claims with, once: public_key as a P-256 JWK
({"kty":"EC","crv":"P-256","x":"…","y":"…"}). Needs your key. Answers 201 with the
key_id (the first 16 hex characters of SHA-256 over the JSON array [x, y]) or
200 if that key was already yours. A key id belongs to one distributor forever, so another
distributor’s key answers 409. Keys are never removed: leaves signed before you rotate stay
verifiable. Publish the same key somewhere you control, so a verifier need not take our word for whose it is.
?key_id=…. The registered public key under that id, for anyone: a public key is public, and a
verifier of a v3 leaf needs it. Answers the key and when it was registered, never whose it is.
Many digests in one request: entries, an array of 1 to 500 objects each with
record_digest and kind, recorded in the order sent, in one transaction. The same
checks apply to each entry as to a single record, and the same ceilings hold per row: if any entry is
refused, nothing in the batch is written and the answer says so. The body may be up to 128 KB. Answers
201 with every entry and its leaf index.
The signed tree head: tree_size, root_hash and timestamp, signed
over riskrouter-evidence-head|v2|tree_size|root_hash|timestamp with the same key as the v1
attestation. Unsigned heads say so. Save one; every later head must be consistent with it.
?leaf_index=N, optionally &tree_size=M. The inclusion proof for one of
your entries (needs your key; another distributor’s entry answers exactly like a missing
one): the entry, the audit path, and the signed head when tree_size is the current size.
To prove against a head you saved earlier, pass its tree_size and check against its root.
Take the size and the root from the same signed head. For a signed (v3) leaf the proof also carries
signer_public_key, so the client signature can be checked without another request.
?first=M, optionally &second=N. Proof that the tree you saved at size
M is a prefix of the tree at size N: the check that catches a rewritten history.
Public, because it reveals nothing but hashes. Check it against the root in the head you saved,
never a root we supply. This is what an independent witness runs.
?chain_tag=…, optionally &from= and &to=. What the log holds in one
completeness chain as of the current signed head: its length and, for each position, the leaf index, the time
it was sealed and the record digest, signed over
riskrouter-evidence-chain|v1|chain_tag|length|tree_size|root_hash|timestamp|from|to|links_digest
with the same key as the head. Up to 1,000 positions a page; next names the following page.
Public, like a head: the tag is derived from a secret you keep, so only someone you give it to can ask, and the
answer names no distributor and holds no record.
To put a record in a chain, send chain_tag (64 hex characters) and chain_seq (1, 2,
3, …) with its digest to POST /api/v2/evidence or in each batch entry, and put the same two inside
the record with chain_prev, the digest of the record before it. A position out of turn answers
409 with the position expected; a tag another distributor uses answers 409; nothing is
written either way. A record a firm then leaves out of what it shows is a gap the statement names. The
specification has the forms; tools/chains.mjs checks a chain, and a spot
check of it, offline.
Register a SCITT Signed Statement (body application/cose, your key). It must be a hash envelope: a
COSE_Sign1 signed ES256 with a key you registered (its id as kid), with CWT claims iss and
sub and a payload that is the 32-byte SHA-256 of your artifact, so the artifact never comes here. The log
keeps one leaf holding the statement’s digest, and answers with an RFC 9942 COSE Receipt and the Transparent
Statement, both base64. The profile is in the specification.
?leaf_index=N, with the key that recorded the entry: a COSE Receipt of inclusion for it at the current
tree size (application/scitt-receipt+cose), for a statement or any other entry. Answered only when the
signing key is bound; otherwise 503 RECEIPT_NOT_SIGNED.
The same tree as /api/v2/evidence/head, as a C2SP checkpoint (text/plain): a signed
note of three lines, the origin api.riskrouter.eu/tlog/evidence-v2, the tree size and the root in
base64, signed with the log's Ed25519 key. This is the form the public witness network cosigns. Answered
only when that key is bound and matches the published one; otherwise 503
CHECKPOINT_NOT_SIGNED, never an unsigned note. Format and key in the specification.
The tree as tiles of 256 hashes, exactly as c2sp.org/tlog-tiles lays them out, so anyone can mirror the whole
tree and compute any root or proof without this API. Immutable once served. Only hashes are published: entry
bundles (tile/entries/…) answer 404 ENTRY_BUNDLES_NOT_PUBLISHED, because every
leaf names a distributor and a kind. node tools/checkpoint.mjs mirror fetches every tile and
checks it gives the checkpoint's root.
The evidence log for AI agents, over the Model Context Protocol (Streamable HTTP, stateless: one JSON-RPC
message per request, one JSON answer, no session). Point any MCP client at
https://api.riskrouter.eu/mcp. Tools: get_evidence_head,
get_inclusion_proof, get_consistency_proof, get_checkpoint,
get_ledger_attestation, get_usage, get_chain and record_evidence. Each is the route
above it on this page, answered by the same code with the same errors. Reading needs no key;
record_evidence takes a digest and a kind, never a record, and needs your key in the connection's
Authorization header, or a key the client gets by signing in. There is no pricing tool: the engine prices for licensed distributors'
software, not for an agent talking to the public. Connecting a client.
Sign-in for MCP clients that cannot send a key header, such as the connectors in Claude's directory. A tool
that needs a key, called with none, answers 401 with a WWW-Authenticate header that
points at the protected-resource metadata; the client then registers (no secret), sends the user to
/oauth/authorize, where they tick that entries are permanent, and exchanges the code (PKCE
S256 required, valid two minutes) at /oauth/token. The access token is a new self-serve
sandbox key, issued exactly as POST /api/v1/sandbox/keys issues one, under the same limits; nothing
about the user is asked or stored. Reading never needs it.
How much is recorded, and by whom, as counts: entries in the quote ledger and the evidence log, the
last 7 and 28 days, twelve calendar weeks, and the figure that matters, outside: entries
recorded with keys that are not ours, self-serve sandbox keys counted separately. Public, because it
returns nothing but numbers and week dates; no identifier, no premium, no record. Cached for five
minutes at the edge. /usage shows it.
Liveness, plus whether the audit ledger binding resolved. binding_configured: false means
quotes still price but nothing is recorded. The bare domain answers this too.
Errors
Every error carries the same envelope: ok: false and an error object with a stable code, a message, and a docs link to its row in this table.
| Code | HTTP | Cause |
|---|---|---|
UNKNOWN_VERTICAL | 400 | Missing or unrecognised active_vertical |
INVALID_COMPONENTS | 400 | selected_components was an array rather than an object |
INVALID_PAYLOAD | 400 | Body was not a JSON object |
MALFORMED_JSON | 400 | Body was not valid JSON |
INVALID_API_KEY | 401 | Key is unknown, revoked, or its distributor is suspended. Nothing was recorded |
KEY_CHECK_UNAVAILABLE | 503 | A key was sent but could not be checked. Refused rather than priced unrecorded |
NOT_AUTHENTICATED | 401 | An export was requested with no key. There is nothing to export for nobody in particular |
LEDGER_TOO_LARGE | 413 | The ledger is past the single-response export ceiling. Refused rather than truncated into a proof that fails |
UNSUPPORTED_MEDIA_TYPE | 415 | Content type was not application/json |
PAYLOAD_TOO_LARGE | 413 | Body exceeded 16 KB |
INVALID_IDEMPOTENCY_KEY | 400 | Idempotency-Key was empty, too long, or not visible ASCII |
IDEMPOTENCY_KEY_REUSED | 409 | That key already recorded a different quote; nothing was written |
ACKNOWLEDGEMENT_REQUIRED | 400 | A sandbox key was requested without "acknowledge_permanent": true |
SANDBOX_CAPACITY_REACHED | 429 | This hour's allowance of new sandbox keys is used; nothing was issued |
SANDBOX_NOT_OFFERED | 404 | A self-hosted instance whose operator has not switched on self-serve sandbox keys; ask the operator for a key. riskrouter.eu always offers them |
SANDBOX_NOT_ISSUED | 503 | The key could not be stored, so none was handed over |
METHOD_NOT_ALLOWED | 405 | Wrong verb for the route |
ROUTE_NOT_FOUND | 404 | No route bound to that path |
BAD_REQUEST | 400 | The request URL could not be parsed |
BODY_READ_FAILED | 400 | The request body could not be read |
REQUEST_REFUSED | 400 | The sign-up form's hidden bot-trap field was filled in; nothing was issued |
INVALID_ENQUIRY | 400 | A contact enquiry had fields that need attention; details says which |
INVALID_LANDING | 400 | A landing count carried a malformed campaign tag or a path that is not a page on this site |
RATE_LIMITED | 429 | Too many requests from one address in a short time. Wait a minute; when a Retry-After header is sent, it says how long |
ENQUIRY_NOT_STORED | 503 | The enquiry could not be saved, so it did not reach us. Nothing is reported as sent |
LANDING_NOT_COUNTED | 503 | The visit could not be counted; nothing else is affected |
LEDGER_UNAVAILABLE | 502 / 503 | The ledger or its attestation could not be read |
NO_SIGNING_KEY | 500 / 503 | This Worker publishes no attestation key, or the one bound is unreadable |
INVALID_EVIDENCE | 400 | record_digest was not a lowercase SHA-256 hex digest, or kind was not a short tag. Nothing was recorded |
INVALID_PROOF_REQUEST | 400 | leaf_index, tree_size, first or second was missing, not a whole number, or outside the tree |
EVIDENCE_NOT_FOUND | 404 | No entry at that index was recorded with this key. Another distributor’s entry answers the same |
EVIDENCE_TREE_TOO_LARGE | 413 | The evidence log is past the size this route reads in one request. Refused rather than answered with a proof that fails |
EVIDENCE_CEILING | 429 | The evidence log is at a ceiling, for this key or overall. Nothing was recorded |
INVALID_CLIENT_SIGNATURE | 400 | client_signature does not verify over the claim payload with a key registered to this distributor, or the key id is not one of yours. Nothing was recorded |
INVALID_SIGNER_KEY | 400 | public_key was not a P-256 public JWK, or key_id was not a 16-character key id. Nothing was registered |
CHAIN_OUT_OF_TURN | 409 | chain_seq is not the next position in that chain; error.details.expected_chain_seq says which is. Nothing was recorded |
CHAIN_TAKEN | 409 | That chain_tag is in use by another distributor. Nothing was recorded |
INVALID_CHAIN_REQUEST | 400 | chain_tag is not 64 lowercase hex characters, or from or to is not a position in the chain |
SIGNER_KEY_TAKEN | 409 | That key is registered to another distributor; a key id belongs to one distributor forever. Nothing was registered |
SIGNER_KEY_NOT_REGISTERED | 503 | The key could not be stored, so it is not registered |
SIGNER_KEY_NOT_FOUND | 404 | No signing key is registered under that id |
EVIDENCE_NOT_RECORDED | 503 | The evidence log did not accept the entry. Answered with recorded: false, never as a success |
INVALID_STATEMENT | 400 | The Signed Statement breaks the registration policy (the message names the rule). Nothing was recorded |
INVALID_STATEMENT_SIGNATURE | 400 | The statement’s signature does not verify with a key registered to this distributor under its kid. Nothing was recorded |
RECEIPT_NOT_SIGNED | 503 | No signing key is bound, so no receipt is issued rather than an unsigned one |
CHECKPOINT_NOT_SIGNED | 503 | No checkpoint key is bound, or the bound key is not the published one. No checkpoint is served rather than an unsigned one |
TILE_NOT_FOUND | 404 | Not a tile path, or a tile the tree does not have yet |
ENTRY_BUNDLES_NOT_PUBLISHED | 404 | Only hash tiles are published; a distributor holds its own entries with their proofs |
TILES_UNAVAILABLE | 503 | The stored tree cannot be read for that tile |
ORCHESTRATION_FAILURE | 500 | Unhandled fault; no detail is leaked |
Rating matrix (a worked example)
POST /api/v1/quote is a worked example of recording a decision at the moment it is made: the server
prices a configuration and, with a key, seals exactly what it priced. The four verticals below are sample data for
that example. They are not products, not for sale and not insurance cover, and your own rates or decisions replace
them. The live matrix is served at GET /api/v1/verticals, every change is listed with its fingerprint on
the changelog, and the demo console drives it.
All prices are monthly, in euro. Base layers are mandatory and cannot be switched off.
Urban Mobility mobility
| Component code | Cover | Premium |
|---|---|---|
public_liability | Public Liability Base | €2.00 |
battery_hardware_theft | Battery / Hardware Theft | €1.50 |
commuter_roadside | Commuter Roadside Assistance | €1.00 |
commercial_delivery | Commercial Delivery Extension | €5.00 |
carshare_deductible | Car-Share Deductible Buy-Down | €2.50 |
Digital Nomad & Freelance digital_nomad
| Component code | Cover | Premium |
|---|---|---|
cyber_extortion_data | Cyber-Extortion & Data Protection Base | €1.50 |
high_value_hardware | High-Value Hardware Protection | €3.00 |
international_roaming | International Roaming Expansion | €2.00 |
professional_indemnity | Freelance Professional Indemnity | €4.00 |
contractual_legal | Contractual Dispute Legal Protection | €1.50 |
Urban Rental & Housing urban_rental
| Component code | Cover | Premium |
|---|---|---|
tenant_fire_water | Tenant Fire & Water Liability Base | €3.50 |
deposit_liquidity_swap | Security Deposit Liquidity Swap | €4.00 |
landlord_legal_defense | Landlord Dispute Legal Defense | €1.00 |
roommate_sublet | Roommate / Sublet Liability | €1.50 |
smart_home_iot | Smart-Home / IoT Failure Cover | €2.00 |
Micro-Travel & Sports micro_travel
| Component code | Cover | Premium |
|---|---|---|
multimodal_medical_transit | Basic Multi-Modal Medical & Transit Base | €2.50 |
winter_sports_gear | Premium Winter Sports & Ski Gear | €3.50 |
parametric_delay | Parametric Flight / Train Delay | €2.00 |
adventure_sports | Extreme Adventure Sports Addendum | €3.00 |
baggage_tech_transit | Lost Baggage / High-Value Tech | €1.50 |