Skip to content

Public API

Every route under /api, with its limits and status codes. Rendered from docs/API.md in the repository.

Source of truth: docs/API.md

This page renders docs/API.md from the repository at request time, so the reference here and the reference in the source tree are the same file. Links that point into the repository open on GitHub.

Base URL of the production deployment: https://app.openverdict.info/api.

Complete reference for every route under app/api. The base URL of the production deployment is https://app.openverdict.info/api; --base <url> in the CLI and the <base> in every example below point at any deployment.

Every response is JSON except the Server-Sent Events stream. The TypeScript types behind the shapes live in lib/engine/contract.ts; the status codes below come from the route handlers themselves.

Conventions

Ids. Claim ids, run ids, seat ids, agent profile ids, certificate ids and object ids are 66-character lowercase hex strings (0x plus 64 hex digits). Transaction digests are base58. Walrus blob ids are base64url. Queue ids and stake reservation ids are UUIDs. GonkaRouter request ids look like req-<digits>-<digits>.

Errors. Every error body carries an error code and usually a message:

{ "error": "validation_error", "message": "claim id is required" }

Error codes are lowercase snake case everywhere except POST /api/extract-claim, which uses uppercase codes (INVALID_URL, NO_CLAIM_FOUND, FETCH_FAILED, ENGINE_NOT_WIRED, INTERNAL_ERROR).

Two statuses appear on almost every route. 503 engine_not_wired means the deployment is missing its chain, storage or database wiring. 500 internal_error carries the underlying message. Neither is listed again per route below unless the route behaves differently.

Guards

GuardApplies toFailure
Public readevery GET in this documentnone
Public writes flagevery public POST403 writes_disabled, message "public submissions are disabled", unless OPENVERDICT_PUBLIC_WRITES=enabled
Rate limitevery public POST429 rate_limited, message "too many submissions, retry later"
Free seats flagPOST /api/agents/register only403 free_seats_disabled, message "stake on a seat through /agents", unless OPENVERDICT_FREE_SEATS=enabled
Operator tokenPOST /api/claims only403 forbidden, message "not authorized for this action"

The rate limiter (app/api/_lib/guard.ts) is in-process and fixed-window over 60 seconds. The global ceiling is 60 requests per window across all guarded writes, and it is checked first so a spoofed header cannot route around it. The per-client bucket of 5 requests per window applies only when OPENVERDICT_TRUST_PROXY=1, because x-forwarded-for is attacker-controlled otherwise; the client key is the first hop, SHA-256 hashed before storage.

Operator auth compares a Authorization: Bearer <OPENVERDICT_OPERATOR_TOKEN> header in constant time and returns one uniform 403 on every failure, so the response never reveals whether a token is configured. A token shorter than 16 characters keeps operator writes closed.

Reading routes

GET /api/weather

The latest public probe of the three model families and the web search provider. A jury needs all four. Sent with Cache-Control: no-store.

curl -s https://app.openverdict.info/api/weather
{
  "probedAtMs": 1788496731822,
  "stale": false,
  "clear": false,
  "families": [
    { "modelId": "deepseek-ai/DeepSeek-V4-Flash-0731", "family": "deepseek", "ok": false, "latencyMs": 60004, "status": "TIMEOUT" },
    { "modelId": "research:firecrawl", "family": "research", "ok": true, "latencyMs": 297, "status": "200 1189 credits" }
  ]
}

clear is true only when the probe is fresh and every family is ok; unknown weather is never clear. stale is true when there is no probe or the newest one is older than the staleness window. status is the HTTP status as text, or TIMEOUT or ERROR.

StatusBodyMeaning
200WeatherReportthe latest probe
503engine_not_wireddeployment not wired
500internal_errorunexpected failure

GET /api/status

Live engine, Sui, GonkaRouter, Walrus and database status.

curl -s https://app.openverdict.info/api/status
{
  "appVersion": "0.1.0",
  "network": "testnet",
  "packageId": "0x1f7b684d36979046a077b38caae8d567616bc691f23b018e65ac194d314f0c13",
  "registryObjectId": "0x4020f3cbe51c1cdf6d004696e7cdf0d19f67fde2572b72a5f39a51d119f8ebab",
  "suiHealthy": true,
  "gonkaMode": "live",
  "walrusMode": "testnet",
  "dbHealthy": true,
  "paused": false
}

latestCheckpoint is optional and absent when the node did not answer. gonkaMode is live or fake; walrusMode is local, testnet or mainnet.

StatusBody
200EngineStatus
503engine_not_wired
500internal_error

GET /api/claims

The board: every claim as a full ClaimInspection, newest first.

