Skip to main content
Independent MeasurementInterface: REST v1Auth: none for decision & resolve
vet402x402 EconomyNovember 2026

API reference

Decision API for x402 agent payments. Check an x402 seller before paying.

vet402 buys from x402 APIs itself and publishes the result of each purchase, so your agent can check a seller before it pays. With no key, resolve the seller's URL to a resource_id and read its decision: ALLOW, WARN or BLOCK, with the L0 to L2 facts behind it.

While keyless reads are on, /decision answers at 10 requests a minute per IP and /resolve at 60. Keyless reads of /decision can be paused by the operator. The score, webhook and attest endpoints take an API key in Authorization: Bearer. Base URL: https://vet402.com/api/v1

Full machine-readable schema: /openapi.yaml (also on GitHub).

Quickstart

The first four need no account, no key and no signature — paste them into a terminal as they are. The first one is the integration itself: a URL in, a decision out.

1 — Should my agent pay this URL? Resolve it, then ask (no key)

# 1. the URL that answers 402 -> its resource_id
curl "https://vet402.com/api/v1/resolve?q=https://api.exa.ai/search"

# 2. resource_id -> facts + ALLOW / WARN / BLOCK in one document
curl "https://vet402.com/api/v1/resources/baad6a17bfaf57b11c0c1d8cfb0b38d3d01f09736b7d8af2f92f0313ddef8bdb/decision?role=payer"
Try it

Try it

Copy resource.resource_id from the first answer into the second. The decision carries recommendation, reason_codes (each one is in the reason-code table) and the L0–L2 facts behind them. Without a key, /decision answers 10 requests per minute per IP and /resolve 60. Past that you get 429 rate_limited with a Retry-After under 60 seconds. A URL that is not in the catalog comes back from /resolve with no resource (and, when nothing on its host is on record either, an empty endpoints with not_found); a URL without https:// gets 400 invalid_query with a suggestion; and an unknown id gets 404 not_found from /decision.

The ledger column l2_schema stores match, mismatch, no_declaration, or not_checked. The decision document and the facts document store conform, mismatch, undeclared, or not_checked. conform is written when the re-read L2 value (l2_reading) of the latest delivery is match. mismatch is written when that re-read value is mismatch. An older mismatch row with no recorded reason is re-read first, and a cut body reads not_checked. In the other cases the decision word is not_checked when the current declaration names output (a required key or an example property), or when the purchase row of the latest delivery recorded a declaration and its re-read value is not_checked, and undeclared when neither holds. For the quickstart resource https://api.exa.ai/search, read facts.l2.status on its decision and on its facts document, and l2_schema on its purchase rows.

2 — What happened when we actually paid an endpoint (no key)

curl "https://vet402.com/api/v1/observatory/endpoints/521e929e-5f89-4603-a964-d1812caf118f/purchases"
Try it

n paid attempts, m settled, each settled row carrying its on-chain txHash. A Circle Gateway row on Arc carries gatewayBatchTx instead, because one batch transaction can settle many payments. This is the record vet402 exists to keep. Swap the id for any endpoint on the observatory.

3 — The public accuracy ledger (no key)

curl "https://vet402.com/api/v1/accuracy"
Try it

Aggregate counts only. The same numbers /accuracy renders, including the operator benchmark.

4 — The exact message a payee has to sign (no key)

curl "https://vet402.com/api/v1/payees/verify?wallet=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045&name=Acme%20API"
Try it

Returns { "message": "…" } — sign it with that wallet and POST it back to the same path to publish a verified-payee page and a badge. Read-only; nothing is written.

5 — Score a payee before paying it (key required)

curl -H "Authorization: Bearer vouch_live_…" \
  "https://vet402.com/api/v1/payees/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/score"

The buyer-side question. Get a key — the free tier is 1,000 lookups a month.

Keys look like vouch_live_… — send them as Authorization: Bearer vouch_live_…. The vouch_ prefix and the VOUCH_API_KEY variable keep the old name (the product was called Vouch until August 2026), so existing keys and configs still work.

API keys and webhook headers retain the vouch_ / Vouch- prefixes for backward compatibility.

Note — the key-less demo scorer

Try it

GET /api/demo/score scores one fixed demo agent chosen server-side, so anyone can watch a real verdict get computed without a key. Nothing in the request selects what it scores — it is a demo, not a free lookup, so it returns the same agent whatever you pass it. To score an address you choose, open /payee or call the payee-score endpoint above (example 5).

Packages

Three published packages, all from this repository. They wrap the same REST API documented below — nothing here is available to a package that is not available to a plain fetch.

npm i @vet402/sdk          # spend guard for an agent about to pay
npm i @vet402/middleware   # x402 request gate for an API provider
npm i @vet402/mcp-server   # MCP tool, so an agent can ask before it pays

