Validation build: the evidence log is live; the pricing example is simulated and places no cover. What that means

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.

POST/api/v1/quote

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.

HeaderResponseLedger
Absent200 with a real priceNothing written. attribution.mode is ANONYMOUS
Authorization: Bearer <key>200 with a real priceRecorded against that distributor, inside the hash chain
A key that does not resolve401 INVALID_API_KEYNothing 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.

POST/api/v1/sandbox/keys

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.

GET/api/v1/verticals

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.

GET/api/v1/ledger/attestation

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.

GET/api/v1/ledger/export

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.

GET/api/v1/ledger/pubkey

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.

POST/api/v1/contact

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.

POST/api/v1/landings

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

POST/api/v2/evidence

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.

POST/api/v2/evidence/keys

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.

GET/api/v2/evidence/keys

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

POST/api/v2/evidence/batch

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.

GET/api/v2/evidence/head

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.

GET/api/v2/evidence/proof

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

GET/api/v2/evidence/consistency

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

GET/api/v2/evidence/chain

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

POST/api/v2/evidence/statement

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.

GET/api/v2/evidence/receipt

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

GET/tlog/evidence-v2/checkpoint

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.

GET/tlog/evidence-v2/tile/<L>/<N>[.p/<W>]

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.

POST/mcp

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.

GET/.well-known/oauth-protected-resource
GET/.well-known/oauth-protected-resource/mcp
GET/.well-known/oauth-authorization-server
POST/oauth/register
GET POST/oauth/authorize
POST/oauth/token

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.

GET/api/v1/usage

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.

GET/api/v1/health

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.

