The auditable decisioning layer — as an API.
One POST returns the pick from ChainChoice's published order for the category your question maps to, the next rows of that order, the rows a stated country removes, and a receipt URL. The order is the one chainchoice.io publishes; nothing in this API re-ranks it. Curl is enough to start.
One curl, one pick.
Keys are issued by hand: write to partnerships@chainchoice.io. There is no self-serve issuance. Then:
curl -X POST https://chainchoice.io/api/v1/recommend \
-H "Authorization: Bearer cck_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"query": "which exchange should I use to buy BTC?",
"country": "DE"
}'The response names the pick, the highest row of ChainChoice's published order for the category the question maps to that Germany keeps, with the next rows of that order, the rows Germany removed and why, the answer text, and a receipt URL your user can open.
Prefer types? Vendor the single-file TypeScript client:
curl -o src/vendor/chainchoice.ts https://chainchoice.io/docs/sdk/chainchoice.ts
import { ChainChoiceClient } from './vendor/chainchoice'
const client = new ChainChoiceClient({ apiKey: process.env.CHAINCHOICE_API_KEY! })
const rec = await client.recommend({ query: "which exchange should I use to buy BTC?", country: "DE" })No dependencies. Apache-2.0. SDK README.
Bearer keys, scoped and metered.
/api/v1/recommend and /api/v1/receipts/:id need an Authorization: Bearer cck_live_<32 hex> header. A key carries scopes (recommend, receipts.read) and a per-minute quota counted separately for each endpoint, 60 by default. A response within the quota carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset; past it, a 429.
Keys are stored as a peppered SHA-256 hash, so a lost key cannot be recovered, only replaced. To revoke or rotate one, write to the same address.
OpenAPI 3.1: the full contract is at /docs/openapi.json, and a test in our repository holds each documented response to the handler that answers it. Feed it to openapi-typescript or openapi-generator for a typed client.
POST / api/ v1/ recommend
The question is mapped to one published category, and the list is that category's order in /data/rankings.json: the same ids, ranks and ChainChoice Scores. A stated country removes the rows the engine excludes there; nothing is re-sorted. A question that maps to no published category gets no pick. Amount, risk appetite and stated priorities do not move the pick.
Request body
{
"query": "which exchange should I use to buy BTC?", // required, at most 800 characters
"country": "DE" // optional, ISO 3166-1 alpha-2
}jurisdiction is read as a country only when neither the body nor the question names one. amountUsd, region, asset and riskTolerance are accepted and not read. Anything else is ignored.
Response (200), abridged
{
"query": "which exchange should I use to buy BTC?",
"answer": "…",
"mode": "recommend",
"pick": {
"providerId": "bybit_exchange",
"providerName": "Bybit",
"publishedRank": 1,
"publishedScore": 81,
"alternatives": [
{ "id": "okx_exchange", "name": "OKX", "publishedRank": 2, "publishedScore": 78 }
]
},
"order": {
"basis": "published_order",
"category": "exchange",
"country": "DE",
"excludedForCountry": [
{ "provider": "Upbit", "publishedRank": 4, "reason": "supportedRegions enumerates asia and none covers Germany" }
],
"excludedCount": 8,
"unavailableReason": null
},
"receipt": {
"id": "9f1c2d34-…",
"url": "https://chainchoice.io/r/9f1c2d34-…",
"fetchUrl": "https://chainchoice.io/api/v1/receipts/9f1c2d34-…"
},
"parsed": { "intent": "buy", "asset": "BTC", "category": "exchange", "region": null, "amountUsd": null },
"validation": { "status": "passed", "invalid": [] },
"timestamp": "2026-09-14T08:14:33.221Z"
}The ids, ranks and scores are the published exchange order for Germany on 2026-09-14; a live call returns whatever /data/rankings.json publishes that day. pick and receipt are null together in explain and clarify mode and whenever order.unavailableReason is set.
GET / r/ :id and / api/ v1/ receipts/ :id
A recommendation that returns a pick writes a receipt. The HTML version at /r/<id> is a page your user can open; the JSON at /api/v1/receipts/:id needs a key with the receipts.read scope.
A receipt holds the question, the pick and alternatives, the answer, the order trace, the market snapshot the answer was given, the sources it read, the build and model stamp, and a content hash with the hash of the receipt before it.
GET / api/ v1/ receipts/ :id/ explanation
GDPR Article 22: a recommendation is an automated decision, and EU readers have a right to meaningful information about the logic involved. This returns it as JSON-LD, with no key, for a recommendation logged for a signed-in reader: the provider, the methodology fields, the alternatives, how to ask for a human review, and the content hash where one exists.
It reads the signed-in recommendation log, whose ids are not receipt ids: a receipt id, including every id /api/v1/recommend returns, answers 404.
DELETE / api/ v1/ user/ :sessionId (or POST)
Right to erasure. Deletes the session's rows from the seven tables keyed on it (funnel events, A/B events, user results, affiliate clicks, saved decisions, outcome follow-ups, agent recommendations), writes the deletion to the audit log, and returns the rows deleted per table with a receipt of the deletion. A session with no rows answers 200 with zero counts.
GET / api/ v1/ imprint, / api/ v1/ affiliate-disclosure and POST / api/ v1/ verify
The Impressum and the affiliate disclosure come back as their Markdown, the SHA-256 of their exact text and the id of the Ed25519-signed receipt issued over it. POST /api/v1/verify with { "receipt": … } checks such a receipt. Send it as GET /api/terminal/receipt?id=<receipt id> serves it, or as an issuer envelope. The endpoint recomputes the receipt's identity and checks the signature against the key set you send or, without one, the published key for the receipt's key id: ChainChoice's own, from /api/terminal/receipt-keys, then a registered issuer's. A failed check is a 200 with verified: false and the reason. None of the three needs a key. A recommendation receipt is hashed, not signed, so it is not one of these.
The check does not rely on us. contentHash is the SHA-256 of the receipt's fields as canonical JSON, keys sorted at every depth. The receipt id is cc-rcpt-<kind>- followed by the first 16 hex characters of the SHA-256 of that hash. The signature is Ed25519 over <receipt id>:<contentHash>. The one-file verifier redoes every step with Node and nothing else, and its header states the recipe in full:
curl -O https://chainchoice.io/docs/sdk/verify.mjs
node verify.mjs --document https://chainchoice.io/api/v1/imprint
node verify.mjs cc-rcpt-audit-<16 hex> # any receipt id
node verify.mjs receipt.json jwks.json # a receipt and a key set you holdNode 18 or later. Apache-2.0. verify.mjs.
What this surface does not have.
- Webhooks. No events are pushed; poll
GET /api/v1/receipts/:idwith the receipt id you hold. - Streaming. Responses are buffered.
- Conversation. Each call is one question.
- The for-you order. The pick is the published order minus the country's exclusions; the order the questionnaire computes from a reader's answers is not returned.
Keys by agreement.
There is no public price list and no paid tier to sign up for. Keys are issued by agreement; write to partnerships@chainchoice.io.
What you can count on.
- The ranking never reads commercial terms. A build check walks every module the ranking can reach and fails if any of them can see affiliate or advertising data. No outside party has audited this.
- The pick is the published pick. Same category, same country, same published order, same pick: the API ranks nothing of its own.
- v1 is the contract. Fields and endpoints may be added within it; a breaking change ships as v2 and runs beside v1 for at least six months (see Versioning). Once, on 2026-09-14, the values of existing v1 fields changed: the pick became the published order's pick.
- Countries, not regions. Send
countryand the rows the engine excludes for that country are removed, each with its reason. Without it, a country named in the question or thejurisdictionfield is used; nothing is inferred from your IP address.
Every change to the contract, dated.
The OpenAPI document carries the newest version, 1.5.0. A build check fails when the documented contract changes without a release here.
- 2026-11-03 · 1.5.0
- changed
components: get_ranking gives each row its address on the ranking page (url, /best/<category>#cc-cmp-row-<id>, the anchor the page, its ItemList and llms.txt carry) and the oldest read date of its evidence (evidenceReadAt, null when none is dated). Added fields; nothing removed or retyped..schemas .McpRanking - changed
components: Each category’s leader carries the same row address (url). Added field; nothing removed or retyped..schemas .McpListCategories
- changed
- 2026-10-28 · 1.4.0
- changed
components: An evidence cell may carry stale (the day a later re-read could not verify it) and wall (that re-read met a bot wall); q, u and d are then its last real read, never that day, and a cell never read has an empty q and u and no d. Added fields; nothing removed or retyped..schemas .PublishedEvidence - changed
components: get_evidence passes the same stale and wall fields through..schemas .McpEvidence - changed
components: A category may carry regionalOrders: for each country whose readers are priced on their own entities' rates and whose order differs from the published one, that country's order as product ids (methodology 2026.10.14). Added field; nothing removed or retyped..schemas .PublishedRankings
- changed
- 2026-10-27 · 1.3.0
- added
components: GET /api/search returns the answer the search bars show before a page opens: the published #1 of the list the reading opens, or the product it names, with its ChainChoice Score, rank and provenance; null for a question, a guide or a comparison..schemas .SearchAnswer - added
components: The follow-ups the bars offer under an answer: the reader’s words plus one phrase the rules read (a sort, the top three side by side, or a head-to-head with the leader)..schemas .SearchRefinement - added
components: Present when the model tier read a sentence the rules could not (mode=read, or the MCP search tool), only while the tier is switched on and released by a passing evaluation; an added field, nothing removed or retyped..schemas .SearchReadBy - changed
GET /api/search: q is required except with mode=status or mode=eval; a weak reading asks instead of opening, and the model tier may read it again. - added
components: The MCP search tool’s consented hand-off: the provider’s address from the catalog’s own record (never a referral link), with a signed receipt..schemas .McpHandOff
- added
- 2026-10-20 · 1.2.0
- added
GET /api/search: The agent read surface is documented: the reading the site’s search bars give a query, and the page it opens. No key. - added
GET /api/explorer: A category’s published order, with each row’s score, breakdown and provider page. No key. - added
POST /api/explorer: A category’s order for one reader: signals.country removes the rows not available there; each row keeps its published score and rank. - added
POST /mcp: The read-only MCP server (Streamable HTTP) over the published data: six tools, described at /.well-known/mcp.json. - added
GET /data/rankings: The published order of every category, with each score’s breakdown, the weights and the formula..json - added
GET /data/evidence/{category}: The quoted, dated evidence behind each criterion score in a category..json - added
components: Every answer on the read surface, and every recommendation, carries provenance: as-of date, methodology version and page, the files that re-check it, the data licence and its credit line, and the paid-link count..schemas .Provenance - changed
components: Adds provenance (an added field; nothing removed or retyped)..schemas .RecommendResponse - changed
components: Adds provenance, as of the day the receipt was issued..schemas .Receipt - changed
components: Adds provenance, as of the day the recommendation was issued..schemas .Explanation - added
components: Each MCP tool’s structuredContent has its own schema (McpListCategories, McpRanking, McpCountryRanking, McpEvidence, McpVerifyReceipt, McpSearch), and a search result, the page it opens and the reading are typed (SearchHit, NavTarget, SearchLanding, SearchResolution, SearchPlan). The read-surface answers, their rows and Provenance are closed: a key the spec does not list is never sent..schemas .McpRanking
- added
- 2026-10-20 · 1.1.0
- changed
RateLimited: A 429 now carries Retry-After (seconds until the window reopens), and x-ratelimit-reset is the Unix second the window reopens, read from the counter; it was always the response time plus 60 seconds. - added This changelog and the versioning and deprecation policy are published, and the spec’s version follows them (it had stayed 1.0.0 through every release below).
- changed
- 2026-10-13 · 1.0.0
- fixed
POST /api/v1/verify: A real signed receipt verifies (its SHA-256 digests were refused as private keys, and the signature in metadata was not read). A receipt naming a key ChainChoice does not publish answers 200 with verified: false instead of a 500, and the verdict names its keySource.
- fixed
- 2026-10-11 · 1.0.0
- added
GET /api/v1/imprint: The signed Impressum. It was served before only as /api/v1?_route=imprint and documented nowhere. - added
GET /api/v1/affiliate-disclosure: The signed affiliate disclosure. It was served before only as /api/v1?_route=affiliate-disclosure and documented nowhere. - added
POST /api/v1/verify: The hosted receipt verifier. It was served before only as /api/v1?_route=verify and documented nowhere.
- added
- 2026-09-14 · 1.0.0
- changed, breaking
POST /api/v1/recommend: The pick is the highest-ranked row of /data/rankings.json the stated country keeps; provider ids are the rankings ids (bybit_exchange, not bybit); pick and alternatives gained publishedRank and publishedScore, the response gained order, and the body gained an optional country. The values of existing v1 fields changed inside v1, which this policy now forbids; it was done because the earlier values were picks ChainChoice does not publish. - changed, breaking
GET /api/v1/receipts/{id}: Receipts store rankObjective "published_order" and a null effectiveCostPct.
- changed, breaking
- 2026-05-15 · 1.0.0
- added
POST /api/v1/recommend: One question in, the pick and the next rows out, with a receipt. - added
GET /api/v1/receipts/{id}: Read a receipt by id. - added
GET /api/v1/receipts/{id}/explanation: The plain-language explanation of a receipt. - added
DELETE /api/v1/user/{sessionId}: Delete what a session stored. - added
POST /api/partner-outcome: A partner reports the outcome of a referral. - added
GET /api/v1/admin/audit: The audit log, for administrators.
- added
Versioning and deprecation.
- The path segment v1 is the contract. Within it, fields, endpoints and optional request fields may be added (a minor version); fixes that keep every documented field and status are a patch.
- Nothing documented is removed or retyped inside v1, and no previously accepted request is turned into a 400. A breaking change ships as v2 and runs beside v1 for at least six months.
- An operation is deprecated with a dated entry here and deprecated: true with an x-sunset date on the operation in /docs/openapi.json. It keeps answering until that sunset date, at least 180 days after the notice.
- Every change to the documented contract is a release in this changelog, and /docs/openapi.json carries the newest release’s version.
In force since 2026-10-20; the releases before it are recorded as they shipped.