Query: state=<number> filters on the on-chain claim state (see the state table under "Ids and links"). A non-numeric value is ignored. There is no server-side limit: the handler reads only state, so a caller that passes ?limit= must trim the array itself, which is what ov board --limit does.

curl -s "https://app.openverdict.info/api/claims?state=10"
{ "claims": [ { "claimId": "0x2732...", "state": 10, "...": "trimmed" } ] }
StatusBody
200{ claims: ClaimInspection[] }
503engine_not_wired
500internal_error

GET /api/claims/{id}

One claim, its deadlines, its committee, its commitments and its result.

Query: verify=1 or verify=true adds a verification object (commitmentsRecomputed, truthScoreRecomputed, evidenceRootsRecomputed, issues[]) recomputed server-side.

curl -s https://app.openverdict.info/api/claims/0x273220b56d87edea0a6db35f85c0fc8f36591461ee6be6962e86bb4586ee4ac6
{
  "claimId": "0x273220b56d87edea0a6db35f85c0fc8f36591461ee6be6962e86bb4586ee4ac6",
  "mode": 1,
  "state": 10,
  "statement": "Humans use only ten percent of their brains.",
  "resolutionCriteria": "Decide whether the statement is true as written ...",
  "deadlines": {
    "evidenceCutoffMs": 1788405501694,
    "proposalDeadlineMs": 1788405506694,
    "challengeDeadlineMs": 1788405511694,
    "firstCommitDeadlineMs": 1788406041694,
    "firstRevealDeadlineMs": 1788406161694,
    "discussionDeadlineMs": 1788407001694,
    "secondCommitDeadlineMs": 1788407241694,
    "secondRevealDeadlineMs": 1788407361694
  },
  "committeeId": "0xcb8560e363f87e690ef55e1a7d4d49c039cc0efe8b43179e1b49e36dfcfe39b6",
  "evidenceRoots": [
    { "phase": 1, "root": "0x532792ca...", "bundleId": "0xad34aa81..." }
  ],
  "commitments": [
    {
      "jurySeatId": "0x44525825...",
      "agentProfileId": "0x546e1491...",
      "modelId": "deepseek-ai/DeepSeek-V4-Flash-0731",
      "committed": true,
      "revealed": true,
      "outcome": 2,
      "confidenceBps": 9500
    }
  ],
  "rounds": [
    { "phase": 1, "expectedJurySeatIds": ["0x..."], "committedJurySeatIds": ["0x..."], "revealedJurySeatIds": ["0x..."] }
  ],
  "attemptChain": {
    "verificationId": "0xf0db7043...",
    "attempt": 3,
    "maxAttempts": 3,
    "status": "SETTLED",
    "previousAttempts": [
      { "claimId": "0xf0db7043...", "attempt": 1, "status": "VOIDED", "voidReason": "PROVIDER_ERROR" }
    ]
  },
  "result": {
    "claimId": "0x2732...",
    "result": "NO",
    "truthScoreBps": 200,
    "certificateId": "0x42954c91...",
    "digest": "572tT7FGmL6FG3ZEzf2DkorPzaStVymnvxNVMgF2bkXi"
  }
}