CodeHTTPCause
UNKNOWN_VERTICAL400Missing or unrecognised active_vertical
INVALID_COMPONENTS400selected_components was an array rather than an object
INVALID_PAYLOAD400Body was not a JSON object
MALFORMED_JSON400Body was not valid JSON
INVALID_API_KEY401Key is unknown, revoked, or its distributor is suspended. Nothing was recorded
KEY_CHECK_UNAVAILABLE503A key was sent but could not be checked. Refused rather than priced unrecorded
NOT_AUTHENTICATED401An export was requested with no key. There is nothing to export for nobody in particular
LEDGER_TOO_LARGE413The ledger is past the single-response export ceiling. Refused rather than truncated into a proof that fails
UNSUPPORTED_MEDIA_TYPE415Content type was not application/json
PAYLOAD_TOO_LARGE413Body exceeded 16 KB
INVALID_IDEMPOTENCY_KEY400Idempotency-Key was empty, too long, or not visible ASCII
IDEMPOTENCY_KEY_REUSED409That key already recorded a different quote; nothing was written
ACKNOWLEDGEMENT_REQUIRED400A sandbox key was requested without "acknowledge_permanent": true
SANDBOX_CAPACITY_REACHED429This hour's allowance of new sandbox keys is used; nothing was issued
SANDBOX_NOT_OFFERED404A 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_ISSUED503The key could not be stored, so none was handed over
METHOD_NOT_ALLOWED405Wrong verb for the route
ROUTE_NOT_FOUND404No route bound to that path
BAD_REQUEST400The request URL could not be parsed
BODY_READ_FAILED400The request body could not be read
REQUEST_REFUSED400The sign-up form's hidden bot-trap field was filled in; nothing was issued
INVALID_ENQUIRY400A contact enquiry had fields that need attention; details says which
INVALID_LANDING400A landing count carried a malformed campaign tag or a path that is not a page on this site
RATE_LIMITED429Too 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_STORED503The enquiry could not be saved, so it did not reach us. Nothing is reported as sent
LANDING_NOT_COUNTED503The visit could not be counted; nothing else is affected
LEDGER_UNAVAILABLE502 / 503The ledger or its attestation could not be read
NO_SIGNING_KEY500 / 503This Worker publishes no attestation key, or the one bound is unreadable
INVALID_EVIDENCE400record_digest was not a lowercase SHA-256 hex digest, or kind was not a short tag. Nothing was recorded
INVALID_PROOF_REQUEST400leaf_index, tree_size, first or second was missing, not a whole number, or outside the tree
EVIDENCE_NOT_FOUND404No entry at that index was recorded with this key. Another distributor’s entry answers the same
EVIDENCE_TREE_TOO_LARGE413The evidence log is past the size this route reads in one request. Refused rather than answered with a proof that fails
EVIDENCE_CEILING429The evidence log is at a ceiling, for this key or overall. Nothing was recorded
INVALID_CLIENT_SIGNATURE400client_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_KEY400public_key was not a P-256 public JWK, or key_id was not a 16-character key id. Nothing was registered
CHAIN_OUT_OF_TURN409chain_seq is not the next position in that chain; error.details.expected_chain_seq says which is. Nothing was recorded
CHAIN_TAKEN409That chain_tag is in use by another distributor. Nothing was recorded
INVALID_CHAIN_REQUEST400chain_tag is not 64 lowercase hex characters, or from or to is not a position in the chain
SIGNER_KEY_TAKEN409That key is registered to another distributor; a key id belongs to one distributor forever. Nothing was registered
SIGNER_KEY_NOT_REGISTERED503The key could not be stored, so it is not registered
SIGNER_KEY_NOT_FOUND404No signing key is registered under that id
EVIDENCE_NOT_RECORDED503The evidence log did not accept the entry. Answered with recorded: false, never as a success
INVALID_STATEMENT400The Signed Statement breaks the registration policy (the message names the rule). Nothing was recorded
INVALID_STATEMENT_SIGNATURE400The statement’s signature does not verify with a key registered to this distributor under its kid. Nothing was recorded
RECEIPT_NOT_SIGNED503No signing key is bound, so no receipt is issued rather than an unsigned one
CHECKPOINT_NOT_SIGNED503No checkpoint key is bound, or the bound key is not the published one. No checkpoint is served rather than an unsigned one
TILE_NOT_FOUND404Not a tile path, or a tile the tree does not have yet
ENTRY_BUNDLES_NOT_PUBLISHED404Only hash tiles are published; a distributor holds its own entries with their proofs
TILES_UNAVAILABLE503The stored tree cannot be read for that tile
ORCHESTRATION_FAILURE500Unhandled 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 codeCoverPremium
public_liabilityPublic Liability Base€2.00
battery_hardware_theftBattery / Hardware Theft€1.50
commuter_roadsideCommuter Roadside Assistance€1.00
commercial_deliveryCommercial Delivery Extension€5.00
carshare_deductibleCar-Share Deductible Buy-Down€2.50

Digital Nomad & Freelance digital_nomad

Component codeCoverPremium
cyber_extortion_dataCyber-Extortion & Data Protection Base€1.50
high_value_hardwareHigh-Value Hardware Protection€3.00
international_roamingInternational Roaming Expansion€2.00
professional_indemnityFreelance Professional Indemnity€4.00
contractual_legalContractual Dispute Legal Protection€1.50

Urban Rental & Housing urban_rental

Component codeCoverPremium
tenant_fire_waterTenant Fire & Water Liability Base€3.50
deposit_liquidity_swapSecurity Deposit Liquidity Swap€4.00
landlord_legal_defenseLandlord Dispute Legal Defense€1.00
roommate_subletRoommate / Sublet Liability€1.50
smart_home_iotSmart-Home / IoT Failure Cover€2.00

Micro-Travel & Sports micro_travel

Component codeCoverPremium
multimodal_medical_transitBasic Multi-Modal Medical & Transit Base€2.50
winter_sports_gearPremium Winter Sports & Ski Gear€3.50
parametric_delayParametric Flight / Train Delay€2.00
adventure_sportsExtreme Adventure Sports Addendum€3.00
baggage_tech_transitLost Baggage / High-Value Tech€1.50