Skip to Content
Reference/v1 API

The /v1 API

Contract v1, frozen 2026-07-25. Default base URL https://arkiv-surex-api.vercel.app — override with SUREX_API_URL.

Frozen early and on purpose: with the contract agreed, the gate, the API, the registry site and the reviewer can each be built and demoed standalone against the same shapes. Additive changes only — anything that would break a shipped gate goes to /v2.

The tables on this page are rendered at build time from packages/core/src/contract.mjs, the same module the gate and the API import. They cannot drift from the contract; if it changes, this page changes with it or the build fails.

Routes

RouteWhat it answers
GET /v1/verdict?fp=sxf1_c6b016134fddd156bb76fce9c9e2cc8d697cbd35e311a4de50af6dbf102b761bThe hot path. One head per fingerprint; a miss is a 200 carrying the unknown head, never a bodyless 404.
POST /v1/verdicts/batchSessionStart prefetch. POST { fps: [...] }, one round trip for a whole config. Max 100.
GET /v1/entry/sxf1_c6b016134fddd156bb76fce9c9e2cc8d697cbd35e311a4de50af6dbf102b761bThe entry and its history.
GET /v1/source/0x%E2%80%A6entityKeyThe source record behind a verdict. Add ?evidence=1 to have the API fetch the blob and report which checks ran.
GET /v1/review/0x%E2%80%A6entityKeyThe review record behind a verdict. Same ?evidence=1.
POST /v1/submissionsSubmit a server. The World ID gate is live; the ingest pipeline behind it is not — a valid proof gets 501 and does not spend the nullifier.
POST /v1/disputesContest a verdict. Human path via World ID, agent path via an AgentKit-signed header.
GET /v1/flaggedThe public feed of everything that blocks — flagged AND disputed.
GET /v1/registry?limit=20&state=flaggedBrowse every state. ?state= and ?limit=.
GET /v1/statsRegistry counts, straight off the chain, plus a named list of the numbers that are not measured yet.

The API is a read path only. There is no private key in that process, no wallet client and no route that writes to Arkiv — verdicts are written by the worker’s wallet in a different process, and the two never share one. A compromise of the read API cannot rewrite the registry, and that separation is a property to preserve rather than an implementation detail.

GET /v1/verdict?fp=… — the hot path

Returns the head shape itself as the body.

