{"openapi":"3.1.0","info":{"title":"oculr","version":"1.0.0","description":"Agentic triage of onchain transactions: send a transaction hash, get back a structured explanation with risks, protocol identification, and address labels. 50+ EVM mainnets (Ethereum, Base, Arbitrum, BNB Chain, Tempo, and more), chain auto-detected. Fully compatible with MPP.\n\nSetup and the full payment contract: https://mpp.oculr.xyz/SKILL.md. Machine-readable tool schemas and measured pricing: https://mpp.oculr.xyz/tool-spec.json.","contact":{"email":"rob@oculr.xyz"},"x-guidance":"Explain any EVM transaction: POST a txHash to /explain/async and poll GET /result/{jobId} with an MPP-paying client (mppx), or stream POST /explain over SSE with tempo.session.manager().sse(). Chain is auto-detected across 50+ EVM mainnets. Payment is per-request MPP micropayment over a session channel — no API key, no signup. Read /SKILL.md first for setup and the payment contract."},"servers":[{"url":"https://mpp.oculr.xyz","description":"Production (MPP/x402 - canonical API host)"},{"url":"/","description":"Same-origin (local dev or self-hosted)"}],"x-service-info":{"categories":["blockchain","security","ai"],"docs":{"homepage":"https://www.oculr.xyz","apiReference":"https://mpp.oculr.xyz/docs","llms":"/llms.txt"}},"paths":{"/health":{"get":{"operationId":"health","summary":"Liveness check (free, no payment)","description":"Unmetered. Use it to check reachability without opening a payment channel. Carries no auth requirement — see the `mpp` security scheme, which names this endpoint as free.","security":[],"responses":{"200":{"description":"Service is up.","content":{"application/json":{"schema":{"type":"object","required":["status","version"],"properties":{"status":{"type":"string","enum":["ok"]},"version":{"type":"string","description":"API version; mirrors the X-Oculr-Version response header."},"commit":{"type":["string","null"],"description":"Git sha of the deployed build serving this request, or null when the platform does not expose it. Unlike `version` it changes on every deploy, so a caller can tell a new build from a stale one."}}}}}}}}},"/session/deposit":{"post":{"operationId":"openSessionDeposit","summary":"Open a payment channel up front (deposit probe)","description":"Returns a 402 session challenge whose only purpose is to let a client open its payment channel as an explicit up-front step, instead of implicitly during its first analysis. Answer the challenge with an mppx client and the channel opens with the deposit locked in escrow.\n\nPriced at $0.01 solely because MPP rejects a zero-amount session; you are not buying anything here and unspent deposit is returned when the channel closes. Open at the advertised `suggestedDeposit` of $16, and set your client's `maxDeposit` ceiling strictly HIGHER - $32 - so the channel can still grow by top-up between analyses: a channel opened exactly AT its ceiling can never top up (both the automatic and the explicit route throw), and the run that outgrows it dies after you have paid for the work already done. `maxDeposit` is a cap, not the amount escrowed - the channel opens at min(suggestedDeposit, maxDeposit). A channel below $2.00 is refused billable content later with `code: \"deposit_below_minimum\"`.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"description":"This endpoint takes no request body — send nothing, or an empty JSON object. The channel parameters (deposit size, currency) come from answering the 402 challenge, not from the body."}}}},"x-payment-info":{"offers":[{"amount":"10000","currency":"0x20C000000000000000000000b9537d11c60E8b50","description":"Oculr session deposit","intent":"session","method":"tempo"}],"protocols":[{"mpp":{"method":"tempo","intent":"session","currency":"0x20C000000000000000000000b9537d11c60E8b50"}}],"price":{"mode":"dynamic","currency":"USD","description":"Varies based on transaction complexity"}},"responses":{"200":{"description":"Channel open (or already open). The deposit is escrowed, not spent.","content":{"application/json":{"schema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean","enum":[true]}}}}}},"402":{"description":"The session challenge to answer in order to open the channel. This is the expected first response, not an error.","headers":{"WWW-Authenticate":{"$ref":"#/components/headers/PaymentChallenge"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/X402Challenge"}},"application/json":{"schema":{"$ref":"#/components/schemas/DepositBelowMinimum"}}}}}}},"/explain/run/{requestId}":{"get":{"operationId":"recoverRun","summary":"Recover a completed run whose transport dropped","description":"Replays the final frames of an analysis that finished after its SSE stream died. This is the documented recovery path for a dropped stream: the `error` frame of a broken stream carries `recover: \"GET /explain/run/<requestId>\"`, and `requestId` is also emitted on the stream before the result.\n\nNot a free-analysis hole and not a second charge: you pay exactly the balance still outstanding on that run (its full metered price minus what was already collected), so a run paid in full is returned for nothing and a half-paid run costs its other half. Idempotent — the shortfall is recomputed from the stored record on every call. Records live for 30 days.","parameters":[{"name":"requestId","in":"path","required":true,"description":"The run id from the stream's `requestId` field, or from the `recover` hint on its error frame.","schema":{"type":"string"}}],"x-payment-info":{"offers":[{"amount":"10000","currency":"0x20C000000000000000000000b9537d11c60E8b50","description":"Oculr analysis result (recovery)","intent":"session","method":"tempo"}],"protocols":[{"mpp":{"method":"tempo","intent":"session","currency":"0x20C000000000000000000000b9537d11c60E8b50"}}],"price":{"mode":"dynamic","currency":"USD","description":"Varies based on transaction complexity"}},"responses":{"200":{"description":"The stored final frames. Returned free when the run was already paid in full, otherwise after the outstanding balance is collected.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunRecovery"}}}},"402":{"description":"Payment required - the balance still outstanding on this run. RFC 9457 problem document; terms in the WWW-Authenticate header.","headers":{"WWW-Authenticate":{"$ref":"#/components/headers/PaymentChallenge"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/X402Challenge"}}}},"404":{"description":"No such run - unknown id, expired 30-day TTL, or the KV backend is inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunError"}}}},"409":{"description":"The run exists but stored nothing replayable - it errored, or its report was too large to store. Distinguished from 404 deliberately: we have the record, it just has no frames.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunError"}}}}}}},"/explain":{"post":{"operationId":"explainTransaction","summary":"Analyse an EVM transaction (chain auto-detected, metered SSE)","description":"Synchronous analysis over SSE with metered MPP payment. Requires Accept: text/event-stream via an mppx session client (tempo.session.manager().sse()) - vouchers are signed incrementally as cost accrues, and the final SSE event ({ type: \"result\", ... }) is the ExplanationResult. A plain JSON request returns 402 with code \"use_metered_sse\". Non-SSE clients should use /explain/async instead - it is metered to the same total price ($0.01 at submit, remainder charged when fetching the finished result).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExplainRequest"}}}},"x-payment-info":{"offers":[{"amount":"10000","currency":"0x20C000000000000000000000b9537d11c60E8b50","description":"Oculr transaction analysis","intent":"session","method":"tempo"}],"protocols":[{"mpp":{"method":"tempo","intent":"session","currency":"0x20C000000000000000000000b9537d11c60E8b50"}}],"price":{"mode":"dynamic","currency":"USD","description":"Varies based on transaction complexity"}},"responses":{"200":{"description":"Analysis complete. May be a *partial* result - check `confidence` (always \"low\" on partials) and whether `summary` starts with \"Partial result\" before trusting the body.","headers":{"Link":{"schema":{"type":"string"},"description":"RFC 5988 describedby links to /SKILL.md, /openapi.json, /tool-spec.json."}},"content":{"text/event-stream":{"schema":{"type":"string","description":"SSE stream. Each event's data is JSON with a `type` field (preflight_start, preflight_done, preflight_status, iteration, agent_text, tool_call, tool_result, skill_call, tokens, complete; plus heartbeat, sent when nothing else has been sent for 15s, so a silent model call is distinguishable from a dropped connection). The final event is the full ExplanationResult with type: \"result\" (or type: \"report\" carrying HTML when report=true); fatal failures arrive as an error frame carrying `code` (the ErrorBody enum) and `message`."}}}},"400":{"description":"Invalid request (bad txHash or body)"},"402":{"description":"Two different 402s, distinguished by Content-Type. (a) `application/problem+json` - the real MPP payment challenge; terms are in the WWW-Authenticate header, mppx session clients handle it automatically. (b) `application/json` with code \"use_metered_sse\" - a transport redirect, not a separate bill: the paid plain-JSON path is retired, so retry with Accept: text/event-stream via an mppx session client, or use /explain/async. On unauthenticated requests it too carries a WWW-Authenticate channel-open challenge (the same session the payable rails bill against); a credentialed retry gets it without the header - switch transport, do not keep paying.","headers":{"WWW-Authenticate":{"$ref":"#/components/headers/PaymentChallenge"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/X402Challenge"}},"application/json":{"schema":{"$ref":"#/components/schemas/MeteredSseRequired"}}}},"500":{"description":"Analysis failed (partial-result fallback was not possible - usually means chain detection itself crashed). Body carries `code: \"internal_error\"`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"502":{"description":"oculr could not pay one of its own upstream services; `code: \"upstream_payment_unavailable\"`. Do NOT retry as though your payment failed - yours is fine, ours is not.\n\nReachability caveat, stated because a generated client will otherwise branch on something it can never see: on the METERED SSE path this condition does NOT produce an HTTP 502. The status is already 200 by the time the analysis runs, and a mid-analysis upstream-payment failure degrades to a PARTIAL RESULT frame (`confidence: \"low\"`, `summary` starting \"Partial result\") - branch on those markers to detect this condition on the SSE rail. The stream's last-resort fatal frame `{ \"type\": \"error\", \"code\": \"internal_error\", \"message\": \"...\" }` today always carries `internal_error`; `upstream_payment_unavailable` is part of the frame's `code` enum (the ErrorBody enum) but is currently never emitted on it. A real HTTP 502 here is only reachable on the unmetered JSON path (local dev or a self-hosted deployment). On /explain/async the same condition lands on the job as `errorCode`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}},"/explain/async":{"post":{"operationId":"explainTransactionAsync","summary":"Start an async transaction analysis (metered, same total price as /explain)","description":"Returns a jobId immediately; poll GET /result/{jobId} for status and result. Metered MPP payment collected across the job lifecycle: submitting charges one $0.01 unit and every poll collects whatever the analysis has accrued since the last one, with the terminal fetch charging the true-up - the cumulative total is identical to the metered SSE /explain price for the same analysis. You MUST keep polling with a paying client: if nothing collects for 90 seconds the analysis is aborted and the job is left with a partial result. Use an mppx client for every call; each returns a payable 402 challenge when more voucher headroom is needed.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExplainRequest"}}}},"x-payment-info":{"offers":[{"amount":"10000","currency":"0x20C000000000000000000000b9537d11c60E8b50","description":"Oculr async transaction analysis","intent":"session","method":"tempo"}],"protocols":[{"mpp":{"method":"tempo","intent":"session","currency":"0x20C000000000000000000000b9537d11c60E8b50"}}],"price":{"mode":"dynamic","currency":"USD","description":"Varies based on transaction complexity"}},"responses":{"202":{"description":"Job accepted","content":{"application/json":{"schema":{"type":"object","properties":{"jobId":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["pending"]}},"required":["jobId","status"]}}}},"400":{"description":"Invalid request"},"402":{"description":"Payment required - MPP session challenge for the $0.01 submit unit. RFC 9457 problem document; the terms are in the WWW-Authenticate header (mppx clients handle it automatically).","headers":{"WWW-Authenticate":{"$ref":"#/components/headers/PaymentChallenge"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/X402Challenge"}}}}}}},"/result/{jobId}":{"get":{"operationId":"getJobResult","summary":"Poll for async job result","description":"Results are retained for **1 hour** after job creation. After expiry the endpoint returns 404. Poll once every 5-15s with a paying (mppx) client: each poll collects what the analysis has accrued since the previous one, and the first fetch after the job finishes charges the fee-bearing true-up, so the cumulative charge (submit unit + polls + true-up) equals the metered SSE /explain price exactly. A poll with nothing yet to collect is free. If nothing collects for 90 seconds the analysis is aborted and the job keeps whatever partial result it had. Subsequent fetches of an already-paid finished result are free.","parameters":[{"name":"jobId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"x-payment-info":{"offers":[{"amount":"10000","currency":"0x20C000000000000000000000b9537d11c60E8b50","description":"Oculr analysis result","intent":"session","method":"tempo"}],"protocols":[{"mpp":{"method":"tempo","intent":"session","currency":"0x20C000000000000000000000b9537d11c60E8b50"}}],"price":{"mode":"dynamic","currency":"USD","description":"Varies based on transaction complexity"}},"responses":{"200":{"description":"Job status. When status=complete the result follows the same partial-result contract as /explain - check `result.confidence` and the \"Partial result\" summary header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobResult"}}}},"402":{"description":"Payment required - accrued metered cost is owed before this poll is answered. RFC 9457 problem document; the terms are in the WWW-Authenticate header (mppx clients handle it automatically).","headers":{"WWW-Authenticate":{"$ref":"#/components/headers/PaymentChallenge"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/X402Challenge"}}}},"404":{"description":"Job not found - either an invalid ID or the result has expired (1h TTL)"}}}}},"components":{"schemas":{"ExplainRequest":{"type":"object","required":["txHash"],"properties":{"txHash":{"type":"string","pattern":"^0x[0-9a-fA-F]{64}$","description":"EVM transaction hash. Chain is auto-detected from the hash across all supported mainnets."},"chainId":{"type":"integer","minimum":1,"description":"Optional EIP-155 chainId. When provided, skips multi-chain auto-detection."},"context":{"type":"string","maxLength":2048,"description":"Optional caller context - passed verbatim to the analysis agent as an analyst hint. Capped at 2048 characters to limit prompt-injection surface; longer values are rejected with 400 \"context too long (N chars); max 2048\". Use it to steer an ambiguous case - e.g. \"check if this is a reentrancy exploit\" or \"this is a known MEV bot, classify the strategy\". It is a hint, not a mode switch: `findings[]` populates automatically whenever the transaction is exploit-shaped, and no context string is required to obtain it."},"report":{"type":"boolean","default":false,"description":"If true, the analyst also renders a self-contained HTML report. NOT uniformly additive - delivery depends on the transport. On the metered SSE /explain path the report REPLACES the JSON: the stream ends with a single `{ type: \"report\", html }` event INSTEAD OF `{ type: \"result\", ... }`, so set this only if HTML is all you need. On the async path it IS additive: GET /result/{jobId} returns `result` (the JSON ExplanationResult) and `html` side by side."},"model":{"type":"string","enum":["claude-opus-5","claude-opus-4-8","claude-opus-4-7","claude-sonnet-4-6","claude-haiku-4-5-20251001"],"default":"claude-opus-5","description":"Optional model override; anything outside the enum is rejected with 400. The enum is generated from the server-side allowlist (ALLOWED_MODELS in src/types.ts) that request validation actually checks, so it cannot drift from what /explain accepts. Omit the field to get `default` above - that is this deployment's effective default (env ANALYSIS_MODEL), resolved when this document was served, and the model the published price bands are anchored to. Sonnet/Haiku cost less; Opus is the default for accuracy."}}},"ExplanationResult":{"type":"object","required":["txHash","status","summary","steps","risks","confidence","addresses","contracts","costs","protocol","txType","usdValue"],"properties":{"txHash":{"type":"string"},"chain":{"type":"string","description":"Detected chain slug (e.g. \"ethereum-mainnet\", \"base-mainnet\")."},"chainName":{"type":"string","description":"Human-readable chain name (e.g. \"Ethereum\", \"Base\")."},"explorerBase":{"type":"string","description":"Block-explorer origin (no trailing slash). UI builds links via `${explorerBase}/tx/<hash>`."},"status":{"type":"string","enum":["success","reverted"],"description":"On-chain receipt status. NOT a signal of analysis success - see confidence."},"analysisModel":{"type":"string","description":"Analysis engine label (\"oculr-analyst\")."},"summary":{"type":"string","description":"One-sentence plain-English summary (markdown allowed on partial results). Starts with \"**Partial result\" when upstream services failed mid-pipeline."},"steps":{"type":"array","items":{"type":"string"},"description":"Ordered steps of what happened"},"risks":{"type":"array","items":{"type":"string"},"description":"Risk flags and anomalies detected. On partial results, includes a \"partial-synthesis:\" line naming the failure."},"protocol":{"type":"string","nullable":true,"description":"Primary protocol slug (e.g. \"uniswap_v3\")"},"txType":{"type":"string","nullable":true,"enum":["swap","transfer","exploit","liquidation","bridge","deployment","mev","governance","routine_infra","approval","stake","other"]},"confidence":{"type":"string","enum":["high","medium","low"],"description":"Always \"low\" on partial results."},"usdValue":{"type":"number","nullable":true,"description":"USD value of the primary action"},"mermaidDiagram":{"type":"string","description":"Mermaid sequenceDiagram source for the call graph."},"balanceChanges":{"type":"array","description":"Server-computed per-address balance flow with USD values.","items":{"type":"object"}},"tokenTransfers":{"type":"array","description":"ERC-20/721/1155 transfers extracted from the trace. Raw amounts - divide by 10^decimals for display.","items":{"type":"object"}},"prettyTrace":{"type":"array","items":{"type":"object"},"description":"Pretty-printed call graph (decoded function selectors + args)."},"rawTrace":{"type":"object","nullable":true,"description":"Raw debug_traceTransaction call frame (top-level). Useful for hand-investigation."},"traceAnnotations":{"type":"object","additionalProperties":{"type":"string"},"description":"AI-generated comments keyed by trace node index."},"txMeta":{"type":"object","description":"Header-level transaction metadata.","properties":{"from":{"type":"string"},"to":{"type":"string","nullable":true},"valueWei":{"type":"string"},"blockNumber":{"type":"integer"},"blockTimestamp":{"type":"integer","nullable":true},"transactionIndex":{"type":"integer","nullable":true},"gasUsed":{"type":"integer"},"gasPrice":{"type":"string","nullable":true}}},"findings":{"type":"array","description":"Structured exploit triage, one entry per distinct vulnerability. Populated automatically when the analysis classifies the transaction as exploit-shaped (txType=\"exploit\"); absent or empty otherwise, and no request field is needed to opt in. The `broken_invariant` field is the highest-value output: the property the protocol assumed and the attacker violated.","items":{"type":"object","required":["broken_invariant","category","severity","confidence","victim","attacker","evidence"],"properties":{"broken_invariant":{"type":"string","description":"One-sentence statement of the violated property - the headline."},"category":{"type":"string","description":"High-level vulnerability bucket. Prefer one of the six canonical values; any kebab-case slug is legal when none of them fits, so do not validate strictly against the list.","anyOf":[{"enum":["protocol-flaw","access-control","private-key-compromise","phishing","rugpull","other"]},{"type":"string"}]},"subcategory":{"type":"string","description":"Free-form kebab-case slug naming the specific mechanism."},"severity":{"type":"string","enum":["informational","low","medium","high","critical"]},"confidence":{"type":"string","enum":["low","medium","high"]},"victim":{"type":"array","items":{"type":"object"}},"attacker":{"type":"array","items":{"type":"object"}},"evidence":{"type":"array","items":{"type":"string"}},"missing_data_to_confirm":{"type":"array","items":{"type":"string"}}}}},"nonFindings":{"type":"array","description":"Things the analysis considered and ruled out: real observations that are not the cause of this transaction (an address-poisoning plant seen only in the sender's history, a setup leg whose value-loss step is a later transaction, a cashout-shaped withdrawal, or the one-sentence rationale for a benign verdict). Absent when there is nothing to report. Rendered as \"Considered and ruled out\".","items":{"type":"string"}},"addresses":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string"},"label":{"type":"string"},"role":{"type":"string"}},"required":["address","label","role"]}},"contracts":{"type":"array","description":"Every contract touched in the trace, with a human-readable name and a one-sentence description of its role in this transaction. Always present (empty array when nothing was resolved) - it was missing from this schema entirely while SKILL.md, /tool-spec.json and /llms-full.txt all promised it, so schema-driven clients silently dropped it.","items":{"type":"object","required":["address","name","description"],"properties":{"address":{"type":"string"},"name":{"type":"string","description":"Human-readable contract name, e.g. \"Multicall3\", \"TimelockController\"."},"description":{"type":"string","description":"One sentence: what this contract is and its role in this tx."},"balanceChange":{"type":"string","nullable":true,"description":"Net change, e.g. \"-1.1 cbBTC\", \"+0.3 ETH\"."}}}},"htmlReport":{"type":"boolean","description":"True when an HTML report was rendered during this analysis. This is a FLAG, not the report: on the async path the HTML itself is `html` on the job payload (a sibling of `result`, not a field inside it); on the metered SSE path it arrives as a `{ type: \"report\", html }` frame that REPLACES the result frame."},"skillsUsed":{"type":"array","items":{"type":"string"},"description":"Analyst playbooks engaged this run, deduped in first-seen order. Public names only."},"prettyTraceMeta":{"type":"object","description":"Present only when `prettyTrace` was compressed for transport. Absence means the trace is complete.","properties":{"originalCount":{"type":"integer","description":"Nodes before compression."},"keptCount":{"type":"integer","description":"Nodes actually present in `prettyTrace`."},"collapsedRuns":{"type":"array","description":"Elided repeating runs, keyed by the trailing node index they follow.","items":{"type":"object","properties":{"afterIndex":{"type":"integer"},"period":{"type":"integer"},"reps":{"type":"integer"},"omittedCount":{"type":"integer"}}}}}},"costs":{"type":"object","description":"Cost breakdown by category: llms, dataCollection, codeExecution, other, and totalUsd (their sum). ALWAYS present - zeroes rather than null, even on partial results and zero-spend fast paths - so callers need no null branch. In production these figures are what you were CHARGED; oculr's internal cost accounting is not exposed.","required":["totalUsd"],"properties":{"llms":{"type":"number"},"dataCollection":{"type":"number"},"codeExecution":{"type":"number"},"other":{"type":"number"},"totalUsd":{"type":"number"}}},"toolCalls":{"type":"array","description":"Each tool invoked during analysis with its duration","items":{"type":"object","properties":{"tool":{"type":"string","description":"Tool name"},"durationMs":{"type":"number","description":"Execution time in milliseconds"},"ok":{"type":"boolean","description":"Whether the tool call succeeded"}},"required":["tool","durationMs","ok"]}}}},"JobResult":{"type":"object","required":["jobId","status"],"properties":{"jobId":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["pending","running","complete","error"]},"result":{"$ref":"#/components/schemas/ExplanationResult","description":"Present when status=complete. Same partial-result contract as /explain - check result.confidence and the \"Partial result\" summary header."},"html":{"type":"string","description":"HTML report - present when status=complete and report=true"},"error":{"type":"string","description":"Error message when status=error"},"errorCode":{"type":"string","description":"Stable machine-readable error code mirroring the sync path (e.g. \"upstream_payment_unavailable\"). Branch on this rather than parsing `error`."}}},"X402Challenge":{"type":"object","description":"MPP payment-required challenge. Returned with HTTP 402 and `Content-Type: application/problem+json` from /explain, /explain/async and /result/{jobId} before payment.\n\nThis body is an RFC 9457 problem document and carries NO payment terms: there is no `accepts` array and no `x402Version` field, so a client that branches on `body.accepts` will misclassify the 402. The terms are in the `WWW-Authenticate: Payment ...` response header (see the PaymentChallenge header).\n\nVerified against production (mpp.oculr.xyz) on 2026-07-29. Clients using `mppx` (npm install mppx) handle this transparently - see https://mpp.dev.","required":["type","title","status","detail","challengeId"],"properties":{"type":{"type":"string","description":"Problem type URI, e.g. \"https://paymentauth.org/problems/payment-required\"."},"title":{"type":"string","description":"Short problem title, e.g. \"Payment Required\"."},"status":{"type":"integer","description":"Mirrors the HTTP status: 402."},"detail":{"type":"string","description":"Human-readable detail naming the resource, e.g. \"Payment is required (Oculr transaction analysis).\"."},"hint":{"type":"string","description":"Prose pointer to wallet tooling. Not machine-actionable - read the WWW-Authenticate header instead."},"challengeId":{"type":"string","description":"Opaque challenge id; equals the `id` auth-param in the WWW-Authenticate header. The challenge is HMAC-bound - never mutate or re-assemble it."}}},"ErrorBody":{"type":"object","description":"Generic error envelope. Branch on `code` (a stable enum), never on the prose in `error`.","required":["error"],"properties":{"error":{"type":"string","description":"Human-readable message. Wording is not stable - do not parse it."},"code":{"type":"string","enum":["upstream_payment_unavailable","internal_error"],"description":"`upstream_payment_unavailable` (HTTP 502): oculr could not pay one of ITS upstream services - your payment is fine, so do not retry as a payment failure. `internal_error` (HTTP 500): anything else."},"hint":{"type":"string","description":"Present on 400s from paid endpoints - points at /SKILL.md and /openapi.json."}}},"DepositBelowMinimum":{"type":"object","description":"The other 402 from a paid endpoint: your channel is open but its deposit is under the $2.00 floor, so billable content is refused. Content-Type is `application/json` (not `application/problem+json`) and there is no WWW-Authenticate header - this is an admission refusal, not a fresh quote. Top up the channel or reopen it larger; close and topUp are never blocked, so escrowed funds are always recoverable.","required":["error","code"],"properties":{"error":{"type":"string"},"code":{"type":"string","enum":["deposit_below_minimum"]},"minDepositUsd":{"type":"number","description":"Admission floor: $2.00."},"suggestedDepositUsd":{"type":"number","description":"What to open with instead: $16."}}},"RunRecovery":{"type":"object","description":"Replayed final frames of a completed run, as returned by GET /explain/run/{requestId}.","required":["requestId","frames"],"properties":{"requestId":{"type":"string"},"txHash":{"type":"string"},"status":{"type":"string"},"frames":{"type":"array","description":"The terminal SSE frames verbatim - normally a single `{ type: \"result\", ... }` carrying the ExplanationResult, or `{ type: \"report\", html }` when the run was started with report=true.","items":{"type":"object","required":["type"],"properties":{"type":{"type":"string"}}}}}},"RunError":{"type":"object","description":"Failure envelope for GET /explain/run/{requestId}.","required":["error","code"],"properties":{"error":{"type":"string"},"code":{"type":"string","enum":["run_not_found","run_not_replayable"],"description":"`run_not_found` (404): unknown id, expired 30-day TTL, or KV backend inactive. `run_not_replayable` (409): the record exists but stored no frames."},"requestId":{"type":"string","description":"Echoed on 409."},"status":{"type":"string","description":"Echoed on 409 - the status the run reached."},"summary":{"type":"string","description":"Echoed on 409 when one was stored."}}},"MeteredSseRequired":{"type":"object","description":"The OTHER 402 from POST /explain: a plain-JSON request (no `Accept: text/event-stream`) is refused because the paid JSON path is retired. Content-Type is `application/json`; distinguish it from a real payment challenge by that Content-Type, or by `code === \"use_metered_sse\"`. Unauthenticated requests still get a WWW-Authenticate channel-open challenge on it (usable on the SSE and async rails); a credentialed retry gets no header - the fix is switching transport, never paying this response repeatedly.","required":["error","code"],"properties":{"error":{"type":"string","description":"Human-readable explanation of how to retry."},"code":{"type":"string","enum":["use_metered_sse"],"description":"Stable machine-readable code - branch on this, not on the prose."}}}},"headers":{"PaymentChallenge":{"description":"Where the payment terms actually live. Observed verbatim on production 2026-07-29:\n\n`WWW-Authenticate: Payment id=\"<challengeId>\", realm=\"mpp.oculr.xyz\", method=\"tempo\", intent=\"session\", request=\"<base64url>\", description=\"Oculr transaction analysis\", expires=\"2026-07-29T22:09:09.796Z\"`\n\nAuth-params: `id` (matches challengeId in the body), `realm`, `method` (payment rail, \"tempo\"), `intent` (\"session\"), `request` (base64url-encoded JSON, below), `description` (what is being billed), `expires` (ISO-8601 - re-request after this to get a fresh challenge).\n\nThe decoded `request` JSON is `{ amount, currency, recipient, suggestedDeposit, unitType, methodDetails: { chainId, escrowContract, operator, sessionProtocol } }`. Treat the whole string as opaque: it is HMAC-bound and any mutation invalidates the challenge.\n\n**Reading the numbers.** `amount` and `suggestedDeposit` are integer strings in the BASE UNITS of the `currency` token — USDC.e on Tempo, 6 decimals. Divide by 10^6 to get USD:\n\n- `amount: \"10000\"` = $0.01\n- `suggestedDeposit: \"16000000\"` = $16.00\n\nThey are neither dollars nor cents, and both wrong readings are large: taking them for dollars overstates by 10^6, and taking them for cents overstates by 10^4 (a $0.01 charge read as $100). The cents reading is the one this challenge used to invite, because `unitType` on the SSE route said \"usd-cent\". It no longer does. This is the most likely arithmetic error when hand-rolling a client — which is why every doc says to use `mppx`, which reads the scale off the token.\n\n**`unitType` is not a currency unit.** It names the unit that `amount` is the price OF (\"amount per unitType\") and carries no scale information at all; never infer the denomination from it. Values differ per route ON PURPOSE — MPP binds `unitType` into its cross-route credential-replay check, and treats the literal \"request\" as \"charge a streamed response once\" rather than \"charge per emitted event\":\n\n- `POST /explain` → \"tick\" — metered SSE: amount is the price of one $0.01 tick, charged repeatedly as cost accrues.\n- `POST /explain/async` → \"request\" — one-shot: amount is the price of the submit request.\n- `POST /session/deposit` → \"request\" — one-shot probe whose only purpose is to hand you a challenge to open a channel with.\n- `GET /explain/run/{requestId}` → \"request\" — one-shot: amount is the outstanding balance of that run.","schema":{"type":"string"}}},"securitySchemes":{"mpp":{"type":"http","scheme":"Payment","description":"Per-request micropayment over MPP. NOT an API key and NOT a static token: there is nothing to paste into a config. The value is a per-request signed voucher tied to a Tempo USDC payment channel, obtained by answering the 402 challenge in the `WWW-Authenticate` response header (see the PaymentChallenge header and the X402Challenge schema). Generated clients CANNOT construct it - use the `mppx` client library, which performs the challenge/response and signs vouchers for you. Free `/health`, `/openapi.json`, `/tool-spec.json`, `/SKILL.md`, `/llms*.txt` endpoints do NOT require this security scheme."}}},"security":[{"mpp":[]}],"x-pricing":{"currency":"USD","unitUsd":0.01,"suggestedDepositUsd":16,"minDepositUsd":2,"recommendedMaxDepositUsd":32,"depositIsEscrow":"The deposit is ESCROW, not a price. You are charged only what the analysis actually costs; every unspent cent returns to you when the channel closes. Your client's `maxDeposit` and the channel deposit are NOT the same number and must not be set equal. The channel opens at `min(suggestedDeposit, maxDeposit)` = $16, which is sized for one analysis; `maxDeposit` is the ceiling that decides whether that channel can ever grow. Set it to $32. At exactly $16 the channel opens ON its ceiling and is welded shut — both an automatic top-up and an explicit `session.topUp()` throw at the moment growth is needed — so the second heavy run on that channel dies with \"requested voucher amount exceeds local maxDeposit\" AFTER you have paid for the work completed up to that point, and you get nothing for it. A cap below the real cost of a run fails the same way, sooner. Opening below $2.00 is refused outright with `deposit_below_minimum`. Spend the headroom BETWEEN analyses — one `session.topUp()`, or close and reopen — never by letting a single analysis run past the deposit: mppx sizes an automatic top-up to the exact shortfall, so on the $0.01-tick metered stream that means one on-chain transaction per cent, stalling the stream. For scale: $16 clears the most expensive analysis on record with only a couple of dollars to spare — any one analysis, or roughly four median-priced ones back to back, but only ONE at the worst case.","perAnalysisUsd":{"status":"measured","asOf":"2026-07-29","sampleSize":48,"source":"oculr's own benchmark corpus, NOT neutral customer traffic: 48 production analyses over 24 distinct transactions, deliberately weighted toward hard exploit cases because that is what the service is built for. A small sample that describes what has been measured, not a guaranteed bound — your own mix decides which band you sit in.","model":"claude-opus-5","percentiles":{"min":0.09,"p25":1.77,"median":3.68,"p75":6.47,"p90":8.06,"max":13.37},"bands":[{"kind":"routine","lowUsd":1,"highUsd":2,"note":"transfers, swaps, straightforward DeFi"},{"kind":"incident-or-exploit","lowUsd":3,"highUsd":4,"note":"many more tool calls over far more data"}],"caveat":"Multi-modal: work clusters in the two bands above with very little in between, so the median ($3.68) sits in a gap few individual analyses actually occupy and must NOT be treated as an expected value. Budget against your band, or against p90 ($8.06). One analysis in ten costs more than $8.06."}},"x-amount-encoding":{"appliesTo":["amount","suggestedDeposit"],"units":"currency-base-units","decimals":6,"toUsd":"Integer strings in the BASE UNITS of the challenge's own `currency` token (USDC.e on Tempo, 6 decimals). Divide by 10^6 (1000000) to get USD. They are NOT dollars and NOT cents — do not infer the scale from `unitType`, which describes the metered unit, not the currency.","examples":[{"field":"amount","wire":"10000","usd":"$0.01"},{"field":"suggestedDeposit","wire":"16000000","usd":"$16.00"}],"unitTypeMeaning":"`unitType` names the unit that `amount` is the PRICE OF (\"amount per unitType\"). It is free-form in MPP and carries no currency information whatsoever. Read the scale from `currency` + its decimals, never from this field.","unitTypeByEndpoint":{"POST /explain":"\"tick\" — metered SSE: amount is the price of one $0.01 tick, charged repeatedly as cost accrues.","POST /explain/async":"\"request\" — one-shot: amount is the price of the submit request.","POST /session/deposit":"\"request\" — one-shot probe whose only purpose is to hand you a challenge to open a channel with.","GET /explain/run/{requestId}":"\"request\" — one-shot: amount is the outstanding balance of that run.","_note":"The values differ ON PURPOSE and are not interchangeable: MPP binds `unitType` into its cross-route credential-replay check, and treats the literal \"request\" as \"charge a streamed response once\" rather than \"charge per emitted event\"."}}}