Every API here is available over the APIs.io API and to AI agents over MCP.
{
"openapi": "3.1.0",
"info": {
"title": "ECCA-AI public API",
"version": "1.0.0",
"summary": "Multi-engine answer verification: dispatch, adjudication, proof permalinks, keys, and checkout.",
"description": "ECCA-AI runs a question through several independent AI engines, then adjudicates their answers into one result and shows the reasoning that settled it. This document is derived from the route handlers under api/ and is pinned by ecca_openapi_contract_test.js: every path maps to a handler file, and every documented error code is a literal the handler (or a module it requires) actually returns. Limits stated here are the code's own constants (client/ecca_tier_limits.js, lib/api_prelude.js, lib/ecca_mcp_server.js). Rate buckets are held in memory per serving instance, so they are an abuse brake, not a global guarantee. No uptime or latency service level is measured or promised. Academic reasoning and dialectic evaluation studio only, not a medical device.",
"termsOfService": "https://eccaai.com/faq",
"contact": {
"name": "ECCA-AI",
"url": "https://eccaai.com/developers"
}
},
"servers": [
{
"url": "https://eccaai.com",
"description": "Production (sole canonical origin)"
}
],
"tags": [
{
"name": "solve",
"description": "Engine dispatch and adjudication. Spends solve quota."
},
{
"name": "mcp",
"description": "Model Context Protocol over HTTP (JSON-RPC 2.0)."
},
{
"name": "proof",
"description": "Durable /p/<id> adjudicated-answer permalinks."
},
{
"name": "keys",
"description": "Self-serve API key lifecycle (session cookie)."
},
{
"name": "billing",
"description": "Checkout and entitlement (session cookie)."
},
{
"name": "status",
"description": "Config-truth probes. No secrets, no side effects."
},
{
"name": "operator",
"description": "Operator-only surfaces. Anything without the operator session is an indistinguishable 404."
},
{
"name": "radar",
"description": "Pre-dispatch topic lookups over consented records. Zero engine calls, zero spend."
}
],
"x-ecca-quotas": {
"source": "client/ecca_tier_limits.js, lib/api_prelude.js, lib/ecca_mcp_server.js, lib/ratelimit.js",
"freeDailySolves": 5,
"freeDailyDeepSolves": 1,
"freeDailyProofShares": 10,
"freeDailyProofSharesPerIp": 30,
"proWindowHours": 5,
"proWindowFullSolves": 20,
"freeStreamMaxOutputBytes": 32768,
"proStreamMaxOutputBytes": 262144,
"abuseBrakePerMinutePerIp": 60,
"keyedRequestsPerMinutePerKeyId": 120,
"resetsAt": "00:00 UTC (the quota key is the UTC date)",
"scope": "Durable free daily pool per account for signed-in sessions and free-tier keys, or per client IP for anonymous web requests; every /api/mcp tool call is keyed (the IP pool is not reachable there), shared across solve surfaces. Pro has an account-wide five-hour window of deep checks starting with the first counted solve, shared by sessions and account-linked keys; subsequent attempts do not extend its reset. The usage snapshot includes used, limit, remaining, meter and resetAt; a full window returns 429 and Retry-After without an upsell. Enterprise and owner are outside this Pro counter, but service safety and capacity limits still apply. Per-minute abuse brakes remain per instance."
},
"x-ecca-error-catalog": {
"bad-op": "body.op is not create or revoke.",
"bad-state": "state is present but not an opaque token matching ^[A-Za-z0-9_-]{1,64}$.",
"check-requires-candidate": "mode check needs body.candidate, the answer another assistant gave, as non-empty text; nothing was dispatched.",
"empty-prompt": "prompt (q on /api/trap-radar) is missing or blank after trimming; nothing was dispatched.",
"empty-proof-payload": "prompt is missing, or neither answer nor synthesis was supplied.",
"Invalid MCP request": "Malformed MCP envelope, unknown method, or unsupported protocol version (400); disallowed Origin (403). A tool rejecting its input answers -32601 with its own message.",
"invalid-checkout-attempt": "checkoutAttemptId is present but does not match ^[A-Za-z0-9_-]{8,128}$.",
"invalid-document": "The supplied base64 data or PDF header is invalid.",
"invalid-id": "The proof id is not 4-64 characters of [a-zA-Z0-9_-].",
"invalid-key-id": "keyId is not 8-64 lowercase hex characters.",
"invalid-plan": "plan is not one of the four eccaai-* plans.",
"Missing required field 'query' or 'prompt'": "Neither query nor prompt was supplied.",
"missing-image": "imageBase64 is empty after stripping any data: prefix.",
"tournament-requires-options": "mode tournament needs body.options with at least two candidates; no default ranking is served.",
"unsupported-image-type": "mimeType is not image/jpeg, image/png or image/webp.",
"api-key-required": "A /api/mcp tools/call carried no API key (JSON-RPC -32001, data.reason key-required; the message names the free key). Every MCP tool call needs one; a free key is one click on /developers after sign-in, no card. initialize, ping and tools/list stay open.",
"invalid-api-key": "An ecca_sk_ key was presented and failed signature verification (or keys are not configured on this deployment). Never a silent downgrade to anonymous.",
"key-revoked": "The key verifies but is on the revocation denylist.",
"unauthorized": "No verified session cookie (or, on /api/export-traces, the bearer does not match the export token).",
"credits-required": "A valid free-tier key has used its 5 daily solves; a paid key tier continues.",
"upgrade-required": "Publishing a proof permalink into search needs the ecca-proof-links entitlement (Pro); minting and unpublishing never do.",
"sign-in-required": "Minting a proof link, or publishing one, needs a signed-in session (publishing: the owner's).",
"proof-share-limit": "A free account, or its client network, has minted today's free proof links (x-ecca-quotas.freeDailyProofShares per account, freeDailyProofSharesPerIp per network). Resets at 00:00 UTC; Retry-After names the wait. Links already minted keep working.",
"proof-share-limit-unavailable": "The free proof-share counter could not be read, so nothing was minted. Retry shortly.",
"not-owner": "Only the account that minted a proof can publish or unpublish it.",
"entitlement-unavailable": "The account tier could not be read; nothing was minted. Retry shortly.",
"proof-read-failed": "The proof to publish could not be read; nothing was changed.",
"proof-index-unreadable": "The owner's proof index could not be read, so the new proof could not be made purgeable; the mint was rolled back.",
"proof-index-full": "The owner's proof index is at its cap; the mint was rolled back.",
"proof-index-write-failed": "The owner's proof index could not be written; the mint was rolled back.",
"checkout-not-enabled": "The requested plan is mapped but is not a live public checkout plan.",
"checkout-not-rolled-out": "The server-side rollout stage (off by default) does not admit this caller; the body names the stage.",
"forbidden-origin": "Key mutations require an allowlisted Origin header in addition to the session.",
"key-not-found": "keyId is not one of the caller's keys. The same answer whether it exists elsewhere or not.",
"no-key": "The account has neither a plan-provisioned key nor any self-serve keys yet.",
"Not found": "Operator routes answer a plain-text 404 for every request without the operator session, wrong token, or wrong method.",
"proof-not-found": "No persisted record exists at that id; nothing is substituted.",
"method-not-allowed": "The HTTP method is not in the route's allowlist.",
"already-subscribed": "The account already holds the requested tier or higher (body carries ownedTier); no Session is created.",
"key-limit-reached": "The account's active-key cap is reached (the body's limit field states it); revoke one first.",
"image-too-large": "imageBase64 exceeds 8000000 characters.",
"document-too-large": "The PDF exceeds about 3 MB, or the extracted text exceeds 45000 characters.",
"payload-too-large": "The request body exceeds the route's byte cap (4001024 on /api/document-ingest, 16777216 on /api/ecca-router, 1048576 on /api/proof-link, 2048 on /api/ecca-key, 262144 on /api/mcp, 4096 on /api/trap-radar, whose q is also capped at 500 characters).",
"unreadable-document": "The PDF could not be parsed or has no readable text.",
"free-daily-limit": "The free daily pool (5 solves per signed-in account or anonymous client IP) is spent; it resets at 00:00 UTC.",
"pro-window-limit": "The Pro account window of deep checks is full. Retry-After and resetAt name its reset; solves and meter report the enforced count. No purchase is needed to reset it.",
"key-rate-limited": "The key id exceeded 120 requests in the current minute.",
"rate-limited": "The per-route abuse brake (60 requests per minute per client IP, per serving instance) tripped; retry_after_s names the window.",
"Document ingestion failed": "Text extraction or the audit failed unexpectedly; internal details are not returned.",
"internal-server-error": "An unexpected failure inside the key route.",
"mint-error": "The freshly minted key failed its own verification; nothing was stored.",
"store-write-failed": "The durable store refused the proof write.",
"ai-unavailable": "Engines were configured and dispatched but none returned an answer.",
"key-write-failed": "The durable store refused the key-list write; no key was created.",
"revoke-failed": "The denylist write failed; the key is still active.",
"stripe-error": "Stripe refused to create the Checkout Session; the reason stays in server logs.",
"stripe-price-unverified": "Stripe could not be reached to verify the configured price before creating a Session.",
"stripe-unreachable": "The Stripe request itself failed.",
"transcription-unavailable": "The vision call returned nothing readable; no problem text is guessed.",
"ai-not-configured": "Fail-closed: no engine is configured on this deployment.",
"billing-catalog-mismatch": "Fail-closed: the configured Stripe price disagrees with the published catalog; mismatches lists the fields.",
"billing-not-configured": "Fail-closed: billing is not configured for the requested plan.",
"engines-cooling": "Every configured engine's circuit is open after repeated failures; retryAfterMs is the measured horizon.",
"export-not-configured": "No export token is configured on this deployment.",
"keys-not-configured": "No key-signing secret is configured on this deployment, so no key can be minted.",
"probe-unavailable": "The capability probe threw; no map is returned so no badge is demoted.",
"proof-links-unavailable": "Fail-closed: proof persistence is not configured on this deployment.",
"spend-ceiling-reached": "The operator's daily engine-dispatch ceiling (only when one is configured) is reached; service resumes when the day rolls over.",
"store-unconfigured": "Fail-closed: no durable store on this deployment.",
"vision-not-configured": "No vision tier on this deployment; no network attempt is made.",
"owner-monthly-cap": "MCP engine work is refused when owner monthly spend reaches its cap or is unknown. No further engine stage starts. Local tools and research remain available within service limits."
},
"components": {
"securitySchemes": {
"ApiKeyBearer": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "ecca_sk_<payload>.<signature>",
"description": "Signed stateless ECCA API key. Only values that start with ecca_sk_ are treated as keys; on /api/mcp a non-ECCA bearer is refused with 401 invalid-api-key; other routes may fall through as anonymous. A presented key that fails verification is an explicit 401, never a silent downgrade. Keys are minted on a Pro or Enterprise checkout, or created on /developers by any signed-in account: a free account holds one FREE-tier key (its daily pool per account), paid accounts have a higher cap reported by activeKeyLimit. Every /api/mcp tool call requires a key."
},
"ApiKeyHeader": {
"type": "apiKey",
"in": "header",
"name": "x-ecca-api-key",
"description": "Alternative carrier for the same ecca_sk_ key."
},
"SessionCookie": {
"type": "apiKey",
"in": "cookie",
"name": "session",
"description": "HttpOnly same-origin session cookie set by /api/auth after sign-in on eccaai.com. Bearer headers are ignored on session-only routes. Mutations on /api/ecca-key additionally require an allowlisted Origin header."
},
"OperatorSession": {
"type": "apiKey",
"in": "cookie",
"name": "__Host-ecca_ops",
"description": "Operator-only. Installed by opening /ops/status?token=<operator token> once; every request without it is a plain 404."
},
"ExportToken": {
"type": "http",
"scheme": "bearer",
"description": "Operator export token for /api/export-traces. Gated: the route is 503 until the deployment configures it."
}
},
"schemas": {
"AtomicClaimRecord": {
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"id",
"url",
"sealed",
"checkedAt",
"engineCount",
"measured",
"splitCount",
"claims",
"limitation"
],
"properties": {
"schema": {
"const": "ecca.claim-verification.v1"
},
"id": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{4,64}$"
},
"url": {
"type": "string",
"pattern": "^https://eccaai\\.com/p/[a-zA-Z0-9_-]{4,64}$"
},
"sealed": {
"type": "boolean"
},
"checkedAt": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"engineCount": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"measured": {
"type": "boolean"
},
"splitCount": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"claims": {
"type": "array",
"maxItems": 72,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"pointId",
"text",
"variants",
"comparison",
"category",
"tie",
"engines",
"checks",
"sources"
],
"properties": {
"id": {
"type": "string",
"pattern": "^claim-[a-f0-9]{24}$"
},
"pointId": {
"type": "string",
"pattern": "^point-[a-f0-9]{24}$"
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 400
},
"variants": {
"type": "array",
"minItems": 1,
"maxItems": 12,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"engineId",
"text"
],
"properties": {
"engineId": {
"type": "string",
"enum": [
"speed",
"depth",
"breadth",
"logic",
"context",
"agentic"
]
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 400
}
}
}
},
"comparison": {
"type": "string",
"enum": [
"split",
"agreement"
]
},
"category": {
"type": [
"string",
"null"
],
"enum": [
"factual",
"interpretive",
"scope",
"ambiguity",
"temporal",
"hallucination",
null
]
},
"tie": {
"type": "boolean"
},
"engines": {
"type": "array",
"minItems": 1,
"maxItems": 6,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"engineId",
"number",
"position"
],
"properties": {
"engineId": {
"type": "string",
"enum": [
"speed",
"depth",
"breadth",
"logic",
"context",
"agentic"
]
},
"number": {
"type": "integer",
"minimum": 1,
"maximum": 6
},
"position": {
"type": "string",
"enum": [
"agrees",
"disagrees",
"not-measured"
]
}
}
}
},
"checks": {
"type": "object",
"additionalProperties": false,
"required": [
"evidence",
"logic",
"assumptions"
],
"properties": {
"evidence": {
"const": "untested"
},
"logic": {
"const": "untested"
},
"assumptions": {
"const": "untested"
}
}
},
"sources": {
"type": "array",
"maxItems": 0,
"description": "No sealed claim-source binding exists in legacy proofs. Answer-level URLs are not attached to a claim by inference."
}
}
}
},
"limitation": {
"const": "Agreement is not proof. Open the sources."
}
}
},
"ProofCitationRecord": {
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"id",
"url",
"available",
"checkedAt",
"counts",
"apa",
"mla",
"reason"
],
"properties": {
"schema": {
"const": "ecca.proof-citation.v1"
},
"id": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{4,64}$"
},
"url": {
"type": "string",
"pattern": "^https://eccaai\\.com/p/[a-zA-Z0-9_-]{4,64}$"
},
"available": {
"type": "boolean"
},
"checkedAt": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"counts": {
"type": "object",
"additionalProperties": false,
"required": [
"compared",
"split"
],
"properties": {
"compared": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"split": {
"type": [
"integer",
"null"
],
"minimum": 0
}
}
},
"apa": {
"type": [
"string",
"null"
]
},
"mla": {
"type": [
"string",
"null"
]
},
"reason": {
"type": [
"string",
"null"
],
"enum": [
null,
"unsealed",
"seal-date-unavailable",
"question-unavailable"
]
}
}
},
"ErrorBody": {
"type": "object",
"required": [
"error"
],
"properties": {
"ok": {
"type": "boolean",
"description": "Present on some routes; always false on an error."
},
"error": {
"type": "string",
"description": "Stable machine-readable code. See x-ecca-error-codes on each response."
},
"message": {
"type": "string"
},
"upgradeUrl": {
"type": "string",
"examples": [
"/pricing"
]
},
"retry_after_s": {
"type": "integer",
"description": "Seconds until the abuse brake window rolls (rate-limited only)."
},
"limit": {
"type": "integer",
"description": "The cap that was hit (free-daily-limit, key-limit-reached)."
}
},
"additionalProperties": true
},
"JsonRpcError": {
"type": "object",
"required": [
"jsonrpc",
"error"
],
"properties": {
"jsonrpc": {
"const": "2.0"
},
"id": {
"description": "Echo of the caller's request id, or null."
},
"error": {
"type": "object",
"required": [
"code",
"message"
],
"properties": {
"code": {
"type": "integer",
"enum": [
-32000,
-32001,
-32002,
-32600,
-32601
]
},
"message": {
"type": "string"
},
"data": {
"type": "object",
"properties": {
"reason": {
"type": "string"
},
"upgradeUrl": {
"type": "string"
}
}
}
}
}
}
},
"Engine": {
"type": "object",
"description": "One registered engine role. Identity only (id/name/role) on the registry read; live telemetry (status/ok/ms) after a dispatch. The registry holds up to 6 roles; how many answered is measured per response in meta.enginesLive.",
"properties": {
"id": {
"type": "string",
"enum": [
"speed",
"depth",
"breadth",
"logic",
"context",
"agentic"
]
},
"name": {
"type": "string"
},
"role": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"live",
"gated",
"unavailable",
"unconfigured"
]
},
"ok": {
"type": "boolean"
},
"ms": {
"type": [
"integer",
"null"
]
},
"receipt": {
"type": "string"
}
}
},
"RegistryResponse": {
"type": "object",
"required": [
"ok",
"providers",
"count"
],
"properties": {
"ok": {
"const": true
},
"providers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Engine"
}
},
"count": {
"type": "integer"
}
}
},
"UsageResponse": {
"type": "object",
"required": [
"ok",
"tier",
"unlimited"
],
"properties": {
"ok": {
"const": true
},
"tier": {
"type": "string"
},
"unlimited": {
"type": "boolean",
"description": "Legacy compatibility flag for enterprise/owner exemptions from this counter; false for Pro. It is not a promise of unbounded service."
},
"solves": {
"type": "object",
"description": "Free daily count or Pro account window, read from the same durable counter the POST path enforces. Reads never spend quota or start a window. Pro adds windowHours, resetAt, retryAfter and meter; resetAt is null before the first counted solve.",
"properties": {
"used": {
"type": "integer"
},
"remaining": {
"type": "integer"
},
"limit": {
"type": "integer"
}
}
}
}
},
"SolveRequest": {
"type": "object",
"required": [
"prompt"
],
"properties": {
"prompt": {
"type": "string",
"description": "The question. Truncated to 64000 characters for free callers and 500000 for paid callers; an empty prompt is a 400 empty-prompt."
},
"context": {
"type": "object",
"description": "Optional context the engines see with the question, as fields: turns (up to the last 3 earlier {q, a} exchanges) and document ({label, text}, text up to 8000 characters). The server builds the engine prompt from them. A shared proof is sealed over the typed question plus this context, stores the context as the engines saw it (capped), and shows it on the public page under What the engines also saw and in /p/<id>.json.",
"properties": {
"turns": {
"type": "array",
"items": {
"type": "object",
"properties": {
"q": {
"type": "string"
},
"a": {
"type": "string"
}
}
}
},
"document": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"text": {
"type": "string"
}
}
}
}
},
"depth": {
"type": "string",
"description": "\"fast\" routes to one cost-optimal engine; \"deep\" is a multi-engine dispatch: paid callers get the full configured matrix; free callers start with two engines and expand on measured divergence, and after the free daily deep solve is spent the request is downgraded to fast with meta.downgraded=true. Accepted aliases: intuitive (fast); standard, executive, pioneer (deep). Any other value is a 400 invalid-depth before any quota is spent.",
"enum": [
"fast",
"deep",
"intuitive",
"standard",
"executive",
"pioneer"
],
"examples": [
"fast",
"deep"
]
},
"mode": {
"type": "string",
"description": "Answer posture. cothink and socratic are chat lanes that always meter through the deep ladder. stress is a Socratic deep solve plus three probe variants of the question (asked again fresh with the question verbatim, its opposite, one premise changed), dispatched together; it is charged as STRESS_TEST_SOLVE_COST solves (client/ecca_tier_limits.js: the main solve plus one per probe) against the caller's free daily solves and free deep solve, or Pro usage-window solves, all reserved atomically before dispatch or none. If the main solve returns an error, every reserved solve (and the free deep solve) is given back and the error carries stressRefund; a reader who disconnects (client-aborted) after the engines started keeps the charge. It returns meta.stressTest (schema ecca.stress-test.v1): each main-answer claim with how many probes it survives (a matching claim with the same stance in that probe's answers; any matching claim with the opposite stance flips it; a failed probe never counts). Matching is lexical (shared words plus a count of negation words), not semantic. stress is refused with a 400 on depth fast, with a niche, with an adjudication mode, and with an API key. debate runs the question as a live three-round debate: round 1 is the normal deep dispatch (each engine that answers is a debater); in round 2 each debater sees only the other debaters' extracted central claims plus the single largest claim split as the debate question and answers in at most 120 words; in round 3 each returns structured JSON (changed_mind yes/no, a one-sentence final_position, side A/B/neither, dropped_claims naming ids of its own claims it withdraws; ids it was never shown are ignored). Rounds 2 and 3 are each cut off at DEBATE_ROUND_SECONDS (client/ecca_tier_limits.js, at most 25 s); a late engine is recorded as late. It is charged as DEBATE_SOLVE_COST solves, reserved atomically before dispatch like stress; a failed round 1 gives every solve back (debateRefund), and a round no engine answered is given back. It returns meta.debate (schema ecca.debate-live.v1): the rounds, a verdict computed only from the structured fields (changedMind, heldOut, noFinal, claimsDropped, split), the per-engine dropped counts, and finalAnswer: the engine whose final position dropped the fewest of its own claims, ties broken by not having changed its mind (DEBATE_HELD_UP_KEY), among engines shown at least one claim of their own; a remaining tie is reported as a tie, never one answer, and with no qualifying engine there is no final answer. Claims that name a round 3 field or steer the other engines are never shown to another engine; rounds 2 and 3 answer in the question's language. On a stream, debate_round, debate_turn and debate_done events arrive as each round starts and each engine lands. debate is refused with a 400 on depth fast, with a niche, with an adjudication mode, and with an API key. verify, tournament, premortem, audit and check are adjudication modes that also attach a deterministic modeReport. check reruns the question alone (the candidate is never shown to the engines) and returns check: the panel's canonical final answer against the candidate's. tutor is a Socratic solve that writes a hint ladder (Hint, Bigger hint, Show next step, Show answer); it never streams and returns tutor (schema ecca.tutor.v1): an id and the steps' kinds and labels only, never their text. tutor-reveal with tutorId and level returns exactly one step's text, in order (an unopened later step is a 409 tutor-step-order); it dispatches nothing and spends no quota. practice asks for PRACTICE_PROBLEMS similar problems (client/ecca_tier_lim
# --- truncated at 32 KB (162 KB total) ---
# Full source: https://artifacts.apis.io/eccaai-com/openapi/_original/eccaai-com-api-openapi.json