{ "fingerprint": "sxf1_…", "state": "flagged", // clean | flagged | disputed | unreviewable | stale | unknown "severity": 4, // 0–4; the gate blocks at 3 "tier": "C", // A | B | C | MISMATCH — an INDEPENDENT axis "name": "@acme/mcp-tools@2.1.0", "enforceAfter": 1785249090468, // selects block WORDING, not whether we block "reviewedCommit": "…", "reviewedAt": "2026-07-25T14:31:30.468Z", "modelId": "qwen3-coder-next:surex32k", "promptVersion": "rv-1", "integrity": "sha512-…", // npm dist.integrity recorded at review time (Tier A) "capabilities": { "network": { "present": true, "evidence": ["src/x.mjs:84 fetch()"], "evidenceTotal": 1 }, "…": {} }, "topFinding": { "file": "…", "line": 55, "description": "…", "severity": 4, "category": "reviewer-injection", "detectedBy": "deterministic-scan" }, "disputeSummary": "…", "evidence": { "blobId": "…", "suiObjectId": "0x…", "registerTx": "…", "certifyTx": "…", "encodingType": "RS2", "contentSha256": "…" }, "arkivEntityKey": "0x…", "updatedAt": "…", "illustrative": true // present whenever the row is demo data. NEVER stripped }

A miss is a 200 carrying the unknown head, never a bodyless 404 — the gate has to make a decision from what it gets. But “we could not look” is a different fact from “we looked and found nothing”: if the registry is unreachable and nothing is in grace, the answer is 503 upstream_unavailable, never a synthesised unknown.

Cache headers match the contract exactly — max-age=900 on a hit, max-age=120 on a miss — because a TTL the server does not honour is a stale block waiting to happen. Responses carry X-SureX-Cache: hit|miss|stale and X-SureX-Mode: mock|live.

A cached flagged or disputed head outlives its TTL when Arkiv is unreachable, served as stale. A cached clean does not: an expired non-blocking head with no reachable registry is a 503. A blip must never un-flag a server already known to be bad, and must never keep answering clean for one that can no longer be checked.

POST /v1/verdicts/batch — the SessionStart prefetch

// request { "fps": ["sxf1_…", "sxf1_…"] } // max 100 // response { "requested": 4, "heads": [ /* one per requested VALID fp, IN REQUEST ORDER, misses as the unknown head */ ], "invalid": [ { "fp": "garbage", "code": "bad_fingerprint" } ], "ttlMs": { "positive": 900000, "negative": 120000 } }

One Arkiv round trip for a whole config — the fingerprints are OR-ed into a single predicate, not looped. Malformed entries go to invalid rather than failing the whole prefetch: one bad row in a config should not cost the other nineteen their verdicts.

A caller may only cache a miss the registry actually answered for. partitionBatchResponse() returns answered and unanswered separately for exactly this reason. An early gate synthesised an unknown for every unanswered fingerprint and cached it, so a batch endpoint that returned nothing wrote eleven negative cache entries — and the hot path then served a flagged server as unknown for the whole negative TTL, with no lookup and no block.

GET /v1/registry · GET /v1/flagged

registry is what a browse page needs: every state, ?state= and ?limit=. flagged is the feed an org-level gateway mirrors, and it returns flagged and disputed — a dispute changes what a user is told, not whether the call is stopped, so a mirror that takes only flagged silently stops enforcing everything anyone has contested.

Both are sorted client-side. orderBy exists in the Arkiv SDK, is accepted silently, and does nothing — it is marked deprecated because server-side ordering is not supported by the network.

GET /v1/entry/:fp · /v1/source/:key · /v1/review/:key

History and evidence. Each record carries a links object built from what it actually recorded — anything missing is omitted rather than guessed:

linktarget
blob<walrus aggregator>/v1/blobs/<blobId>
suiObjecthttps://suiscan.xyz/testnet/object/<id>
registerTx, certifyTxhttps://suiscan.xyz/testnet/tx/<digest>
arkivEntityhttps://explorer.braga.hoodi.arkiv.network/entity/<key>

?evidence=1 additionally fetches the Walrus blob and reports which checks actually rancontent-sha256 can be passed while blob-id reports asserted when no encoder is available. Claiming a check that did not run is exactly the kind of thing this product exists to object to.

A direct key read re-applies the provenance check by hand, because getEntity has no creator filter of its own: an entity whose creator is not the SureX writer, or whose project or entity type does not match, is a 404.

GET /v1/stats

{ "registry": { "source": "arkiv", "chainId": 60138453102, "project": "surex-lisbon", "writerAddress": "0x…", "entries": 0, "verdictHeads": 0, "byState": { "clean": 0, "flagged": 0, "disputed": 0, "unreviewable": 0, "stale": 0, "unknown": 0 } }, "contract": { "version": "v1", "frozenAt": "2026-07-25" }, "mode": "live", "omitted": [ "hitRate — no lookups served by this process yet", "…" ] }

Numbers that are not real are omitted and named in omitted, never invented. Before the process has served a lookup there is no hitRate key at all — not a zero. Registry counts are real, from count() against the chain; the hit rate, when present, is real but narrow: lookups this process has served since it started, in memory, which on serverless means one warm instance rather than the fleet.

registry.byState and /v1/flagged can legitimately disagree. byState counts on-chain rows; flagged only lists heads that survive parseVerdictHead. A row written with a malformed fingerprint is counted but not served — because the gate makes an enforcement decision from a head, so a malformed one must never be passed through.

Errors

Always { "error": { "code", "message", … } }.

CodeMeaning
bad_fingerprintThe fp did not match sxf1_ followed by 64 lowercase hex characters.
not_foundNo such record.
rate_limitedToo many requests; carries Retry-After.
unauthenticatedIdentity did not check out — a missing or bad signature, a replayed nonce, a World ID proof that failed.
agent_not_human_backedAgentBook confirmed no registration for the signing wallet. This is the gate, and it is only returned on a confirmed on-chain zero.
upstream_unavailableWe could not look. Distinct from "we looked and found nothing" — including a throttled AgentBook read.
invalid_bodyThe request body did not carry what the route needs.
internalA fault on our side. Named separately so a caller deciding whether to retry knows which side broke.
not_implementedA route the contract defines and this deployment does not serve.

upstream_unavailable versus agent_not_human_backed is the distinction that took the longest to get right: lookupHuman() returns null for a throttled RPC exactly as it does for an unregistered agent, so only a confirmed on-chain zero may become a 403.

Cache and budget

SettingValueWhy
positiveTtlMs15 minHow long a head may be cached. The API sends the matching Cache-Control.
negativeTtlMs120 sA miss, and only when the registry actually said so.
flaggedGraceMs30 daysA cached flag keeps blocking with no network at all. A blip must not un-flag a server already known to be bad.
hookTimeoutSeconds10 sThe hook's own timeout. Exceeding it is a silent fail-open, so the budget below sits well inside it.
networkTimeoutMs1500 msThe hot-path lookup gives up here and proceeds.
batchNetworkTimeoutMs6000 msThe SessionStart prefetch, which nobody is waiting on.

CORS and auth

It is a public read API: SUREX_CORS_ORIGIN defaults to *, and no route on the read path requires authentication. The only authenticated surface is an admin control for reloading the review model on the machine that hosts it, mounted only when its slug is configured — described honestly in apps/api/README.md as a demo control and not a security boundary.

Last updated on