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

API reference

/decision and /resolve need no key (10 and 60 requests a minute per IP). 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.

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. 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 vet402's former name (Vouch) so existing keys and configs keep working.

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

SpendGuard answers “may my agent send this payment?” and nothing else. It never touches keys, funds, or signing — execution stays with your wallet stack. Under the default allow-only policy, anything that is not a clean ALLOW denies.

const guard = vouch.createSpendGuard({
  maxPerTxUsd: 10,      // deny any single payment above $10
  dailyBudgetUsd: 50,   // deny once today's allowed total would pass $50
});

const decision = await guard.evaluate({ payee: "0xabc...", amountUsd: 5 });

if (decision.allow) {
  // hand off to AgentKit / Privy / your own signer
} else {
  console.error(decision.reasons);
  // ["payee_recommendation_not_allow"]   verdict was WARN or BLOCK
  // ["payee_score_degraded"]             the score came from a degraded read
  // ["payee_partial_measurement"]        some inputs could not be measured
  // ["payee_trust_unauthenticated"]      your API key is missing or invalid
  // ["payee_trust_unavailable"]          the lookup failed upstream — retryable
}

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-09-29.4)

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

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, 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_<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 purchase evidence is older than 30 days.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.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, 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: a 4xx vet402 attributes to its own request shape, 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_undeclared—The listing declares no output schema.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 cut at 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.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=payeeBLOCKMore than 30% of the payer's recent payments are retry bursts.For the seller: an unusual retry pattern.—
<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.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 and irreversible on x402.
  • 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/42/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/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 }.

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 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 "does this URL deliver as declared, right now?" from L0 liveness, L1 settle-through and L2 conformance; 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-09-29.4 (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 the same kind of failure 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 both kinds together: held, a row with a held_reason in the ledger export, and a receipt awaiting on-chain verification that brought a result, whose held_reason is empty). 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 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 every signed attempt is older than the window (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, 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. 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 the listing declares no output schema. 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.

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,
    "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.

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, "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). 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.

Response

{
  "chain": "eip155:8453", "window": "30d",
  "settlements_raw": 980, "settlements_real": 520,
  "wash": { "self_deal": 12, "circular": 0, "test": 448 },
  "attribution": { "confirmed": 410, "probable": 70, "unmatched": 40 },
  "unique_payers_raw": 31, "unique_payers_real": 24, "unique_payees_real": 57,
  "endpoints_with_real_settlement": 61,
  "by_source": { "l1_purchase": 448, "payments_api": 2, "chain_index": 530 },
  "indexed_since": {
    "all": "2026-08-23", "byChain": { "eip155:8453": "2026-08-23" },
    "all_chains_since": "2026-08-23",
    "window_requested_days": 30, "window_covered_days": 13, "window_fully_covered": false,
    "note": "indexed_since.all is the oldest UTC day this index holds …"
  },
  "definition": "settlements_raw counts every indexed …",
  "disclaimer": "…",
  "retrievedAt": "…"
}

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 (listed 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), latest_probe_verdict, last_probed_at, latest_probe_older_than_7d. Counting published_verdict=pass gives publishedPass; 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, 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. 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
api.example.com,521e929e-…,api.example.com/v1/x,delivered,,,,,2026-09-29T00:02:29Z,settled,eip155:8453,200,c4d4b20f-…,true

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. No hosted status page yet; during beta, material incidents are communicated to integrators directly. Point your own uptime monitor at /api/health in the meantime.

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