Key fields. outcome in a commitment is the numeric vote code (1 YES, 2 NO, 3 UNSURE); result.result is the string form and adds UNRESOLVED. confidenceBps is basis points out of 10000. deliberation[] appears on a two-round claim: one DeliberationTurnPublic per debate turn, with ordinal, exchange (1 to 3), stance, confidenceBps, argument, citations[] and status (SPOKEN or SKIPPED). A turn that ran on deliberation prompt spec V4 also carries specVersion ("4"), answering (the seat number this turn answers, null only when it opens the debate), theirPoint (that seat's point, at most 240 characters), analysis (at most 900), position (at most 240, stated last) and question ({seat, text}, present only when this turn put one to a named seat). All six are absent on turns from spec V1 to V3, and argument is always present: for a V4 turn it is the analysis and the position joined. Seat numbers in a V4 turn are 1-based and equal the juror numbers the console prints, so seat 1 is juror 1; V1 to V3 turns number seats from 0. debateConvergedAfterExchange is present when the debate stopped early. attemptChain.status is ACTIVE, VOIDED, SETTLED or GAVE_UP, and a voided attempt carries void and later relaunchedAs.

StatusBodyMeaning
200ClaimInspectionthe claim
400validation_errorempty id
404claim_not_foundno claim with that id
503engine_not_wired
500internal_error

GET /api/claims/{id}/report

The public fact-check report in display order, plus the machine-readable audit bundle.

curl -s https://app.openverdict.info/api/claims/0x2732.../report
{
  "claimId": "0x2732...",
  "statement": "Humans use only ten percent of their brains.",
  "submittedUrls": [],
  "label": "NO",
  "truthScore": 2,
  "truthScoreFormula": "confidence is read as the juror's probability that its own vote is correct; mean(YES confidence, NO (10000-confidence), UNSURE 5000) over valid reveals, rounded half-up; displayed as basis-points / 100",
  "finalRoundVotes": [
    { "jurySeatId": "0x44525825...", "outcome": "NO", "confidenceBps": 9500, "valid": true }
  ],
  "agents": [
    {
      "agentProfileId": "0x546e1491...",
      "owner": "0xc751f1d9...",
      "modelId": "deepseek-ai/DeepSeek-V4-Flash-0731",
      "role": "SOURCE_AUTHENTICITY",
      "outcome": "NO",
      "confidenceBps": 9500,
      "gonkaRequestId": "devshard-70083-36",
      "evidenceIds": ["0x8966af5f..."],
      "reasoning": "The claim that humans use only ten percent ...",
      "publicReasoningTrace": [
        { "check": "Search for evidence challenging the claim", "finding": "...", "assessment": "CONTRADICTS", "evidenceIds": ["0x..."] }
      ]
    }
  ],
  "evidence": [
    { "evidenceId": "0x15617905...", "sourceUrl": "urn:openverdict:claim-statement", "blobId": "5Wm-E1ZTXcfuIBjKaLhoZqOhUUGJ99RdGlStBEPwkBk", "contentHash": "0x8753be8c..." }
  ],
  "evidenceRoot": "0x532792ca...",
  "sui": {
    "claimObjectId": "0x2732...",
    "committeeId": "0xcb8560e3...",
    "certificateId": "0x42954c91...",
    "revealedVoteIds": ["0x29b1445e..."]
  },
  "auditBundle": { "...": "trimmed" }
}

label is YES, NO, UNSURE, UNRESOLVED or PENDING. truthScore is basis points divided by 100, or null before settlement. valid on a vote is false when the reveal did not match its commitment; only valid reveals enter the score. auditBundle holds version, claim, committee, evidence, evidenceArtifacts, runs, runApprovals, commitments, reveals and certificate.

StatusBody
200FactCheckReport
400validation_error
404claim_not_found
503engine_not_wired
500internal_error

GET /api/claims/{id}/events

The resolution event log, either as a Server-Sent Events stream (the default) or as a JSON snapshot.

Query and headers:

ParameterEffect
snapshot=1return { events: ResolutionEvent[] } as JSON instead of streaming
from=Nstart at sequence N, inclusive
Last-Event-ID: N headerresume at sequence N + 1; takes precedence over from
curl -s "https://app.openverdict.info/api/claims/0x2732.../events?snapshot=1&from=70"
curl -N  "https://app.openverdict.info/api/claims/0x2732.../events?from=70"

The stream is sent with Content-Type: text/event-stream; charset=utf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive and X-Accel-Buffering: no. Events are unnamed, so an EventSource receives them through onmessage; each carries id: <sequence> and one data: line. A : heartbeat comment every 15 seconds keeps proxies from closing the connection.

The envelope:

{
  "kind": "claim_finalized",
  "phase": "FINALIZED",
  "source": "SUI",
  "claimId": "0x2732...",
  "eventId": "e9e50366-4810-4c9b-a7c3-a6803793a9b8",
  "sequence": 78,
  "occurredAt": "2026-09-03T03:27:41.882Z",
  "publishedAt": "2026-09-03T03:27:41.882Z",
  "visibility": "PUBLIC_NOW",
  "transactionDigest": "572tT7FGmL6FG3ZEzf2DkorPzaStVymnvxNVMgF2bkXi",
  "payload": { "outcome": "NO", "truth_score_bps": 200, "certificate_id": "0x42954c91..." }
}
FieldMeaning
kindwhat happened, for example claim_created, evidence_submitted, evidence_retrieved, committee_selected, evidence_frozen, agent_activity, RESEARCH_TICK, output_repaired, run_approved, vote_committed, phase_changed, vote_revealed, inference_completed, argument_published, claim_finalized
phaselifecycle phase label, for example CREATE, EVIDENCE_1, EVIDENCE, COMMIT_1, ROUND_1, INFERENCE_1, REVEAL_1, DISCUSSION, COMMIT_2, REVEAL_2, FINALIZED
sourceENGINE, GONKA_ROUTER, TOOL, EVIDENCE or SUI
claimIdthe claim this event belongs to
eventIdUUID, unique per event
sequencemonotonic per claim; the SSE id: and the --since value
occurredAtISO 8601 UTC
visibilityPUBLIC_NOW, PUBLIC_AFTER_REVEAL or INTERNAL_REDACTED
payloadfree-form object, one shape per kind

Optional: publishedAt, actorId, runId, transactionDigest, checkpoint, artifactHash.

StatusBody
200SSE stream, or { events } with snapshot=1
400validation_error
503engine_not_wired
500internal_error

GET /api/claims/{id}/runs/{runId}/proof

Everything public about one juror inference run. A revealed run is immutable, so it is served with Cache-Control: public, max-age=31536000, immutable.

curl -s https://app.openverdict.info/api/claims/0x2732.../runs/0x36173f41.../proof

Top level: runId, claimId, phase, agentProfileId, jurySeatId, promptHash, inputHash, outputHash, runHash, gateway, claimDeadlines, sealPolicy, sealedBlobId, sealed, revealedBlobId, revealed, bundle, sui. A failed seat carries failure and nulls for runHash, bundle and both blob ids.

{
  "gateway": { "gatewayRequestId": "req-1788405572969008592-322552", "devshardId": "70083", "systemFingerprint": "vllm-0.25.1-tp2-8aac2e07" },
  "sealPolicy": { "packageId": "0xf54eb611...", "threshold": 1, "keyServers": [ { "objectId": "0xb012378c...", "weight": 1, "aggregatorUrl": "https://seal-aggregator-testnet.mystenlabs.com" } ] },
  "sui": {
    "claimObjectId": "0x2732...",
    "agentProfileId": "0x4ee8af57...",
    "jurySeatId": "0xc5e4acc5...",
    "runApproval": { "objectId": "0xcf3a6bc9...", "transactionDigest": "FKDaAKuki8Hsjk1ZiYvvm4WTQjiKMoWiTsVgdXxqbWaE" },
    "commitment": { "objectId": "0x98cdacf3...", "transactionDigest": "Fgc3kP5b2zaidMT4geQLf2pUesPb5mgPFhUsJkhisXm9" },
    "reveal": { "objectId": "0xea1afadf...", "transactionDigest": "2a8Pg3xUHeheVGV7xFRK1XaSjRBujMnbwQbvBKc9dcho" }
  }
}

The bundle sections, in the order a verifier walks them:

SectionWhat it holds
request.messagesthe exact conversation sent to the model, system message first; this is what the re-execution check resends
request alsomodel, attemptKind, maxTokens, temperature, responseFormat
promptSpec, promptHashthe pinned prompt and its hash
toolPolicy, toolPolicyHashthe search and open budgets the juror ran under
transcript.steps[]every search and open, each with action (action, intent support or challenge, query), result, modelRequestId and completedAtMs
transcript.opened[]every page opened, with evidenceId, contentHash, canonicalHash and its Walrus blob ids
transcript.citations, transcript.countswhat was cited, and the search, open and turn counts
validatedOutputthe schema-checked answer: outcome, confidenceBps, reasoning, publicReasoningTrace, citations, evidenceFor, evidenceAgainst, decisiveEvidence, counterEvidenceSummary, unsupportedClaims
rawResponsethe provider response verbatim, including id, model, system_fingerprint, usage and metrics
gatewaygatewayRequestId, devshardId, systemFingerprint
auditthe flat InferenceRunAudit record: model, request ids, token counts, latency, evidence root and every hash
sealthe revealed key material: sealedBlobId, algorithm, ivHex, keyHex, coreHash, aad
verifythe formula for each hash, for example runHash: blake2b256(BCS(RunRecordV1))
attempts[]every provider attempt, retries, repairs and hedges included
StatusBodyMeaning
200the proof body
400validation_errormissing claim id or run id
404run_not_foundno such run on that claim
503engine_not_wired
500internal_error

GET /api/fact-checks/queue/{id}

One queued submission. Sent with Cache-Control: no-store.

curl -s https://app.openverdict.info/api/fact-checks/queue/<queueId>
{
  "queueId": "…",
  "status": "QUEUED",
  "statement": "…",
  "createdAt": "2026-09-04T04:00:00.000Z",
  "expiresAt": "2026-09-04T10:00:00.000Z",
  "weather": { "clear": false, "stale": false, "families": [] }
}

status is QUEUED, LAUNCHED, EXPIRED or CANCELLED. A launched item carries claimId; a failed launch carries launchError.

StatusBody
200QueuedFactCheck
404not_found
503engine_not_wired
500internal_error

GET /api/agents

The juror seat directory.

curl -s https://app.openverdict.info/api/agents
{
  "agents": [
    {
      "agentProfileId": "0x81a73726...",
      "owner": "0x6573776e...",
      "modelId": "MiniMaxAI/MiniMax-M2.7",
      "role": "SKEPTIC",
      "manifestHash": "0xdd897be7...",
      "active": true,
      "reputation": {},
      "backing": { "kind": "WALLET", "label": "sui-wallet-stake" },
      "trackRecord": { "seatsServed": 0, "committed": 0, "revealed": 0, "agreedWithCertificate": 0 },
      "staker": "0x9cd8dcd0...",
      "stakeMist": "100000000",
      "earnedMist": "6650000"
    }
  ]
}

staker is the account that posted the seat's bond, absent on seats the operator opened. stakeMist is that bond in MIST as a decimal string. earnedMist is lifetime jury reward tickets for the seat, whether withdrawn or not, never a live wallet balance. The backing object is the engine's record of how the seat was opened (ZKLOGIN, WALLET, ALLOWLIST or UNKNOWN): the field name is historical, and the value describes the staking path, not any identity claim.

StatusBody
200{ agents: AgentDirectoryEntry[] }
503engine_not_wired
500internal_error

GET /api/agents/{id}/manifest

The published manifest document for one seat: the pinned prompt, tool policy and evidence policy that its runs are hashed against.

curl -s https://app.openverdict.info/api/agents/0x044ef4ad.../manifest
{
  "version": "5",
  "network": "testnet",
  "backingKind": "TESTNET_DEMO_ALLOWLIST",
  "humanBackingHash": "0x6136b57b...",
  "humanVerificationProvider": "testnet-demo-allowlist",
  "operationalOwner": "0x6573776e...",
  "role": "SKEPTIC",
  "modelId": "MiniMaxAI/MiniMax-M2.7",
  "providerId": "gonkarouter",
  "promptSpec": { "version": "4", "providerId": "gonkarouter", "systemPrompt": "… trimmed …" },
  "promptHash": "0x…",
  "toolPolicy": { "…": "trimmed" },
  "toolPolicyHash": "0x…",
  "evidencePolicyId": "…",
  "evidencePolicyHash": "0x…"
}

humanBackingHash is the staker hash and backingKind names the staking path; both field names are historical and neither asserts anything about a person. operationalOwner is the engine signing key that runs the seat.

StatusBody
200AgentManifestDocument
400validation_error
404manifest_not_found
503engine_not_wired
500internal_error

GET /api/gateway-receipts/{requestId}

A thin proxy onto GonkaRouter's public receipts lookup (https://api.gonkarouter.io/v1/receipts/), which has no CORS headers so the browser cannot call it directly. Metadata only, no auth, no content. The requestId must match req-<digits and dashes>. A receipt for a finished request never changes, so successful lookups are cached in process and served Cache-Control: public, max-age=31536000, immutable.

curl -s https://app.openverdict.info/api/gateway-receipts/req-1788405572969008592-322552
{
  "x_request_id": "req-1788405572969008592-322552",
  "x_devshard_id": "70083",
  "model": "deepseek-ai/DeepSeek-V4-Flash-0731",
  "created_at": "2026-09-03T03:19:51Z",
  "outcome": "success",
  "status_code": 200,
  "stream": false,
  "total_tokens": 9029,
  "ttft_ms": 18315,
  "duration_ms": 18315
}
StatusBodyMeaning
200the receipt, verbatim from the gateway
400validation_errorthe id does not look like req-...
404receipt_not_foundthe gateway has no record of that id
429gateway_rate_limitedthe gateway rate limited the lookup
502gateway_error (with the upstream status) or gateway_unreachablethe gateway failed or timed out after 15 seconds

Writing routes

POST /api/extract-claim

Extract up to three checkable factual claims from a page or a pasted paragraph, using one GonkaRouter model. Guards: public writes flag, rate limit. Note the uppercase error codes on this route.

Body: exactly one of url or text, and nothing else (the schema is strict).

FieldLimits
url1 to 2048 characters, http: or https:; fetched through the SSRF-guarded evidence retriever
text40 to 20000 characters; never fetched
curl -s -X POST https://app.openverdict.info/api/extract-claim \
  -H 'content-type: application/json' \
  -d '{"url":"https://en.wikipedia.org/wiki/Eiffel_Tower"}'
{
  "claims": [
    { "claim": "The Eiffel Tower was completed in 1889.", "reason": "A dated, falsifiable construction fact.", "quote": "… trimmed …" }
  ],
  "language": "en",
  "claim": "The Eiffel Tower was completed in 1889.",
  "sourceUrl": "https://en.wikipedia.org/wiki/Eiffel_Tower",
  "modelId": "deepseek-ai/DeepSeek-V4-Flash-0731",
  "gonkaRequestId": "devshard-70083-2",
  "gatewayRequestId": "req-…"
}

claims[] is in source order, at most three, each claim at most 1000 characters with the quote at most 300 and the reason at most 2000. The top-level claim repeats the first one for older clients. sourceUrl appears only on the URL path and is the final URL after redirects. The model receives only a bounded, inert excerpt of the page (at most 12000 characters) and is told never to follow instructions inside it. The handler retries once with a prompt-based JSON fallback if the provider rejects the response format, and once more with a repair turn if the reply does not parse.

StatusCodeMeaning
200one to three claims
400INVALID_URLbody is not exactly one valid url or one text of 40 to 20000 characters
403writes_disabledpublic writes are off
404NO_CLAIM_FOUNDthe source held no checkable factual claim, or the model failed or replied unparseably
429rate_limitedover the limit
502FETCH_FAILEDthe source page could not be fetched safely
503ENGINE_NOT_WIREDdeployment not wired
500INTERNAL_ERRORthe extraction runtime could not start

POST /api/fact-checks

Submit a claim for direct review. Guards: public writes flag, rate limit.

FieldRequiredLimits
claimyesstring, 5 to 1000 characters after trimming
textnostring, up to 20000 characters
urlsnoarray of at most 5 strings, each https: and at most 2048 characters
resolutionCriterianostring, up to 2000 characters
curl -s -X POST https://app.openverdict.info/api/fact-checks \
  -H 'content-type: application/json' \
  -d '{"claim":"The first Bitcoin halving happened in November 2012."}'

Clear weather returns 200 and the claim is live on Sui:

{ "claimId": "0x2732…" }

Weather that is not clear returns 202 and the submission is queued:

{ "queued": true, "queueId": "…", "weather": { "clear": false, "stale": false, "families": [] } }

A queued submission launches on the first clear probe, one launch every ten minutes, and expires after six hours. Unknown weather never queues.

StatusCodeMeaning
200launched, claimId returned
202queued, queueId and the current weather returned
400validation_errorthe message names the field and its bound
403writes_disabled
429rate_limited
503engine_not_wired
500internal_error

POST /api/claims/{id}/runs/{runId}/reexecute

Resend one revealed juror's exact recorded conversation to the same model and compare the answer. This costs a real model call, so it sits behind the public writes flag and the rate limit. It grants the caller no signer and writes nothing on chain.

curl -s -X POST https://app.openverdict.info/api/claims/0x2732.../runs/0x36173f41.../reexecute
{
  "requestedAt": "2026-09-04T04:00:00.000Z",
  "completedAt": "2026-09-04T04:00:18.000Z",
  "latencyMs": 18000,
  "gatewayRequestId": "req-…",
  "devshardId": "70083",
  "systemFingerprint": "vllm-0.25.1-tp2-8aac2e07",
  "servedModel": "deepseek-ai/DeepSeek-V4-Flash-0731",
  "outputHash": "0x…",
  "outcome": "NO",
  "confidenceBps": 9500,
  "matches": { "outcome": true, "outputHash": false, "servedModel": true },
  "rawContent": "… the model's reply …"
}

matches compares the fresh answer against the recorded one. A differing outputHash is normal: models are not deterministic, so this is corroboration, never proof.

StatusCodeMeaning
200the re-execution result
400validation_errormissing claim id or run id
403writes_disabled
404run_not_found
409run_not_revealedthe run is still sealed
429rate_limited
502provider_errorthe model provider failed; the message is truncated to 500 characters and the API key redacted
503engine_not_wired
500internal_error

POST /api/agents/stake/prepare

Step one of staking on a juror seat. The engine validates the model, assigns the seat's debate role when the caller names none, allocates a free operational signing slot, writes the seat's manifest document to Walrus and returns the register_staked_agent arguments. Nothing is on chain yet, and an abandoned reservation expires and frees its slot again. Guards: public writes flag, rate limit.

FieldLimits
addressthe staker's Sui address, 1 to 66 characters
modelId1 to 128 characters, from the release manifest catalog
roleoptional, 1 to 32 characters, one of SKEPTIC, SOURCE_AUTHENTICITY, INVESTIGATOR

Nobody has to pick a role: research is identical for every seat, and the role only sets a juror's instructions in a round-two debate. With role omitted (what the stake card sends) the engine takes the least represented role among the active seats that run the same model, breaking a tie in the order INVESTIGATOR, SKEPTIC, SOURCE_AUTHENTICITY, and skipping any role no committee could seat. The role it used comes back as role, and it is what the manifest, the profile id hash and args.roleHash carry. A role that is named is used as it is, and one outside the three is rejected.

curl -s -X POST https://app.openverdict.info/api/agents/stake/prepare \
  -H 'content-type: application/json' \
  -d '{"address":"0x9cd8…","modelId":"MiniMaxAI/MiniMax-M2.7"}'
{
  "reservationId": "…",
  "expiresAt": "2026-09-04T04:15:00.000Z",
  "role": "INVESTIGATOR",
  "target": { "packageId": "0x1f7b684d…", "registryObjectId": "0x4020f3cb…", "clockObjectId": "0x6" },
  "args": {
    "manifestHash": "0x…",
    "manifestBlobId": "…",
    "modelHash": "0x…",
    "roleHash": "0x…",
    "stakerHash": "0x…",
    "operationalOwner": "0x…"
  },
  "minStakeMist": "100000000"
}

args are in the order the entry function takes them. role is the seat's debate role, named or assigned. stakerHash is blake2b-256 of the staker address. minStakeMist is 0.1 SUI.

StatusCodeMeaning
200the reservation
400validation_errora field is missing, too long, or the model or a named role is unknown
403writes_disabled
409slots_exhaustedevery operational signing slot is taken
429rate_limited
503engine_not_wired
500internal_error

POST /api/agents/stake/confirm

Step two. The engine reads the staker's settled transaction, checks it against the reservation, binds the seat's signing slot and tops it up with gas. Replaying a confirmed reservation returns the stored result rather than writing twice. Guards: public writes flag, rate limit.

FieldLimits
reservationId1 to 64 characters, from prepare
digest1 to 64 characters, the settled transaction digest
curl -s -X POST https://app.openverdict.info/api/agents/stake/confirm \
  -H 'content-type: application/json' \
  -d '{"reservationId":"…","digest":"…"}'
{
  "agentProfileId": "0x81a73726…",
  "staker": "0x9cd8dcd0…",
  "stakeMist": "100000000",
  "digest": "…",
  "backingKind": "WALLET_STAKED",
  "operationalOwner": "0x…",
  "gasFloat": "funded"
}

gasFloat is funded, skipped or failed; funding the seat's signing key never fails the confirmation.

StatusCodeMeaning
200the seat is recorded
400validation_errorthe transaction does not match the reservation
403writes_disabled
404reservation_not_foundunknown or expired reservation
429rate_limited
502chain_read_failedthe transaction could not be read from the chain
503engine_not_wired
500internal_error

POST /api/agents/register

The older signed-message path: the staker signs a canonical message and the operator posts the bond. A signature is not money, so this route is off by default and answers 403 unless OPENVERDICT_FREE_SEATS=enabled. Real seats come from prepare plus confirm above. Guards: public writes flag, free seats flag, rate limit.

FieldLimits
address (or zkLoginAddress for older clients; address wins)up to 66 characters
signaturebase64 personal-message signature, up to 16384 characters
modelIdup to 128 characters
roleoptional, up to 32 characters, one of the three debate roles
curl -s -X POST https://app.openverdict.info/api/agents/register \
  -H 'content-type: application/json' \
  -d '{"address":"0x…","signature":"…","modelId":"…"}'
{ "agentProfileId": "0x…", "humanBackingHash": "0x…", "backingKind": "WALLET_STAKED", "digest": "…", "role": "INVESTIGATOR" }

role is optional here exactly as on prepare: omit it and the engine assigns the least represented role on that model, then returns the role it recorded. humanBackingHash is the staker hash: a historical field name, not an identity claim. Any Sui wallet signature is accepted, zkLogin included.

StatusCodeMeaning
201the seat is registered
400validation_errora field is missing or too long, or the engine rejected the request
403writes_disabled or free_seats_disabled
409slots_exhausted
429rate_limited
503engine_not_wired or zklogin_verification_unavailablesignature verification is temporarily unavailable
500internal_error

POST /api/sponsor

Ask Shinami's Gas Station to attach gas and sign one allowlisted transaction. The browser builds the TransactionKind, this route allowlists it, and the user's wallet then signs the bytes Shinami returned, so the user still approves the full transaction. The access key never leaves the server. Guards: public writes flag, rate limit.

FieldLimits
sendera valid Sui address
transactionKindbase64 TransactionKind, 1 to 8192 characters

The allowlist is positive, not a blocklist: at most eight commands, no Move call except demo_binary_pool::enter and agent_registry::register_staked_agent in the deployed package (plus the 0x2::coin helpers the SDK emits to assemble the stake), no reference to the gas coin, no withdrawal naming the sponsor instead of the sender. The gas budget is capped server-side at 50,000,000 MIST, and the package id comes from the engine's own manifest, never from the request.

{ "txBytes": "…", "sponsorSignature": "…", "txDigest": "…", "expireAtTime": 1788500000 }
StatusCodeMeaning
200gas attached and signed
400sponsor_rejectedthe body or the transaction kind failed the allowlist; the message says why
403writes_disabled
429rate_limited
502sponsor_failedthe gas station returned an error
503sponsor_unavailable or engine_not_wiredno SHINAMI_GAS_ACCESS_KEY configured
500internal_error

POST /api/evidence

Submit an evidence artifact or source URL against a claim. Guards: public writes flag, rate limit. This handler is a stub in the current build: it validates the body, touches the engine and returns 201, but persists nothing. Evidence in production is gathered by the engine's own retriever.

FieldRequired
claimIdyes, a string
url or textat least one of the two
StatusCode
201{ "success": true }
400validation_error
403writes_disabled
429rate_limited
503engine_not_wired
500internal_error

POST /api/claims (operator only)

Create a claim directly on chain, bypassing the fact-check front door. The engine signs with the operator key, so this route needs Authorization: Bearer <OPENVERDICT_OPERATOR_TOKEN>.

FieldLimits
statementnon-empty, at most 2000 characters
resolutionCriterianon-empty, at most 4000 characters
mode1 DIRECT_REVIEW or 2 OPTIMISTIC_SETTLEMENT
deadlinesall eight keys, each a positive safe integer in milliseconds: evidenceCutoffMs, proposalDeadlineMs, challengeDeadlineMs, firstCommitDeadlineMs, firstRevealDeadlineMs, discussionDeadlineMs, secondCommitDeadlineMs, secondRevealDeadlineMs
committeeBudget, evidenceBudgetdecimal strings of 1 to 18 digits, in MIST
StatusCodeMeaning
201{ claimId, digest }
400validation_errorthe message names the offending field
403forbiddenmissing, weak or wrong bearer token
503engine_not_wired
500internal_error

Claim, run, seat, agent profile, certificate and evidence ids are 66-character lowercase hex. Transaction digests are base58; Walrus blob ids are base64url.

WhatURL pattern
Claim pagehttps://app.openverdict.info/claims/<claimId>
Report pagehttps://app.openverdict.info/claims/<claimId>/report
Queue pagehttps://app.openverdict.info/fact-check/queue/<queueId>
Evidence pagehttps://app.openverdict.info/evidence/<evidenceId>
Agent pagehttps://app.openverdict.info/agents/<agentProfileId>
Board, submit, verify, agents, risk/claims, /fact-check, /verify, /agents, /risk
Sui objecthttps://suiscan.xyz/testnet/object/<objectId>
Sui transactionhttps://suiscan.xyz/testnet/tx/<digest>
Walrus blobhttps://aggregator.walrus-testnet.walrus.space/v1/blobs/<blobId>
GonkaRouter receipthttps://api.gonkarouter.io/v1/receipts/<gatewayRequestId>

There is no page at /claims/<id>/runs/<runId>: run detail is a panel on the claim page. The auditor and the CLI accept that shape as an input link and audit the whole claim while highlighting that run.

On-chain claim states, as returned in state:

ValueNameValueName
0CREATED7COMMIT_2
1PROPOSED8REVEAL_2
2CHALLENGED9FINALIZED_UNCHALLENGED
3REVIEW_REQUESTED10FINALIZED_REVIEWED
4COMMIT_111UNRESOLVED
5REVEAL_112CANCELLED
6DISCUSSION

States 1, 2 and 9 belong to the optimistic (bonded) pathway. Vote outcome codes are 1 YES, 2 NO, 3 UNSURE; claim results add 4 UNRESOLVED. Claim modes are 1 DIRECT_REVIEW and 2 OPTIMISTIC_SETTLEMENT.

CLI exit codes and HTTP statuses

pnpm ov <command> maps HTTP responses onto these exit codes:

ExitMeaningHTTP status behind it
0success; for watch, the claim finalized200, 201, 202
1audit only: at least one check FAILedany (the failure is in the recomputation, not the transport)
2input or request error, one error: ... line on stderr400, 404, and any transport failure
3watch only: the attempt voided or the verification gave up200 (the state came from the claim record)
4watch only: stopped before the end, --for reached200
5submit and extract only: rate limited or public writes disabled429, 403

ov audit and pnpm audit:claim share exit codes 0, 1 and 2: 0 when every check passed, was UNAVAILABLE or was SKIPPED, 1 on any FAIL, 2 on an input or fetch error. A source outage marks a check UNAVAILABLE with a manual URL, and never FAIL.