@vet402/* is the canonical scope. @vouchscore/* is the old name (the product was called Vouch until August 2026) and is published from the same npm account and left in place only so existing installs keep resolving. It is frozen at the version it had when the name changed, not kept in step with @vet402/*, so new work should take the canonical scope. Unscoped vouch-sdk and @getvouch/sdk exist on npm and are unrelated packages by other publishers — installing those gets you someone else's code, not ours.

SDK: read a score

apiUrl defaults to the hosted API, so a key is the only thing you have to supply. Copy this into a .mjs file and it runs.

import { createVouchClient } from "@vet402/sdk";

const vouch = createVouchClient({ apiKey: process.env.VOUCH_API_KEY });

// Seller side — "should I accept payment from this wallet?"
const seller = await vouch.getWalletScore("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045");
console.log(seller.trustScore, seller.recommendation); // 0–100 and ALLOW | WARN | BLOCK — live values

// Buyer side — "should my agent pay this wallet?"
const payee = await vouch.getPayeeScore("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045");
console.log(payee.score, payee.recommendation, payee.dataDepth);

SDK: ask before paying, with or without a key

getDecision and resolve work with no key. Leave apiKey unset and the client sends no Authorization header: getDecision is then key-less at 10 requests per minute per IP (429 rate_limited beyond), and resolve at 60. With a key, getDecision spends 1 unit of your monthly quota per call. The score calls above (getWalletScore, getPayeeScore) do need a key; without one the server answers 401 missing_api_key.

import { createVouchClient } from "@vet402/sdk";

const vouch = createVouchClient({}); // no apiKey: key-less, 10/min per IP

const { resource } = await vouch.resolve("https://api.exa.ai/search");
if (!resource?.resource_id) throw new Error("not in the vet402 catalog");
const d = await vouch.getDecision(resource.resource_id, { role: "payer" });
console.log(d.recommendation, d.reason_codes);

SDK: gate a payment

Gate the payment on the resource decision for the URL, not on the payee wallet score. Pay when recommendation is ALLOW and degraded is false. A missing resource, a WARN, a BLOCK, or a degraded read stays unpaid.

import { createVouchClient } from "@vet402/sdk";

const vouch = createVouchClient({}); // no apiKey: key-less, 10/min per IP

const { resource } = await vouch.resolve("https://api.exa.ai/search");
if (!resource?.resource_id) throw new Error("not in the vet402 catalog");
const d = await vouch.getDecision(resource.resource_id, { role: "payer" });

if (d.recommendation === "ALLOW" && d.degraded === false) {
  // the caller pays from here. this sample does not sign or send
  console.log("gate open", d.recommendation, d.degraded);
} else {
  console.error(d.recommendation, d.reason_codes, d.degraded);
}

SDK: payee wallet score

This sample checks the payee wallet score. It does not call the resource decision. A wallet score can read ALLOW when the decision for one URL paid to that wallet reads WARN or BLOCK. Call getDecision for that URL. Pay only when recommendation is ALLOW and degraded is false.

const guard = vouch.createSpendGuard({
  maxPerTxUsd: 10,
  dailyBudgetUsd: 50,
});

const scored = await guard.evaluate({ payee: "0xabc...", amountUsd: 5 });
console.log(scored.allow, scored.reasons);

Middleware: gate an x402 endpoint

import { createExpressGate } from "@vet402/middleware/express";

// Mount AFTER x402 verification, so `req.payer` is set.
app.use("/api/paid", createExpressGate({
  apiUrl: "https://vet402.com/api/v1",
  apiKey: process.env.VOUCH_API_KEY,
  getAddress: (req) => req.payer,   // the counterparty to vet
}));

Anything but ALLOW returns 403 { error: "trust_blocked" } before your handler runs; an ALLOW continues with the full decision on req.vouchTrust.

Both the SDK and the middleware default to allow-only. Only an ALLOW passes; a WARN, a BLOCK, a degraded verdict and a partially measured one are all denied unless the caller explicitly opts out. That default is fail-closed on purpose — see Decision verdicts & reason codes for what to do about a WARN.

MCP: let an agent ask before it pays

@vet402/mcp-server exposes the score as Model Context Protocol tools, so an MCP-capable agent (Claude Desktop and other MCP clients) can check a counterparty before it settles. Your client launches it with npx — no clone, no build. Add this to the client config (~/Library/Application Support/Claude/claude_desktop_config.json on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows) and restart:

{
  "mcpServers": {
    "vouch-trust": {
      "command": "npx",
      "args": ["-y", "@vet402/mcp-server"],
      "env": {
        "VOUCH_API_KEY": "vouch_live_your_key_here"
      }
    }
  }
}

VOUCH_API_KEY is optional. Without it the two /decision tools work key-less (10/min per IP) and the five score and attest tools answer missing_api_key; with one, created at /dashboard/keys, those five work too. VOUCH_API_URL is optional and defaults to the hosted API. The seven tools: check_resource_decision and pay_if_trusted (the /decision route; pay_if_trusted also holds a signer if you configure one), and check_agent_trust, check_wallet_trust, check_payee_trust, explain_trust_score and attest_x402_payment (key required). In 0.3.0 on npm, check_resource_decision takes the sha256 resourceId from /api/v1/resolve; taking the URL itself, and answering not_in_catalog for a URL or id vet402 does not list, is in the repository and ships with the next release. Same fail-closed reading as the SDK: treat anything but ALLOW as “do not pay yet”.

Decision verdicts & reason codes

GET /api/v1/resources/{id}/decision answers ALLOW, WARN or BLOCK from vet402’s own L0–L2 records, and lists the reasons in reason_codes. There is no score behind it: the transitional 0–100 payee score is left out of the response unless you ask for it with include_score=1, and even then it is marked superseded_by: "recommendation" and is not used for the decision.

  • BLOCK: The record shows a reason not to pay: the L0 probe failed twice in a row (l0_fail), money moved without delivery two or more times on the seller's side (l1_paid_not_delivered), the body lacks declared keys, wash volume, or your own block list.
  • WARN: Not cleared: evidence is missing, old, unverified, not yet settled or not sorted to a side. A failure where no money moved, or one /sellers leaves not sorted, is a WARN at most. The SDK and the middleware refuse a WARN unless you opt out.
  • ALLOW: Needs a passing L0 probe, a delivery within 30 days, and no BLOCK or WARN reason. Compare the 402 you get with verified_terms before you pay.

Reason codes (rules 2026-11-13.1)

“Verdict” is what the code causes on its own; — means it explains without changing the verdict. Codes in angle brackets stand for a family.

With role=payee, facts.short_interval_payment_rate is the share of gaps of 60 s or shorter between a payer’s settled payments to the same resource. It describes payment timing and does not change the verdict. Settled payments do not show whether a payment was a retry.

Decision reason codes: meaning, what it means for the payer, and whether the seller can fix it
CodeVerdictWhat it meansFor the payerSeller can fix
l0_pass—vet402's latest probe got a valid 402 payment request from this URL.The URL is up and asks for payment as listed.—
l0_failBLOCKTwo probes in a row got no valid 402 payment request.Do not pay: the URL is not answering as an x402 seller right now.Yes
l0_unverifiedWARNvet402 has not measured a valid 402 recently (not probed yet, no probe in the last 30 days, a URL with a path parameter, a probe that could not complete, or one failure so far); the l0_unverified_<cause> code next to it says why.A WARN with degraded: true. Unverified is not a failure; l0_fail (two measured failures in a row) is the L0 BLOCK.Partly
l0_unverified_single_failWARNThe latest probe failed once; the published rule needs two failures in a row.Probably temporary. Retry later, or pay under your own ceiling.Yes
l0_unverified_no_recent_probeWARNvet402 has probed this listing before, but not in the last 30 days, so this answer does not rest on a recent probe.Not measured recently, so WARN and degraded. A measurement gap, not a finding against the seller.No
l0_unverified_<cause>WARNWhy the probe could not be measured, for example l0_unverified_tls, l0_unverified_not_probed, l0_unverified_path_template or l0_unverified_request_shape.Not measured, so WARN and degraded. A measurement gap, not a finding against the seller.Partly
l1_delivered—vet402 paid and got a non-empty 2xx other than 202 Accepted at least once in the 30-day window.Delivery evidence exists. ALLOW needs it to be at most 30 days old.—
l1_not_attemptedWARNvet402 has not signed a paid attempt against this URL.No purchase evidence. Pay under your own policy, or pass allow_without_l1=true.No
l1_staleWARNvet402's last delivery is older than 30 days, or its last signed attempt is. Attempts inside the window that did not deliver can sit next to it.Evidence is old; treat as unmeasured until the next re-buy.No
l1_inconclusiveWARNvet402 signed paid attempts in the window, but none counts: none delivered and none is on the seller's side on /sellers.A gap in vet402's measurement, not evidence against the seller. The l1_not_counted_* codes say why.No
l1_never_deliveredWARNFailures on the seller's side in the window and no delivery on record at any age. A listing with an older delivery reads l1_stale instead.Nothing reached vet402. It is a BLOCK when l1_paid_not_delivered is also there twice on the seller's side (money moved).Yes
l1_paid_not_deliveredWARN or BLOCKAfter the last delivery in the window, a payment moved money (settled on-chain) and no delivery came back: an error, a 202 Accepted, or an empty body. Attempts on vet402's side and held attempts are left out.You can lose the payment. A WARN; a BLOCK only when two or more of these are on the seller's side on /sellers (seen on two different UTC days). Not sorted ones stay a WARN.Yes
l1_latest_failedWARNSomething was delivered, in the window or earlier, but the latest counted attempt failed.It worked before and failed last time; retry later or pay under your own ceiling.Yes
l1_empty_2xx_settlement_unknownWARNAfter the last delivery in the window, a paid attempt brought no result back and it is not yet known whether it moved money: a 2xx with an empty body and no settlement linked, a receipt still awaiting on-chain verification, or a signed attempt that failed less than 90 minutes ago while vet402 links late settlements.Not counted as a failure yet, and not an ALLOW until it is settled. If a settlement is linked and nothing was delivered, the attempt counts as l1_paid_not_delivered.Yes
l1_not_counted_vet402_side—Attempts left out because the record shows vet402's side of the fault (its wallet short of funds, or the declared input not sent).These attempts say nothing about the seller.No
l1_not_counted_held—Attempts left out while held (held rows except seller_fixable_input), or a receipt awaiting on-chain verification.Not counted until the hold is resolved.No
l1_not_counted_no_charge—Attempts left out because the seller answered without taking a payment.No money moved; not a failed purchase.No
l1_not_counted_unproven—Attempts left out because vet402 cannot show the failure was not its own (shown as not sorted on /sellers).They do not count against the seller. If money moved, l1_paid_not_delivered is also there and the answer is a WARN.No
l1_not_counted_unconfirmed—Attempts left out because the seller-side failure was seen on one UTC day so far; the seller's side needs two different days.Not counted against the seller until the same failure shows up on a second day. If money moved, l1_paid_not_delivered is also there (WARN).Partly
l1_waived_by_operator—You passed allow_without_l1=true, so missing or old L1 evidence does not hold back an ALLOW. It is left out when an L1 failure or an unsettled empty 2xx holds the answer back, since those are not waived.Your own opt-in; failures are not waived.—
l2_conform—The latest delivered body has the keys the listing's output schema requires.The shape matches what the listing declares.—
l2_conform_no_required_keys—The latest delivered body matched a declaration that marks no key as required. vet402 checked that the body is a JSON object or array, not which keys it carries.The keys in the body are unverified. This code does not change the recommendation.Yes
l2_undeclared—Neither the catalog nor the purchase row of the latest delivery gives an output declaration to check against: no output schema, or one with no required keys and no example properties.Nothing to compare; this alone does not stop an ALLOW.Partly
l2_not_checkedWARNThe listing declares an output schema, but vet402 has not checked a delivered body against it: no delivery in the window, a body over 256 KiB or cut off, or a delivery recorded before the check covered it.The shape of what you get is unverified; a WARN until a delivered body is checked.Partly
l2_mismatchWARN or BLOCKThe latest delivered body did not match the listing's output schema.A BLOCK when the evidence names the missing keys (mismatch_kind missing_keys); otherwise l2_mismatch_unexplained and a WARN.Yes
l2_mismatch_unexplainedWARNA mismatch with no missing key on record: the complete body was not JSON, was JSON that was not closed, or was not an object, or the content type was not JSON. A body vet402 could not read to the end is not a mismatch: since 2026-09-29 vet402 reads up to 256 KiB of a paid body and records a longer body, or one cut off after some bytes arrived, as not checked; an older row, read up to 16,000 bytes, is not counted as a mismatch when a key recorded as missing shows at the top level of the stored start of the body, when it recorded no missing key and its JSON body ran past the stored start, or when the listing declares no output schema now.Not a BLOCK: vet402 cannot say what differed. Check the body yourself.Partly
offer_driftingWARNThe price, asset or payTo changed three or more times within 24 hours.Compare the 402 you get with verified_terms before paying.Yes
wash_dominatedBLOCKMost settlements to this seller are self-dealing or circular: real payments are 10% or less of at least 10 raw ones.The payment volume does not reflect real buyers.Partly
dialect_mismatchWARNThe 402 speaks a different x402 version than the caller_dialect you sent.Your client may not be able to pay this 402 as it is.Partly
data_thinWARNLittle data behind the answer. Reserved: the public route does not set it today.Treat as a WARN.No
operator_blacklistBLOCKThe counterparty is on your own block list (keyed calls).Your own rule; vet402 applies it and keeps it out of facts.No
sybil_highrole=payeeBLOCKThe payer shares an owner with several agents and a funder with other payers.For the seller: likely one operator behind many identities.—
retry_burstrole=payee—Retired in rules 2026-11-02.3 and not returned since. It read short gaps between settled payments as retries, which settled rows cannot show. The number stays in facts as short_interval_payment_rate.For the seller: in an older response or log, read it as no signal about the payer.—
<input>_unavailablerole=payeeBLOCKAn input could not be read (settlements_unavailable, funder_index_unavailable or erc8004_unavailable).For the seller: degraded, so fail-closed. Retry later.—
thin_historyrole=payeeWARNThe payer has two or fewer settled payments in 30 days.For the seller: little history to go on.—
shared_funderrole=payeeWARNThe payer's wallet was funded by the same address as other payers.For the seller: possibly related identities.—
new_payerrole=payeeWARNvet402 first saw this payer less than 7 days ago.For the seller: a new counterparty.—
erc8004_registeredrole=payee—The payer is registered as an ERC-8004 agent.For the seller: an on-chain identity exists.—
history_okrole=payee—None of the payee checks above fired. The funder index covers few wallets, so a payer outside it reads as no shared funder, which does not show it is independent.For the seller: ALLOW.—

Score bands (agent, wallet and payee scores)

Every scored response carries a numeric score and a recommendation. The bands are fixed and the same for the agent, wallet and payee engines:

Score thresholds for ALLOW, WARN and BLOCK, and what to do with each
VerdictScoreWhat it meansRecommended handling
ALLOW70 – 100Every check completed and nothing adverse was found.Proceed. This is the only band the SDK and the middleware pass by default.
WARN40 – 69Not cleared. Something is thin, unusual, or only partially measured — not enough to condemn the address, not enough to pass it either.Decide deliberately rather than by default: allow it under a spend ceiling, queue it for review, or require a second signal.
BLOCK0 – 39Adverse signals, a blacklist hit, or a check that could not be completed at all (degraded: true).Do not pay. A degraded BLOCK is a refusal to answer, not a finding — retry once the upstream recovers.
  • Band the recommendation, not the number. Read recommendation, not trustScore >= 70. A blacklisted address and a degraded verdict are BLOCK regardless of what the number says, so a numeric comparison in your own code will disagree with ours on exactly the cases that cost money.
  • The default is allow-only, and that is the point. @vet402/sdk and @vet402/middleware deny everything that is not ALLOW unless you opt out explicitly. The asymmetry is deliberate: declining a good payee costs a retry, paying a bad one is final under x402's exact scheme.
  • WARN is where your policy lives, not ours. We publish the band and the raw signals behind it; what an acceptable risk is at your transaction size is yours to set. The one handling we do argue against is treating WARN as a quiet ALLOW — our operator benchmark currently scores known-bad addresses WARN rather than BLOCK in a minority of cases, so a pass-on-WARN integration would pay those.

Rate limits

Scoring is synchronous, so plan for both the monthly quota and the burst behaviour below.

Monthly request quota by plan
PlanMonthly requests
Free1,000
Pro50,000
Scale500,000
  • Quota is per calendar month (UTC) and shared across all keys on an account. Each /score call is 1 unit; a /scores/batch of N agents is N units. Every scored response carries X-RateLimit-Limit, X-RateLimit-Used, and X-RateLimit-Remaining headers so you can track consumption without a separate call.
  • What spends the monthly units, and what does not. Only calls that carry your key spend units: each score call (agent, wallet, payee), each /decision sent with a key, and the other keyed routes in the reference below are 1 unit per call (a batch of N is N). Key-less calls spend no monthly units: /decision without a key (10/min per IP), /resolve (60/min per IP), and the observatory and accuracy reads each have their own per-IP window instead.
  • No per-second burst throttle on authenticated calls today. Authenticated requests are governed by the monthly quota only — you may spend it as fast as you like — so pace client-side if you must not exhaust the month in one run.
  • Abuse throttles (IP-based), per minute. Key-less and pre-auth paths carry their own IP cap, independent of the quota: authentication failures 60; the unauthenticated demo scorer 10; GET /api/v1/accuracy 20; the badge SVGs 60; the agent passport 20. Verify endpoints split read from write: GET (message preview) 30/IP, while POST is 8/IP and 4 per wallet or agent, so one identity cannot rewrite its public profile in a loop from many IPs. Valid authenticated traffic does not hit any of these.

Two kinds of 429

The two distinct causes of an HTTP 429 and how to tell them apart
CauseBodyHeadersWhat to do
Monthly quota spent (authenticated)error: "rate_limit_exceeded" with retryAfter, usage, limitX-RateLimit-* and Retry-After (seconds to the start of next month, UTC)Stop. Retrying inside the month cannot succeed — raise the plan or wait for the reset.
IP throttle (key-less / pre-auth paths)error: "rate_limited"RateLimit-Limit / -Remaining / -Reset and Retry-After (seconds, always under 60)Sleep for Retry-After and retry. The window is one minute.

The header families are deliberately different names: X-RateLimit-* reports the monthly plan quota, RateLimit-* (IETF draft names) reports the short IP window. No route sets both families on the same response.

GET /api/v1/agents/:agentId/score

Score by ERC-8004 agent ID. Pass ?wallet=0x... to verify the agent's registered wallet.

Response

{
  "agentId": "42",
  "wallet": "0x1234...",
  "trustScore": 78,
  "recommendation": "ALLOW",
  "signals": { "identity": {...}, "reputation": {...}, "wallet": {...}, "x402": {...}, "sybil": {...}, "manual": {...} },
  "breakdown": {
    "components": {
      "identity":   { "score": 100, "weight": 0.05, "contribution": 6.25 },
      "reputation": { "score": 66,  "weight": 0.10, "contribution": 8.25 },
      "wallet":     { "score": 75,  "weight": 0.25, "contribution": 23.44 },
      "x402":       { "score": 83,  "weight": 0.40, "contribution": 41.5 }
    },
    "weightedSubtotal": 79,
    "sybilPenalty": 0,
    "prePolicyScore": 79
  },
  "scoredAt": "2026-07-14T00:00:00Z",
  "cacheExpiresAt": "2026-07-14T00:05:00Z",
  "disclaimer": "Scores are informational only and do not constitute a guarantee, credit assessment, or investment advice."
}

GET /api/v1/wallets/:address/score

Score by wallet address. Primary integration path for x402 API middleware.

Response

{
  "agentId": "0",
  "wallet": "0x1234...",
  "trustScore": 61,
  "recommendation": "WARN",
  "signals": { ... },
  "scoredAt": "2026-07-14T00:00:00Z",
  "cacheExpiresAt": "2026-07-14T00:05:00Z",
  "disclaimer": "Scores are informational only and do not constitute a guarantee, credit assessment, or investment advice."
}

GET /api/v1/payees/:address/score

Buyer-side screening: should my agent pay this wallet? Never 404s for an unfamiliar wallet — a wallet with no history returns 200 with dataDepth "thin" so you can weigh the confidence yourself. See Payee score below for the composition.

Response

{
  "payee": "0x1234...",
  "score": 52,
  "recommendation": "WARN",
  "dataDepth": "thin",
  "degraded": false,
  "signals": { "receiving": {...}, "walletHealth": {...}, "drainPattern": {...}, "outcomeHistory": {...}, "flags": [...] },
  "scoredAt": "2026-08-13T00:00:00Z",
  "cacheExpiresAt": "2026-08-13T00:05:00Z",
  "disclaimer": "Scores are informational only … it is not an identity or legal-standing check."
}

POST /api/v1/scores/batch

Score up to 25 agents in a single request.

Request body

{
  "agents": [
    { "agentId": "1" },
    { "agentId": "2", "wallet": "0x..." }
  ]
}

Response

{
  "results": [
    { "agentId": "1", "trustScore": 78, "recommendation": "ALLOW", ... },
    { "agentId": "2", "error": "invalid_agent_id" }
  ]
}

POST /api/v1/payments/x402

Attest an x402 payment settlement after payment verification. Idempotent on txHash.

Request body

{
  "wallet": "0xpayer...",
  "txHash": "0xabc...",
  "amount": "1000000",
  "network": "base",
  "resource": "/api/premium/data"
}

Response

// 201 Created (first attestation)
// 200 OK (already recorded — idempotent replay on txHash)
{
  "ok": true,
  "created": true,
  "id": "b3f1...",
  "wallet": "0xpayer...",
  "txHash": "0xabc..."
}

GET /api/v1/agents/:agentId/history

Score history snapshots. Requires Pro or Scale plan. Supports ?limit= (1-100, default 20).

Response

{
  "agentId": "42",
  "history": [
    { "trustScore": 78, "recommendation": "ALLOW", "scoredAt": "2026-07-13T00:00:00Z", ... },
    { "trustScore": 74, "recommendation": "ALLOW", "scoredAt": "2026-07-12T00:00:00Z", ... }
  ]
}

GET /api/v1/watchlist

List your watched targets (max 50 per key). POST {targetType, target, chainId?} to add; DELETE /api/v1/watchlist/:id to remove. A daily cron re-scores entries and fires the watch.verdict_changed webhook only when the recommendation changes (score jitter without a verdict change is stored but not pushed).

Response

{
  "watchlist": [
    { "id": "…", "targetType": "wallet", "target": "0x…", "chainId": 8453,
      "lastScore": 74, "lastRecommendation": "ALLOW", "lastCheckedAt": "2026-08-05T06:30:00Z" }
  ]
}

POST /api/v1/webhooks

Register a webhook endpoint (max 5 per key). The signing secret is returned ONCE — store it. events must be a non-empty subset of the events list below. URL must be https to a public host (SSRF-guarded at registration AND at every delivery). GET /api/v1/webhooks lists your endpoints (secrets never returned); DELETE /api/v1/webhooks/:id removes one.

Request body

{
  "url": "https://your-host.example/vouch-hook",
  "events": ["watch.verdict_changed", "outcome.recorded"]
}

Response

// 201 Created — secret shown once
{
  "id": "…",
  "url": "https://your-host.example/vouch-hook",
  "events": ["watch.verdict_changed", "outcome.recorded"],
  "secret": "whsec_…"
}

GET /api/v1/payees/verify?wallet=0x…&name=Acme+API

Preview the exact canonical message for a (wallet, name) pair before signing — no API key. Pass url= as well when the profile will include a link; that URL is bound into the signature. The same message is echoed back in a failed POST's expectedMessage field. Fetch it rather than building it locally: since 2026-09-05 the message names the requesting domain, and signatures over the older form stop verifying on 2026-09-21 (signature_message_legacy_expired).

Response

{ "message": "vet402.com — verified payee registration\ndomain: vet402.com\nwallet: 0x…\nname: Acme API\nissued: 2026-09-05T12:00:00.000Z (valid 10 minutes)\nThis signature proves control of the wallet above. It moves no funds and grants no spending approval.", "issued": "2026-09-05T12:00:00.000Z" }

POST /api/v1/payees/verify

Address control verification — free, no API key. Sign the canonical message above (fetch it via GET on this same path, including url= when you will send one) with the payee wallet; a valid signature proves control and publishes /payee/:address plus an embeddable badge at /api/badge/:address. Verification proves wallet control only; scores stay independent.

Request body

{ "wallet": "0x…", "name": "Acme API", "url": "https://…", "issued": "2026-09-05T12:00:00.000Z", "signature": "0x…" }

Response

{ "ok": true, "profile": "/payee/0x…", "badge": "/api/badge/0x…" }

GET /api/v1/agents/verify?agentId=42&name=Acme+Agent

Agent-side twin of payee verify. Preview the exact canonical message to sign for (agentId, name) — no API key. The agent's on-chain wallet is resolved and returned so you sign with the right key.

Response

{ "agentId": "42", "wallet": "0x…", "message": "vet402.com — agent passport registration\ndomain: vet402.com\nagentId: 42\nwallet: 0x…\nname: Acme Agent\nissued: 2026-09-05T12:00:00.000Z (valid 10 minutes)\nThis signature proves control of the wallet above. It moves no funds and grants no spending approval.", "issued": "2026-09-05T12:00:00.000Z" }

POST /api/v1/agents/verify

Trust-passport registration — free, no API key. Sign the canonical message above with the agent's on-chain wallet (getAgentWallet(agentId)); a valid signature plus the on-chain wallet binding proves control of the agent identity and publishes /agent/:agentId, a machine-readable passport at /api/v1/agents/:agentId/passport, and a badge at /api/badge/agent/:agentId.

Request body

{ "agentId": "42", "name": "Acme Agent", "url": "https://…", "issued": "2026-09-05T12:00:00.000Z", "signature": "0x…" }

Response

{ "ok": true, "agentId": "42", "wallet": "0x…", "profile": "/agent/42", "badge": "/api/badge/agent/42" }

GET /api/v1/agents/:agentId/passport

The portable, third-party-verifiable passport — no API key. Returns the signed identity claim, the verification material (canonical message + signature, so any counterparty can re-run verifyMessage and cross-check the wallet against getAgentWallet on-chain), and a live score with explicit freshness (scoredAt / cacheExpiresAt).

Response

{ "agentId": "42", "verified": true, "identity": { "name": "Acme Agent", "wallet": "0x…", "proof": { "message": "…", "signature": "0x…", "scheme": "eip191-personal-sign" } }, "score": { "trustScore": 78, "recommendation": "ALLOW", "x402": { "paymentCount": 12, "uniqueDays": 6 }, "scoredAt": "…", "cacheExpiresAt": "…" } }

GET /api/v1/check?url=https%3A%2F%2Fapi.example.com%2Fx402

Key-less REST twin of MCP check_x402_seller — the same JSON document (recommendation, delivery, the five latest purchases, verified_terms). Optional method= narrows the HTTP method like /resolve. Counts against the same per-IP limit as /resolve (60 requests per minute per IP). The CDN may serve an answer up to 60 seconds old, and up to five more minutes while it refreshes in the background. Unknown or duplicate query parameters are 400 invalid_query (allowed lists url and method). url must be https (http → invalid_url); bad method → invalid_method. 503 temporarily_unavailable when the store cannot be read. When VET402_RECEIPT_SIGNER_KEY is set, the body also carries receipt. POST /api/v1/receipts/verify checks it. When the variable is unset, receipt is omitted and the rest of the body stays as it is.

Response

{ "not_in_catalog": false, "recommendation": "ALLOW", "delivery": { "paid_attempts": 3, "delivered": 3, "settled_not_delivered": 0, "window_days": 30 }, "l1_purchases": [ … ], "as_of": "…" }

GET /api/v1/diagnose?url=https%3A%2F%2Fseller.example%2Fpaid

Keyless read of one unpaid 402. No wallet. vet402 fetches the URL once through its SSRF guard and returns ALLOW, WARN, or BLOCK, the cheapest payable accept, and one fix line per check. method is GET or POST. 20 requests a minute per IP. The fetch stops at 10 seconds and 64 KB. Private addresses are refused with unsafe_target. A completed read may be cached for 60 seconds, and a shared cache may keep serving it for five more minutes while it refreshes. A failed live read is not stored. A listed URL also points at GET /api/v1/check.

Response

{ "verdict": "ALLOW", "recommended_accept": { "scheme": "exact", "network": "eip155:8453", "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "amount": "1000", "payTo": "0x1111111111111111111111111111111111111111", "usd_atomic": "1000" }, "checks": [ { "id": "returns_402", "status": "pass", "fix": "vet402 received HTTP 402." } ], "checked": ["returns_402"], "not_checked": [ { "id": "solana_fee_payer", "reason": "vet402 found no Solana accept to check." } ], "as_of": "2026-11-12T00:00:00.000Z", "catalog": { "listed": false, "check_url": null }, "http_status": 402 }

POST /api/v1/receipts/verify

Check a verdict receipt from GET /api/v1/check or GET /api/v1/resources/:resourceId/decision. No API key. Send input_sha256, verdict, as_of, signer, and signature. vet402 rebuilds the EIP-191 message and checks it with verifyMessage. A match returns ok true. A changed field returns ok false and error invalid_signature. A field vet402 cannot read returns 400 invalid_input. The signed lines are vet402.com verdict receipt, domain: vet402.com, input sha256, verdict, as_of, and a closing line that the signature attests the verdict and moves no funds. input_sha256 is the sha256 of the canonical input in the verdict receipt section of the docs. When VET402_RECEIPT_SIGNER_KEY is unset, those routes omit receipt and the rest of the body stays as it is. 30 requests per minute per IP.

Request body

{ "input_sha256": "<64 lowercase hex>", "verdict": "ALLOW", "as_of": "2026-11-12T00:00:00.000Z", "signer": "0x…", "signature": "0x…" }

Response

{ "ok": true }

POST /api/v1/payment-proof

Keyless read of one payment receipt. No wallet. Pass paymentResponse (the seller PAYMENT-RESPONSE header, base64 JSON) or network plus transaction. vet402 reads the transaction on Base, Celo, Arbitrum One, Avalanche C-Chain, BNB Chain, Solana, Arc, Tempo, XRPL and Stellar, the chains it reads again after a purchase, and returns found, network, tx, block, from, to, asset, amount in atomic units, status (confirmed, failed, not_found, unsupported_network), explorer and as_of. A chain outside that set comes back as unsupported_network. 20 requests a minute per IP. The chain read stops at 8 seconds. This route does not pay and does not sign.

Request body

{ "paymentResponse": "<base64>" }

Response

{ "found": true, "network": "eip155:8453", "tx": "0x…", "block": "123", "from": "0x…", "to": "0x…", "asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "amount": "10000", "status": "confirmed", "explorer": "https://basescan.org/tx/0x…", "as_of": "2026-11-12T00:00:00.000Z" }

POST /api/mcp

The hosted MCP server, also at https://vet402.com/mcp. Streamable HTTP, stateless, free, no key. It reads records and makes no payment. It serves protocol revisions 2025-06-18 (with initialize) and 2026-07-28 (server/discover). Tools: check_x402_seller (what happened when vet402 paid an x402 URL, with the transaction of each attempt and verified_terms), check_my_x402_endpoint (for sellers: the catalogs a host is listed in, the latest result of each listing, failure modes with links to /sellers/fix-first and when vet402 can buy again), find_x402_sellers (listings that vet402 paid and received content from in the last 30 days, ordered by that count) and check_payment_proof (reads a PAYMENT-RESPONSE header or a network and a transaction on the chains vet402 reads again after a purchase, at 20 requests a minute per IP shared with POST /api/v1/payment-proof). 120/min per IP, plus 30/min per IP for find_x402_sellers and 30/min per IP for check_my_x402_endpoint (429 with JSON-RPC code -32029). GET and DELETE are 405. A request with an Origin header from another site is 403. The body is capped at 64 KB. The installable package @vet402/mcp-server also offers diagnose_x402_url. vet402 reads one unpaid 402 for a URL and does not pay and does not sign.

Response

{
  "jsonrpc": "2.0", "id": 1,
  "result": {
    "structuredContent": { "not_in_catalog": false, "recommendation": "ALLOW", "reason_codes": ["l0_pass", "l1_delivered"],
      "delivery": { "paid_attempts": 3, "delivered": 3, "settled_not_delivered": 0, "last_attempt_at": "…", "window_days": 30 },
      "l1_purchases": [ { "attempted_at": "…", "network": "eip155:8453", "status": "settled", "tx_hash": "0x…", "explorer_url": "https://basescan.org/tx/0x…" } ],
      "record_url": "https://vet402.com/observatory/e/521e929e-…", "method_url": "https://vet402.com/observatory/methodology", "as_of": "…" },
    "content": [ { "type": "text", "text": "{ …the same JSON… }" } ]
  }
}

GET /api/v1/resolve?q=…

Reverse lookup — no key, 60/min. q is read by shape: a URL gives its Resource (resource_id = sha256(method + " " + canonical_url)) and the endpoints on that host; Known limit: an id hashed from a raw URL resolves for the common spellings of a listed URL (trailing slash, host case, :443, query order, HTTPS://, a trailing ? # or //), but not for mixed-case hosts or per-request query values — resolve here first (the MCP pay_if_trusted always does); a domain its endpoints; a 0x / base58 address or a chain:address payee_id the endpoints that declare it as payTo; a tx hash the indexed settlement and, when attributed, its resource. Identifiers only — never a recommendation. A q the classifier cannot place, or a URL that is not an absolute https URL, is a 400 { error: "invalid_query", expected: "q", message, suggestion?, accepted, query } — message says what is wrong in words and suggestion is the corrected q (https:// added). Nothing on record comes back as a 200 with endpoints: [] and not_found: { reason: "not_in_catalog", note, next, listing_sources, documentation }. listing_sources names each source vet402 reads listings from with its sync times in UTC, and documentation links to /faq#not-listed. block_time_source on a tx settlement says which clock block_time uses: chain for a ledger time stored at verification or read from the observed purchase recorded for that verification, attempted_at for the purchase attempt when no ledger time was stored, verified_at for an older row whose block_time is the moment verification finished, null when the row names none of these clocks.

Response

{
  "query": { "kind": "url", "value": "https://api.example.com/v1/quote" },
  "resource": { "endpoint_id": "3f1c…", "resource_id": "9a7e…", "observatory_id": "521e929e-…", "canonical_url": "https://api.example.com/v1/quote", "method": "GET", "payee_id": "eip155:8453:0x…", "catalog_status": "listed", "first_seen": "…", "last_seen": "…" },
  "endpoints": [ { "endpoint_id": "3f1c…", … } ],
  "disclaimer": "Scores are opinions; L0–L2 are measurement records. …"
}

GET /api/v1/resources/:resourceId

One Resource by resource_id (sha256 hex) — no key, 120/min. The record, the payees that resources under it declare, and links to /decision, /facts and the observatory page. 400 invalid_resource_id, 404 not_found.

Response

{
  "resource": { "endpoint_id": "3f1c…", "resource_id": "9a7e…", "observatory_id": "521e929e-…", "canonical_url": "…", "method": "GET", "payee_id": "eip155:8453:0x…", "catalog_status": "listed", "first_seen": "…", "last_seen": "…" },
  "payees": [ { "payee_id": "eip155:8453:0x…", "endpoints": 1 } ],
  "links": { "decision": "/api/v1/resources/9a7e…/decision?role=payer", "facts": "/api/v1/observatory/endpoints/521e929e-…/facts", "observatory": "/observatory/e/521e929e-…" },
  "disclaimer": "…"
}

GET /api/v1/resources/:resourceId/decision?role=payer|payee&payer=…&caller_dialect=v1|v2&allow_without_l1=false&amount_usd=…&max_per_tx_usd=1&min_l1_deliveries=0&require_vet402_allow=true

The ledger column l2_schema stores match, mismatch, no_declaration, or not_checked. The decision document and the facts document store conform, mismatch, undeclared, or not_checked. conform is written when the re-read L2 value (l2_reading) of the latest delivery is match. mismatch is written when that re-read value is mismatch. An older mismatch row with no recorded reason is re-read first, and a cut body reads not_checked. In the other cases the decision word is not_checked when the current declaration names output (a required key or an example property), or when the purchase row of the latest delivery recorded a declaration and its re-read value is not_checked, and undeclared when neither holds. The canonical integration since 2026-09-02 (spec §8.3 / §9.1) — 1 unit per call with a key, or key-less at 10/min per IP with the same body (since 2026-09-07; 429 rate_limited when the window is spent; Idempotency-Key and customer allow/block lists apply to keyed calls only). role=payer (default) answers "what did vet402 record when it paid this URL in the last 30 days?" from L0 liveness, L1 settle-through and L2 conformance. An ALLOW can carry l2_undeclared: the listing declared no output to compare with. role=payee answers "should this seller serve this payer?" and requires payer. facts and recommendation always arrive in the same document; the transitional score is left out of the body unless you pass include_score=1 (0 or 1; anything else is 400 invalid_include_score), and then it carries superseded_by: "recommendation" and is not the basis of the recommendation. facts.l1.last_attempt_at is when we last attempted an L1 purchase against this resource (ISO 8601 UTC, null before the first attempt) — distinct from observed_at, which is when we last paid. spending_halted is vet402's own spending halt: a fact about the measurer, not the seller, and while it is true the L1 facts here are not today's observation. not_attempted_reason (spending_halted | no_eligible_accept) qualifies the reason code l1_not_attempted; it is null for the other ways an attempt ends before the signature, which are published per attempt in the decision ledger rather than summarised here. Rules 2026-11-13.1 (rules_version). Each paid attempt (one L1 purchase row) is sorted by one function, the same one /sellers, /api/v1/sellers/export.csv and this decision read: whether money moved, whether it delivered, and whose side a failure is on. Money moved when the payment settled on-chain, or when a settle_failed row carries a transaction vet402 verified; it did not move when vet402 signed nothing or the transaction the seller named was not found; otherwise it is not yet known. The outcome is one of: delivered (settled on-chain, then a 2xx other than 202 Accepted, with a non-empty body); pending (not yet known whether money moved: a receipt awaiting on-chain verification, a 2xx naming a transaction, or a signed attempt that failed less than 90 minutes ago while vet402 links late settlements; the ledger status is left as recorded); seller (the seller's side: vet402 can show from the row that the failure was not its own, and such a failure, of any kind, was seen at the listing on 2 or more different UTC days); vet402 (vet402's side: its wallet was short, it did not send the declared input, or one of its own limits or errors); unsorted (not sorted: held for vet402's own request shape, no charge taken, a paid answer with no result in it, seen on one UTC day so far, or vet402 cannot show the failure was not its own); not_bought (vet402 did not sign a payment, so it is not a purchase result). An attempt counts for or against the seller only when it delivered or is on the seller's side; every other signed attempt is left out with an l1_not_counted_* code (l1_not_counted_held counts held rows except seller_fixable_input, plus a receipt awaiting on-chain verification whose held_reason is empty). A settled 400 or 422 whose paid request carried every declared input and whose bodyHead parses as JSON with a structured input validation error (FastAPI/pydantic detail[].loc, zod issues[].path, or errors[].field|path|param with in or location on body or query, not free text, with every cited location on the request input, not payment, headers, cookies or authentication) is counted on the seller's side once seen on 2 different UTC days. In the decision it counts as a seller-side attempt (l1_latest_failed or l1_never_delivered, WARN) but never as l1_paid_not_delivered and never toward a BLOCK. /api/v1/sellers/export.csv follows /sellers. The ledger export.csv does not change. L1: after the last delivery in the 30-day window, an attempt that moved money and did not deliver adds l1_paid_not_delivered; it is a BLOCK only when two or more of them are on the seller's side (seen on 2 different UTC days), and a WARN otherwise, including when /sellers leaves them not sorted. A failure where no money moved is at most a WARN (l1_never_delivered when nothing was ever delivered). When no result came back and it is not yet known whether money moved (an empty 2xx with no settlement linked, a receipt still awaiting on-chain verification, or a signed attempt that failed less than 90 minutes ago), l1_empty_2xx_settlement_unknown makes it a WARN, never an ALLOW, until it settles. l1_latest_failed (something delivered, but the latest counted attempt did not) is a WARN. ALLOW needs a delivery within 30 days (l1_basis.fresh_days); otherwise l1_stale and a WARN, also the word when the only delivery is older than the window or every signed attempt is (l1_not_attempted means none was ever signed). l1_basis carries the counts, the last attempt, signed attempt and delivery, and the days since each. L0: l0_fail (two measured failures in a row) is a BLOCK. l0_unverified (not probed yet, no probe in the last 30 days, a URL with a path parameter vet402 cannot fill, a probe that could not complete, or one failure so far) is a WARN with degraded: true, next to l0_unverified_<cause>; unverified is not a failure. L2: l2_mismatch is a BLOCK when the evidence names missing keys (mismatch_kind missing_keys), otherwise l2_mismatch_unexplained and a WARN. L2 reads the latest delivery at any age, so a mismatch holds until a later delivery replaces it. A listing that declares an output schema but whose latest delivery vet402 has not checked against it (no delivery, a body over 256 KiB or cut off) is l2_not_checked, a WARN; l2_undeclared means neither the catalog nor the purchase row of the latest delivery gives an output declaration to check against. l2_conform_no_required_keys next to l2_conform means the declaration marks no key as required, so vet402 checked that the body is a JSON object or array, not which keys it carries. verified_terms.amount is in 6-decimal ledger units: multiply it by 10^12 on BNB Chain and by 10 on Stellar before comparing it with a 402. Every code, its effect and whether the seller can fix it: https://vet402.com/docs/api#reason-codes. Limit of the method: vet402 buys with a fixed, published User-Agent, so a seller could answer vet402 differently from other buyers; other buyers' settlements show that they paid, not that they received, so the decision cannot detect this. verified_terms (since 2026-09-29, role=payer) carries the terms vet402 actually paid on the last purchase whose delivery it confirmed: pay_to, asset, amount (base units, 6 decimals), network, scheme, protocol and verified_at, or null when nothing was ever delivered, and null on XRPL until the issuer is recorded (a currency code alone cannot tell RLUSD from a look-alike). 0x addresses are lowercase. Compare it with the 402 you received before you pay: if the 402's payTo, asset, network or scheme differ from verified_terms, or its amount is higher, do not pay. An ALLOW is the fact that these terms delivered; it is not a guarantee for a different recipient or a higher price. Each row of evidence[] names the ledger it was observed in: evidence[].source is vet402 for our own L0–L2 record (the row carries purchase_id and a public receipt URL), and subgraph for a row read from The Graph's x402 subgraph. This route emits vet402 rows; the subgraph rows are added to the same array by the payOrRefuse SDK when the caller reads The Graph with their own Graph Gateway API key, and such a row carries subgraphId, block.number, deployment and queriedAt so live index data can be told apart from a static snapshot. Counts from the two sources are kept on separate rows and are not added together — they count different things. Send Idempotency-Key to retry without spending a second unit. Your own policy (role=payer, since 2026-09-07): add amount_usd (what the 402 asks), max_per_tx_usd (your ceiling, default 1) min_l1_deliveries (floor on facts.l1.n_delivered) and/or require_vet402_allow (default true, the SDK's requireVet402Allow) and the same document gains caller_policy — your rule applied server-side, in the payOrRefuse SDK's own words: verdict ALLOW | REFUSE with reason_codes price_above_ceiling, evidence_unavailable, payee_recommendation_block, payee_recommendation_not_allow or insufficient_delivery_evidence, in the SDK's order (ceiling, degraded, BLOCK, not-ALLOW, L1 floor). With require_vet402_allow=true a WARN refuses with payee_recommendation_not_allow, as the SDK's default does; false waives the WARN and needs min_l1_deliveries ≥ 1 or the call is 400 invalid_policy. It sits beside recommendation and never rewrites it; a floor or a waiver never lifts BLOCK or degraded; not_evaluated names what the server did not check (min_subgraph_receipts always — The Graph is read only with your own key). Without those queries the body is unchanged. 400 invalid_resource_id / invalid_role / invalid_caller_dialect / payer_required / invalid_amount_usd / invalid_policy / invalid_evidence_policy, 404 not_found, 503 decision_unavailable. When VET402_RECEIPT_SIGNER_KEY is set, the body also carries receipt. POST /api/v1/receipts/verify checks it. When the variable is unset, receipt is omitted and the rest of the body stays as it is.

Response

{
  "subject": { "type": "resource", "id": "9a7e…", "endpoint_id": "3f1c…", "observatory_id": "521e929e-…", "canonical_url": "…", "method": "GET" },
  "role": "payer",
  "payer": null,
  "recommendation": "ALLOW",
  "reason_codes": ["l0_pass", "l1_delivered", "l2_undeclared"],
  "facts": {
    "l0": { "status": "pass", "observed_at": "…", "dialect": "v2", "fail_reason": null },
    "l1": { "n_delivered": 3, "n_settled": 3, "n_attempts": 3, "n_probe_error": 0, "p50_ms": 812, "p95_ms": 1490, "last_purchase_id": "…", "observed_at": "…", "last_attempt_at": "2026-09-04T19:02:29.789Z" },
    "l2": { "status": "undeclared", "declaration_hash": null, "response_hash": null, "diff_hash": null, "missing_keys": null, "observed_at": null },
    "availability_7d": 1, "availability_30d": 0.97, "offer_stability": "stable",
    "payees": ["eip155:8453:0x…"],
    "settlement_30d_real": 41, "settlement_30d_raw": 44, "settlement_30d_test": 3, "settlement_30d_measurement": 0,
    "unique_payers_30d_real": 9, "wash_dominated": false
  },
  "spending_halted": false,
  "not_attempted_reason": null,
  "freshness": { "l0": "…", "l1": "…", "l2": null },
  "l1_basis": { "window_days": 30, "fresh_days": 30, "n_counted": 3, "n_not_counted": 0, "n_paid_undelivered": 0, "n_paid_undelivered_since_last_delivery": 0, "latest_counted_delivered": true, "last_attempt_at": "2026-09-27T12:00:41.102Z", "last_signed_attempt_at": "2026-09-27T12:00:41.102Z", "last_delivered_at": "2026-09-27T12:00:41.102Z", "days_since_last_attempt": 1.5, "days_since_last_delivery": 1.5 },
  "verified_terms": { "purchase_id": "eip155:8453:0x…", "pay_to": "0x…", "asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "amount": "10000", "decimals": 6, "network": "eip155:8453", "scheme": "exact", "protocol": "x402", "verified_at": "2026-09-27T12:00:41.102Z" },
  "evidence": [ { "level": "L0", "url": "https://vet402.com/observatory/e/521e929e-…" }, { "level": "L1", "purchase_id": "…", "url": "https://vet402.com/api/v1/observatory/endpoints/521e929e-…/purchases" } ],
  "degraded": false,
  "policy": "allow_only",
  "caller_policy": { "applied": { "amount_usd": 1.5, "max_per_tx_usd": 1, "min_l1_deliveries": 0, "require_vet402_allow": true }, "verdict": "REFUSE", "reason_codes": ["price_above_ceiling"], "not_evaluated": ["min_subgraph_receipts"] },
  "rules_version": "…",
  "registry": { "status": "off", "tx_hash": null },
  "scoredAt": "…", "cacheExpiresAt": "…",
  "disclaimer": "…"
}

GET /api/v1/endpoints/:endpointId

One Endpoint by endpoint_id (sha256 of origin + path prefix) or its observatory uuid — no key, 120/min. Existing /observatory/e/{id} links keep resolving. 400 invalid_endpoint_id, 404 not_found.

Response

{
  "endpoint": { "endpoint_id": "3f1c…", "resource_id": "9a7e…", "observatory_id": "521e929e-…", "canonical_url": "…", "method": "GET", "payee_id": "eip155:8453:0x…", "catalog_status": "listed", "first_seen": "…", "last_seen": "…" },
  "payees": [ { "payee_id": "eip155:8453:0x…", "endpoints": 1 } ],
  "links": { "facts": "/api/v1/observatory/endpoints/521e929e-…/facts", "payees": "/api/v1/endpoints/3f1c…/payees", "observatory": "/observatory/e/521e929e-…" },
  "disclaimer": "…"
}

GET /api/v1/endpoints/:endpointId/payees

endpoint → payees[] — no key, 120/min. Every payee_id (chain:address) declared by resources under the same endpoint_id, with how many resources name each.

Response

{ "endpoint_id": "3f1c…", "payees": [ { "payee_id": "eip155:8453:0x…", "endpoints": 2 } ], "count": 1, "disclaimer": "…" }

GET /api/v1/payees/:address/endpoints

payee → endpoints[] — no key, 120/min. :address is chain:address (EVM lowercased, Solana base58 as-is); a bare 0x address is read as Base, a bare base58 address as Solana mainnet. 400 invalid_payee_id.

Response

{ "payee_id": "eip155:8453:0x…", "endpoints": [ { "endpoint_id": "3f1c…", "canonical_url": "…", "method": "GET", "catalog_status": "listed", … } ], "count": 1, "disclaimer": "…" }

GET /api/v1/observatory/endpoints/:id/facts

L0–L2 seller facts for one endpoint — no key, 120/min. :id is the observatory uuid or the endpoint_id. The same facts object /decision carries, without the recommendation: this route contains no score and no verdict by design (§8.3). n_probe_error counts attempts where our own request was malformed, kept apart from the seller's non-delivery; settlement_30d_test is vet402's own measurement purchases, disclosed and excluded from the wash_dominated denominator. settlement_30d_measurement counts payers on the published measurement list (wash_flag measurement) and is left out of settlement_30d_real and out of that denominator. The list is at https://vet402.com/observatory/methodology#measurement-payers. The ledger column l2_schema stores match, mismatch, no_declaration, or not_checked. The decision document and the facts document store conform, mismatch, undeclared, or not_checked. conform is written when the re-read L2 value (l2_reading) of the latest delivery is match. mismatch is written when that re-read value is mismatch. An older mismatch row with no recorded reason is re-read first, and a cut body reads not_checked. In the other cases the decision word is not_checked when the current declaration names output (a required key or an example property), or when the purchase row of the latest delivery recorded a declaration and its re-read value is not_checked, and undeclared when neither holds.

Response

{
  "subject": { "type": "resource", "id": "9a7e…", "endpoint_id": "3f1c…", "observatory_id": "521e929e-…", "canonical_url": "…", "method": "GET" },
  "facts": { "l0": {…}, "l1": {…}, "l2": {…}, "availability_7d": 1, "availability_30d": 0.97, "offer_stability": "stable", "payees": ["…"], "settlement_30d_real": 41, "settlement_30d_raw": 44, "settlement_30d_test": 3, "settlement_30d_measurement": 0, "unique_payers_30d_real": 9, "wash_dominated": false },
  "freshness": { "l0": "…", "l1": "…", "l2": null },
  "evidence": [ { "level": "L0", "url": "…" }, { "level": "L1", "purchase_id": "…", "url": "…" } ],
  "disclaimer": "…",
  "retrievedAt": "…"
}

GET /api/v1/census/summary?chain=eip155:8453&window=30d

Settlement census — no key, 60/min, cached 5 minutes. settlements_raw counts every indexed x402-related settlement in the window; settlements_real excludes wash_flag self_deal / circular / test (including every wallet vet402 pays from) and measurement (payers on the published list of other measurement or inspection services, counted in wash.measurement). Both are always returned together and never merged. chain is CAIP-2 or a v1 slug (base, solana); omit for all chains. window is 7d or 30d. indexed_since reports how far back the index actually reaches, overall and per chain, so window_covered_days is never more than window_requested_days and the counts are a floor rather than a total whenever window_fully_covered is false. 400 invalid_window / invalid_chain.

Example of the shape only

These counts are not a reading. Fetch the URL and cite retrievedAt from that response.

{
  "chain": "eip155:8453", "window": "30d",
  "settlements_raw": "<count>", "settlements_real": "<count>",
  "wash": { "self_deal": "<count>", "circular": "<count>", "test": "<count>", "measurement": "<count>" },
  "attribution": { "confirmed": "<count>", "probable": "<count>", "unmatched": "<count>" },
  "unique_payers_raw": "<count>", "unique_payers_real": "<count>", "unique_payees_real": "<count>",
  "endpoints_with_real_settlement": "<count>",
  "by_source": { "l1_purchase": "<count>", "payments_api": "<count>", "chain_index": "<count>" },
  "indexed_since": {
    "all": "<YYYY-MM-DD>", "byChain": { "eip155:8453": "<YYYY-MM-DD>" },
    "all_chains_since": "<YYYY-MM-DD>",
    "window_requested_days": "<days>", "window_covered_days": "<days>", "window_fully_covered": "<boolean>",
    "note": "indexed_since.all is the oldest UTC day this index holds …"
  },
  "definition": "settlements_raw counts every indexed …",
  "disclaimer": "…",
  "retrievedAt": "<ISO-8601>"
}

GET /api/v1/observatory/corrections?endpoint=…&reason=…&limit=100&cursor=…

The correction log as JSON — no key, 60/min. Every published verdict that later changed: dispute_remeasure (a seller's signed dispute triggered a re-measurement that overturned it), settlement_backfill (a settlement moved up or down the ledger on on-chain evidence), reverify (a re-verification overturned it; since 2026-09-29 also a re-probe of a published L0 fail that rested on probes recorded under the earlier rules, after.trigger=rule_change_reprobe), path_template. before / after are the published values; corrections unfavourable to vet402 are listed the same way and rows are never deleted. endpoint filters by observatory uuid; reason filters by reason; limit 1–500 rows a page. Since 2026-09-29 every row can be read: page.nextCursor, passed back as cursor, returns the next older page (null on the last page), and total counts the rows matching endpoint and reason whatever the cursor — ?reason=settlement_backfill gives the 'Ledger status changes' count on /corrections. For a purchase row, subject_id is the purchase_id column of the ledger export; settlement_path names the path a settlement_backfill row took (verified_settled, claim_refuted, seller_named_tx_promoted, vet402_index_link, late_link_withdrawn, seller_named_tx_declined, other). 400 invalid_reason / invalid_cursor.

Response

{
  "corrections": [ { "id": "…", "subject_type": "purchase", "subject_id": "c4d4b20f-…", "level": "l1", "before": { "status": "settle_claimed" }, "after": { "status": "settled", "blockNumber": "…" }, "reason": "settlement_backfill", "dispute_id": null, "created_at": "…", "settlement_path": "verified_settled" } ],
  "total": 312,
  "page": { "limit": 100, "returned": 100, "nextCursor": "MjAyNi0w…", "order": "created_at DESC, id DESC", "howToPage": "…" },
  "totalDefinition": "…",
  "definition": "Each row is a public verdict that changed after publication: …",
  "disclaimer": "…"
}

GET /api/v1/observatory/l0/export.csv

The latest public L0 verdict per endpoint, as CSV — no key, 6/min, cached up to 15 minutes. One row per endpoint on record (active or not, excluding endpoints paying vet402's own addresses): the data behind the L0 counts of /api/v1/observatory/state. Columns: endpoint_id, resource_key, network, chain, network_class (mainnet | testnet | unclassified), listed, published_verdict (pass | fail | unverified | withdrawn), latest_probe_verdict, last_probed_at, latest_probe_older_than_7d. Counting published_verdict=pass gives publishedPass. Counting published_verdict=withdrawn gives withdrawnFail, a delisted fail streak left out of publishedFail. network_class=mainnet rows grouped by chain give byChain. Definitions in the x-vet402-column-notes header, retrieval time in x-vet402-retrieved-at. Since 2026-09-29.

Response

endpoint_id,resource_key,network,chain,network_class,listed,published_verdict,latest_probe_verdict,last_probed_at,latest_probe_older_than_7d
521e929e-…,api.example.com/v1/x,eip155:8453,Base,mainnet,true,pass,pass,2026-09-29T10:31:02Z,false

GET /api/v1/sellers/export.csv

The /sellers classification, as CSV — no key, 6/min, cached up to 15 minutes, served noindex like the page. One row per active Base listing (records opened for a seller's re-test request included), classified by its latest purchase row with the same function /sellers uses; counting rows by outcome gives the page's totals. Columns: host, endpoint_id, resource_key, outcome (delivered | pending | seller | vet402 | unsorted | not_bought | not_tried), fix_mode, side_label, confirmed_seller, held_reason, latest_attempted_at, latest_status, latest_network, latest_http_status_paid, purchase_id (the ledger export's purchase_id), in_ledger_export, attribution_review (under_review on seller rows, whose attribution is under review). Definitions in the x-vet402-column-notes header, retrieval time in x-vet402-retrieved-at. Since 2026-09-29.

Response

host,endpoint_id,resource_key,outcome,fix_mode,side_label,confirmed_seller,held_reason,latest_attempted_at,latest_status,latest_network,latest_http_status_paid,purchase_id,in_ledger_export,attribution_review
api.example.com,521e929e-…,api.example.com/v1/x,delivered,,,,,2026-09-29T00:02:29Z,settled,eip155:8453,200,c4d4b20f-…,true,

GET /api/v1/delivery/sellers.json

Delivery feed: one row per active listing with at least one signed purchase in the last 30 days. Key-less, 30/min, process cache 300 seconds. CC-BY-4.0. Filters: network, min_delivered (0 to 100000), listed_in (bazaar | circle | mpp), cursor. See /docs/mcp#feed.

Response

{
  "as_of": "2026-10-28T12:00:00Z",
  "window_days": 30,
  "method_url": "https://vet402.com/observatory/methodology",
  "license": "CC-BY-4.0",
  "license_url": "https://creativecommons.org/licenses/by/4.0/",
  "row_count": 1,
  "total_rows": 1,
  "next_cursor": null,
  "rows": [{
    "url": "https://api.example.com/v1/data",
    "method": "GET",
    "network": "eip155:8453",
    "price_usd": 0.007,
    "listed_in": "bazaar",
    "bazaar_payers_30d": 12,
    "paid_attempts_30d": 3,
    "delivered_30d": 2,
    "settled_not_delivered_30d": 0,
    "last_attempt_at_30d": "2026-10-27T00:00:00Z",
    "last_delivered_at_30d": "2026-10-26T00:00:00Z",
    "record_url": "https://vet402.com/observatory/e/00000000-0000-0000-0000-000000000001"
  }]
}

POST /api/v1/events/:trustEventId/outcome

Report a result label for a past score verdict this key requested. outcomeType is confirmed_fraud, confirmed_legitimate, chargeback_dispute, or other. Repeating the same outcomeType for the same event returns the existing row. A trustEventId this key does not own answers 404, the same answer as an unknown id. Bearer key.

Request body

{ "outcomeType": "confirmed_legitimate" }

Response

HTTP 201 when the label is new. HTTP 200 when this key already reported that outcomeType for this event.

DELETE /api/v1/webhooks/:id

Remove a webhook endpoint this key owns. An id this key does not own answers 404. Bearer key.

Response

HTTP 200

DELETE /api/v1/watchlist/:id

Remove a watch this key owns. An id this key does not own answers 404. Bearer key.

Response

{ "ok": true }

POST /api/v1/demo/verify

One live L0 probe of a catalog-listed endpoint, for /playground. The probe is not written into the public register. level l1 asks for one real purchase and runs when DEMO_L1_ENABLED and the Observatory L1 flag are both on. While the runtime spending halt is engaged, level l1 answers 503 spending_halted and signs nothing. Key-less. 5 requests per minute per IP.

Request body

{ "endpointId": "<uuid>", "level": "l0" }

Response

HTTP 200 with the live measurement.

GET /api/frame/endpoint/:id

Farcaster Frame HTML for one endpoint receipt record. An unknown id answers 404. Key-less.

Response

text/html

POST /api/v1/observatory/contributions

Submit an externally observed L0 result. Intake answers 403 unless an operator flag is on. A stored row is not folded into a published verdict. Key-less. 10 requests per minute per IP.

Request body

{ "endpointId": "<uuid>", "verdict": "pass", "address": "0x…", "signature": "0x…", "issued": "<RFC3339>" }

Response

HTTP 201 when stored. HTTP 403 when intake is off.

GET /api/v1/observatory/export.csv

Paid-attempt ledger as CSV. days is an integer from 1 to 366; without it the window is the last 90 days. The ledger starts at 2026-08-14T09:54:31Z, so from 2026-11-12T09:54:31Z the default window no longer reaches its first rows; ask for days=366 to read the whole ledger. A non-canonical days value answers 308 to the canonical query. Another query parameter, or days given twice, answers 400 invalid_query. Output stops at 50000 rows and sets x-vet402-truncated. A cell whose text starts with =, +, -, @, a tab, or a carriage return is written with a leading apostrophe. Key-less. 6 requests per minute per IP. Columns in order: attempted_at, resource_key, network, status, amount_units, spent_units, tx_hash, http_status_paid, latency_ms, l2_schema, held_reason, request_body, request_body_sha256, settlement_source, request_query, request_query_sha256, confirmed_units, purchase_id, l2_reading.

Response

attempted_at,resource_key,network,status,amount_units,spent_units,tx_hash,...

GET /api/v1/graph/payto/:address

Endpoints one payTo wallet receives on, and other payTo values receiving on the same hosts. Adjacency facts. Bearer key. 1 unit.

Response

HTTP 200. Graph with definition in the body.

GET /api/v1/observatory/anchors

One hash root per UTC day over that day's L1 rows. An older root describes the ledger as it stood when the root was computed. A missed day stays without a root and is listed in missingHeadDays as no_head_for_day. dayVerification checks the newest day that a later day links to, from the day file, the published root and the next day's prevRoot. Query days from 1 to 366, default 30. No key needed. 30 requests per minute per IP.

Response

HTTP 200 while the newest root is one or two days old. HTTP 503 anchors_head_stale with the same body when it is older. Anchors newest day first, with the recompute recipe.

GET /api/v1/observatory/anchors/day

The canonical JSON of one sealed day, byte for byte. sha256 of the previous root, a newline and this body equals that day's root in /anchors and the prevRoot of the next sealed day. A day with no sealed root answers 404 no_head_for_day. 10 requests per minute per IP. CC-BY-4.0.

Response

HTTP 200. The day JSON. x-vet402-day-root, x-vet402-prev-root, x-vet402-chain-root and x-vet402-day-verification headers.

GET /api/v1/observatory/ledger-corrections

Ledger corrections. Rows are added and not rewritten. Each row is self or third_party. A third_party row carries signerAddress, signature and signedMessage. signatureValid is checked again on each read, so anyone can repeat the check with no key. POST adds a row with a bearer token (self) or a signature from an allowed address (third_party). A row that cites citedRowId gets priorRowSha256, the SHA-256 of that row in the day file, read from the ledger. Filing a correction leaves the cited row as it is. CC-BY-4.0.

Response

HTTP 200. corrections, counts by kind, license and cite.

GET /api/v1/observatory/traceable-by-chain

For each of ten chains (Base, Solana, Tempo, XRPL, Celo, Arc, Stellar, BNB Chain, Arbitrum One and Avalanche C-Chain), the latest settled purchase in the same fields as the home page example, with its tx and a public explorer link. nonce_bound and amount_payee_only are separate lists, and each row names its settledTier. A Circle Gateway row inside a shared batch transaction is not listed. A chain with no such purchase is named in chainsWithoutExample. No API key. 30 requests per minute per IP. CC-BY-4.0.

Response

HTTP 200. chains, lists.nonce_bound, lists.amount_payee_only, chainsWithoutExample, home, moreThanHomeAlone, license and cite.

GET /api/v1/rwa/facts/:address

Stock Token track record of one address on Robinhood Chain (eip155:4663), rebuilt from public chain data. The response field scope states what was scanned. Key-less.

Response

HTTP 200. Facts JSON. scope states the scan. gaps names what was not scanned.

GET /api/v1/rwa/paid/facts/:address

The same JSON as GET /api/v1/rwa/facts/:address, paid per call over x402 v2 in USDG on Robinhood Chain (eip155:4663). Without a PAYMENT-SIGNATURE header the answer is 402. Asset 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168, amount 10000 base units (0.01 USDG), payTo 0x973cD8a91A771C2C04C6036888F8175D6b4F6227, facilitator Dexter. Key-less aside from that payment header.

Response

HTTP 200 with the facts JSON after a valid payment. HTTP 402 with PAYMENT-REQUIRED when the header is absent.

POST /api/v1/observatory/endpoints/:id/subscribe

Leave an email on one endpoint record. kind notify starts pending until POST /api/v1/observatory/subscriptions/confirm. Opening the confirm link changes nothing by itself. kind dispute records an objection (reason, 20 to 2000 characters) and forwards it to support. website is a honeypot and is left empty. Key-less. 5 requests per hour per IP.

Request body

{ "email": "person@example.com", "kind": "notify", "website": "" }

Response

{ "ok": true, "receipt": "<8 chars>", "kind": "notify", "verdictAtSubmission": "pass", "note": "…" }

POST /api/v1/observatory/subscriptions/confirm

Confirm a pending change-notice subscription. The button on /observatory/confirm POSTs the token. Opening the email link does not confirm. The token is single-use and valid for 72 hours from the confirmation email. Key-less. 30 requests per hour per IP.

Request body

{ "token": "<token>" }

Response

{ "ok": true, "status": "confirmed" }

POST /api/v1/observatory/subscriptions/unsubscribe

Stop change-notice email for one subscription. The page POSTs JSON with id and token. A mail client can POST List-Unsubscribe=One-Click with id and t in the query (RFC 8058). There is no GET. Key-less. 30 requests per hour per IP.

Request body

{ "id": "<uuid>", "token": "<token>" }

Response

{ "ok": true, "status": "unsubscribed" }

GET /api/v1/observatory/backtest

vet402 signed purchase attempts, split into avoided (signalled attempts that did not settle) and forgone (signalled attempts that settled). Both figures are in the body with definition. Key-less. 12 requests per minute per IP. Cached 1 hour.

Response

HTTP 200. attemptsTotal, avoided, forgone, and definition.

POST /api/v1/observatory/disputes

File a dispute for one endpoint. The payTo key signs an EIP-191 message over endpointId, subject (l0, l1, or listing), reason, address, and issued. issued is within 10 minutes of server time. Acceptance runs one L0 re-measurement under the publication gate. Key-less. 5 requests per minute per IP.

Request body

{ "endpointId": "<uuid>", "subject": "l0", "reason": "…", "address": "0x…", "signature": "0x…", "issued": "<RFC3339>" }

Response

HTTP 201 when accepted. HTTP 403 when the signer is not the endpoint payTo.

GET /api/v1/observatory/live

Server-sent events for new L0 probes and L1 purchases. The stream closes after about 55 seconds and a client reconnects with Last-Event-ID. Key-less. 10 connections per minute per IP.

Response

text/event-stream. Event names are probe and purchase.

POST /api/v1/observatory/requests

Queue one listed endpoint for the next L0 run. Repeated requests for one endpoint stay one row. Key-less. 5 requests per minute per IP.

Request body

{ "endpointId": "<uuid>" }

Response

HTTP 201 when queued. HTTP 200 when that endpoint was already queued.

GET /api/v1/payees/:address/history-flags

History predicates for one payTo wallet, with counts and definition in the body. Bearer key. 1 unit.

Response

HTTP 200. Flags, counts, and definition.

GET /api/v1/payees/:address/verdict-fast

Deprecated. Returns a cached verdict for a payTo wallet, or cache_cold when the cache has no confident verdict. This route does not compute a fresh score. Bearer key. 1 unit.

Response

HTTP 200. Branch on hit or cache_cold. The body carries deprecated and deprecation.

POST /api/v1/waitlist

Store an email and an interest of premium_data, design_partner, or other. This route sends no mail. Key-less. 5 requests per minute per IP.

Request body

{ "email": "person@example.com", "interest": "premium_data" }

Response

HTTP 201 when recorded.

GET /api/v1/observatory/decisions

Pay-or-refuse decisions recorded by the scheduled L1 run. Refusals before signing and outcomes after paying are both included. budget_denied, halted, request_error, in_flight, and payer_unfunded are left out. Query days from 1 to 366, default 30. Key-less. 30 requests per minute per IP. Cached 15 minutes.

Response

HTTP 200. Decision rows newest first, with totals and the cumulative backtest.

GET /api/v1/observatory/concentration

Receiving-structure aggregates over the active catalog, with denominators. No host or payTo is named. Key-less. 12 requests per minute per IP. Cached 1 hour.

Response

HTTP 200. Aggregates with definition in the body.

GET /api/v1/v4/delivery-hit-rate-yardstick

Pre-registered delivery hit rate yardstick: minSample, ledger train or eval dates, fixedAt. Not Gate2 PMF counts. Key-less. 60 requests per minute per IP.

Response

HTTP 200. fixedAt, minSample, timeSplit, license, cite.

GET /api/v1/v4/named-buyer-delivery-forecast?snapshotDate=YYYY-MM-DD&resourceKey=…

F4 v1 dated delivery forecast (名乗る買い手の場合). Training rows come from Stellar roots day snapshots on or before snapshotDate. Key-less. 60 requests per minute per IP.

Response

HTTP 200. snapshotDate, resourceKey, text, trainCount, deliveryRate.

GET /api/v1/observatory/history

L0 and L1 aggregates per UTC day and CAIP-2 chain, oldest day first. l1Attempts uses the paid-attempt denominator of /api/v1/observatory/state. Query days from 1 to 366, default 30. Key-less. 30 requests per minute per IP.

Response

HTTP 200. days, coverageFrom, rolledUpThrough, lastRollupAt, recomputeWindowDays.

GET /api/v1/observatory/state

Aggregate L0 and L1 measurements, from the same readers as /observatory/state. On each byChain row, chain stays the join key. label is the public chain name and network is the CAIP-2 id. withdrawnFail counts catalog-delisted endpoints whose newest probes would publish as fail, and those endpoints are left out of publishedFail. Key-less. 30 requests per minute per IP.

Response

HTTP 200. publishedPass, publishedFail, withdrawnFail, publishedUnverified, and l1.byChain with chain, label, and network.

GET /api/v1/observatory/watch?wallet=0x…

Preview the message this key has signed by the receiving wallet, plus the catalog endpoints paying that wallet. Fetch this message and sign it. Do not build the message locally. Bearer key.

Response

{ "message": "…", "endpointsPayingThisWallet": [] }

POST /api/v1/observatory/watch

Register for endpoint.delisted events on a payee wallet this key has claimed. The body carries the wallet and the EIP-191 signature from the GET preview. Up to 20 wallets per key. Bearer key.

Request body

{ "wallet": "0x…", "signature": "0x…" }

Response

{ "ok": true, "wallet": "0x…" }

DELETE /api/v1/observatory/watch

Remove this key's observatory watch on a wallet. Bearer key.

Request body

{ "wallet": "0x…" }

Response

{ "ok": true }

GET /api/v1/observatory/endpoints/:id/purchases

L1 receipt series for one endpoint: attemptCount, settledCount, and the tx hash on a settled row. The record page reads the same series. Key-less. 30 requests per minute per IP. Cached 10 minutes.

Response

HTTP 200. endpointId, resourceKey, attemptCount, settledCount, and the purchase rows.

GET /api/v1/accuracy

The public accuracy ledger as JSON over a rolling 90-day window. Aggregate counts, with no wallet addresses and no agent ids. Key-less.

Response

HTTP 200. AccuracyReport, including l0, slo, and coverageWeekly.

GET /api/demo/score

Scores one agent chosen server-side, so a caller can see a computed verdict with no key. The request does not choose the agent. When a verdict cannot be computed the body says live false. 10 requests per minute per IP.

Response

HTTP 200. Branch on live. A cached value names cacheExpiresAt.

GET /api/badge/:address

SVG badge stating whether a signed payee claim exists. The badge carries no score. A .svg suffix on the address is accepted. Key-less. 60 requests per minute per IP.

Response

image/svg+xml

GET /api/badge/agent/:agentId

SVG badge for an ERC-8004 agent. A claim exists or it does not, and the badge carries no score. A .svg suffix on the agent id is accepted. Key-less. 60 requests per minute per IP.

Response

image/svg+xml

GET /api/badge/endpoint/:id.svg

SVG receipt badge for one x402 endpoint. The text states paid attempts that settled against paid attempts. An unknown endpoint still answers HTTP 200 with a not-yet-measured badge. The .svg suffix is part of the path. Key-less. 60 requests per minute per IP.

Response

image/svg+xml

GET /api/badge/host/:host.svg

SVG receipt badge for one seller host. Counts settled paid calls in the last 30 days and how many returned HTTP 2xx other than 202, grouped across listings on that host. Hosts on /sellers return HTTP 200; others return 404. Zero settled rows in the window still return 200 with a no-calls line. The .svg suffix is part of the path. Key-less. 60 requests per minute per IP.

Response

image/svg+xml

GET /api/transparency/operator-overrides

Public log of global operator blacklist actions. Each row has wallet, action, reason, and created_at. Customer-scoped lists are not in this response. Key-less.

Response

{ "overrides": [], "count": "<count>", "note": "…" }

GET /api/v1/usage/monthly

Decision call counts made with a key, for one published chain and one closed UTC month. Figures come from keyed decision rows and are stored on the first read after 00:00 UTC on the 3rd day of the next month. Answers are summed by relation. A labeled relation shows the number of owners. Fewer than 3 owners shows <3 and leaves the call count out. An unclassified relation leaves the call count out. Calls made without a key are not counted per chain. The response leaves out the key, the email address, and the name. chain eip155:42220 is published. month is YYYY-MM from 2026 through 2100. Key-less. 60 requests per minute per IP.

Response

HTTP 200. chain, month, relations. HTTP 400 when chain or month is missing, unreadable, outside 2026 through 2100, still open, or not yet at the 3rd day of the next month. HTTP 503 when the usage tables could not be read.

GET /api/health

Liveness for an uptime poller. The public body is {"status":"ok"}, degraded, or error, from a probe of both scoring engines. degraded and error answer HTTP 503. ?deep=1 returns per-check detail and requires the admin bearer token. Without that token it answers 403. The plain call is key-less.

Response

HTTP 200 when the status is ok. HTTP 503 when the body is degraded or error.

Score breakdown

Every scored verdict (agent and wallet endpoints, and each element of a batch) carries a breakdown object that decomposes the chain score into its four weighted components. It is derived from the same numbers the verdict used, so it can never disagree with trustScore.

  • components — each of identity, reputation, wallet, x402 reports its 0–100 score, its weight, and its contribution (score × weight ÷ 0.8; the four contributions sum to weightedSubtotal). Weights are identity 0.05, reputation 0.10, wallet 0.25, x402 0.40 — divided by 0.8 because the customer whitelist/blacklist is a policy layer, not a signal. Weighted toward the signals that are hardest to fake: a real settled x402 payment history counts most, self-asserted identity and reputation least. ALLOW additionally requires verifiable on-chain evidence — self-assertion alone is capped below ALLOW.
  • weightedSubtotal — the weighted average of the four components, before any sybil adjustment.
  • sybilPenalty — points removed by sybil / data- availability flags (always ≤ 0). The specific flags are in signals.sybil.flags.
  • prePolicyScore — weightedSubtotal + sybilPenalty, clamped to 0–100. This equals trustScore unless a manual list moved it, in which case manualOverride is true. The manual layer is deliberately kept out of the breakdown so the chain-derived explanation stays separable from policy.

Hard-blocked verdicts (wallet mismatch, unregistered agent) omit breakdown — no weighting ran — and carry a blockReason instead. Treat the field as optional.

Payee score

GET /api/v1/payees/:address/score — and the public page at /payee/:address — runs a different engine from the agent/wallet endpoints above and carries no breakdown object. It weighs three tracks, and the weights shift with how much receiving history the wallet actually has, because a cold wallet cannot be judged on a track record it does not have.

Payee score component weights by data depth
dataDepthMeansReceivingWallet healthDrain pattern
thinunder 3 payments received, or from under 2 distinct payers — and under 3 vet402-verified deliveries over 2+ days15%45%40%
moderate3+ payments received from 2+ distinct payers, or 3+ vet402-verified deliveries over 2+ days35%35%30%
rich10+ payments received across 7+ days from 3+ distinct payers, or 10+ vet402-verified deliveries across 7+ days50%25%25%
  • What lifts a payee out of “thin” is an observed fact, never a claim. Two facts count, and only these two. Owner-signed x402 settlements from payers whose funding sources are independent (a cluster funded by one address counts as one payer). And vet402’s own L1 purchases in which the observatory paid the address, confirmed the settlement on chain and confirmed the purchased resource was delivered — counted by deliveries and by distinct days, because the buyer is always vet402 itself and a seller cannot forge or schedule those purchases. Until one of those facts exists, the score is capped one point under ALLOW no matter how healthy the wallet looks. A payment vet402 made that never settled, or settled without delivery, adds nothing here (it is disclosed as l1PaidNeverSettled and can only lower the ceiling).
  • The raw inputs are in the response. signals.receiving reports paymentCount / uniqueDays / distinctPayers, signals.walletHealth reports ageDays / txCount / isBurner, and signals.drainPattern reports the in/out counts and ratio — each with its own 0–100 score, so the weighted arithmetic above can be re-run from the payload.
  • degraded: true is a refusal, not a reading. It means an input could not be read at all. Callers receive a fail-closed BLOCK; the public page prints “Not verifiable right now” rather than a number, because a specific accusation against a named wallet must not rest on an upstream outage.dataDepth answers a different question — how much history exists — and a data-poor wallet read completely is not the same thing.
  • Outcomes adjust the score after weighting. signals.outcomeHistory carries the outcome types on record and the points they moved, which is the same ledger /accuracy aggregates.

Webhooks

vet402 is otherwise a pull API. Webhooks turn it into a monitoring service: register an endpoint once and we POST you a signed event when something you care about changes — most importantly a watched target whose verdict moved (e.g. an ALLOW you gated a payment on becoming a BLOCK). Register with POST /api/v1/webhooks (above); up to 5 endpoints per key.

Events

Webhook event types and their payloads
EventFires whendata fields
watch.verdict_changedA watchlist target's recommendation changes on a re-scan (daily cron). Verdict changes only — not score jitter.watchId, targetType, target, chainId, previous{score,recommendation}, current{score,recommendation}
outcome.recordedAn outcome (auto-detected or partner-reported) lands on a verdict you requested.trustEventId, outcomeType, source, wallet, agentId
list.changedYour own manual whitelist/blacklist changes (also on import) — a team audit trail.action, wallet, listType
endpoint.delistedAn x402 endpoint paying a wallet you claim-proved via POST /api/v1/observatory/watch vanished from the public discovery catalog on a complete fetch (daily observatory sync). Factual listing-state notice, not an operator assessment.resourceKey, resourceUrl, payTo, detectedOn, lastSeenAt, historyUrl

A score is never pushed — scores are computed on demand and pushing a cached one would invite treating a stale number as fresh.

Delivery payload

Every delivery is a JSON POST with this envelope. id is unique per event — dedupe on it (see idempotency below).

POST https://your-host.example/vouch-hook
Content-Type: application/json
Vouch-Signature: t=1723000000,v1=5f2b…   (hex HMAC-SHA256)
User-Agent: vouch-webhooks/1

{
  "id": "evt_9f8a…",
  "type": "watch.verdict_changed",
  "createdAt": "2026-08-06T09:30:00.000Z",
  "data": {
    "watchId": "…",
    "targetType": "wallet",
    "target": "0x…",
    "chainId": 8453,
    "previous": { "score": 74, "recommendation": "ALLOW" },
    "current":  { "score": 31, "recommendation": "BLOCK" }
  }
}

Verifying the signature

The Vouch-Signature header is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256(secret, `${t}.${rawBody}`) — the timestamp, a literal dot, then the raw request body. Recompute it with your whsec_… secret, compare in constant time, and reject if the timestamp is more than 5 minutes from now (replay guard). The reference implementation is below — copy it as-is.

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret, rawBody, header, toleranceSec = 300) {
  const parts = new Map(header.split(",").map(p => {
    const i = p.indexOf("="); return [p.slice(0, i), p.slice(i + 1)];
  }));
  const t = Number(parts.get("t"));
  const v1 = parts.get("v1");
  if (!Number.isFinite(t) || !v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // replay guard
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(v1);
  return a.length === b.length && timingSafeEqual(a, b);
}

Delivery, retries & idempotency

  • At-most-once, no retry. Each event is delivered once with a 5-second timeout. A non-2xx response or timeout is not re-delivered — it increments a failure counter instead. A 2xx resets that counter to zero. (Design your handler to catch up by polling the watchlist / outcome endpoints, not by relying on redelivery.)
  • Auto-disable. After 20 consecutive failed deliveries the endpoint is disabled to stop wasting egress on a dead URL. Re-create it (POST /api/v1/webhooks) to re-enable — a new secret is issued.
  • Idempotency. Treat id as an idempotency key: store processed ids and ignore a repeat, so a duplicate dispatch (e.g. overlapping cron passes) is a no-op on your side.
  • SSRF safety / redirects. The target URL is re-validated at delivery time and redirects are rejected (a redirect at delivery is an SSRF vector, not a feature). Point the endpoint at its final https URL directly.

Availability

vet402 is an early-stage service run by a single operator. Anyone can create a free key at /signup; there is no waitlist. We publish our real operating posture rather than a contractual uptime figure we can't yet stand behind:

  • No SLA credits during beta. Service is best-effort, with no financial uptime guarantee. When we commit to a numeric target it will be backed by measured operating history — we would rather under-promise than publish a number the way some vendors publish accuracy claims they never measured.
  • Infrastructure. Serverless compute (Vercel), managed Postgres (Neon), and Base RPC. Availability inherits from these providers; there is no independent multi-region failover today.
  • Fail-closed, not fail-wrong. When an upstream (RPC, indexer, settlement store) is unavailable, the affected signal is marked with an *_unavailable flag and penalized rather than guessed — a degraded lookup returns a more cautious verdict, not a confidently wrong one. Each response's dataCoverage reports indexer and settlement freshness so you can see what the score could draw on.
  • Monitoring. A public health endpoint, GET /api/health, returns 200/503 for uptime pollers. It probes both scoring engines — seller-side and buyer-side — and reports the worse of the two. 200 means both answered from complete inputs; anything else is 503, including a degraded verdict where the engine still answers but could not read everything (that is what a visitor sees as “Not verifiable right now”). The JSON body carries "degraded" or "error" so you can tell a partial outage from a total one. A deeper env/DB/RPC probe runs on a daily cron and returns 503 only on a critical failure (indexer catch-up lag is reported, not alerted, to avoid backfill alert fatigue).
  • Status & incidents. See /status. During beta, material incidents are communicated to integrators directly. Your own uptime monitor can point at /api/health.

Error codes

HTTP error codes returned by the vet402 API
StatusMeaningDetail
400Bad requestMalformed body/params (e.g. invalid wallet format, empty batch).
401UnauthorizedMissing or invalid API key on the Authorization: Bearer header.
403Forbidden / plan upgrade requirede.g. score history on a plan below Pro.
429Rate limitedTwo causes, told apart by the error string: "rate_limit_exceeded" is the monthly quota (retry next month), "rate_limited" is a one-minute IP throttle (retry after the reported seconds). See Two kinds of 429 above.

Error bodies are shaped as { "error": string, "details"?: object }.

The memoFAQDashboardIntegrations