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"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"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"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"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
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.
| Code | Verdict | What it means | For the payer | Seller 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_fail | BLOCK | Two 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_unverified | WARN | vet402 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_fail | WARN | The 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> | WARN | Why 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_attempted | WARN | vet402 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_stale | WARN | vet402's purchase evidence is older than 30 days. | Evidence is old; treat as unmeasured until the next re-buy. | No |
l1_inconclusive | WARN | vet402 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_delivered | WARN | Failures 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_delivered | WARN or BLOCK | After 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_failed | WARN | Something 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_unknown | WARN | After 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_checked | WARN | The 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_mismatch | WARN or BLOCK | The 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_unexplained | WARN | A 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_drifting | WARN | The price, asset or payTo changed three or more times within 24 hours. | Compare the 402 you get with verified_terms before paying. | Yes |
wash_dominated | BLOCK | Most 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_mismatch | WARN | The 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_thin | WARN | Little data behind the answer. Reserved: the public route does not set it today. | Treat as a WARN. | No |
operator_blacklist | BLOCK | The counterparty is on your own block list (keyed calls). | Your own rule; vet402 applies it and keeps it out of facts. | No |
sybil_highrole=payee | BLOCK | The 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 | BLOCK | More than 30% of the payer's recent payments are retry bursts. | For the seller: an unusual retry pattern. | — |
<input>_unavailablerole=payee | BLOCK | An input could not be read (settlements_unavailable, funder_index_unavailable or erc8004_unavailable). | For the seller: degraded, so fail-closed. Retry later. | — |
thin_historyrole=payee | WARN | The payer has two or fewer settled payments in 30 days. | For the seller: little history to go on. | — |
shared_funderrole=payee | WARN | The payer's wallet was funded by the same address as other payers. | For the seller: possibly related identities. | — |
new_payerrole=payee | WARN | vet402 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:
| Verdict | Score | What it means | Recommended handling |
|---|---|---|---|
| ALLOW | 70 – 100 | Every check completed and nothing adverse was found. | Proceed. This is the only band the SDK and the middleware pass by default. |
| WARN | 40 – 69 | Not 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. |
| BLOCK | 0 – 39 | Adverse 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, nottrustScore >= 70. A blacklisted address and a degraded verdict areBLOCKregardless 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/sdkand@vet402/middlewaredeny everything that is notALLOWunless 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.
| Plan | Monthly requests |
|---|---|
| Free | 1,000 |
| Pro | 50,000 |
| Scale | 500,000 |
- Quota is per calendar month (UTC) and shared across all keys on an account. Each
/scorecall is 1 unit; a/scores/batchof N agents is N units. Every scored response carriesX-RateLimit-Limit,X-RateLimit-Used, andX-RateLimit-Remainingheaders 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
/decisionsent 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:/decisionwithout 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/accuracy20; the badge SVGs 60; the agent passport 20. Verify endpoints split read from write:GET(message preview) 30/IP, whilePOSTis 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
| Cause | Body | Headers | What to do |
|---|---|---|---|
| Monthly quota spent (authenticated) | error: "rate_limit_exceeded" with retryAfter, usage, limit | X-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,falseGET /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-…,trueScore 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,x402reports its 0–100score, itsweight, and itscontribution(score × weight ÷ 0.8; the four contributions sum toweightedSubtotal). 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 equalstrustScoreunless a manual list moved it, in which casemanualOverrideistrue. 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.
dataDepth | Means | Receiving | Wallet health | Drain pattern |
|---|---|---|---|---|
| thin | under 3 payments received, or from under 2 distinct payers — and under 3 vet402-verified deliveries over 2+ days | 15% | 45% | 40% |
| moderate | 3+ payments received from 2+ distinct payers, or 3+ vet402-verified deliveries over 2+ days | 35% | 35% | 30% |
| rich | 10+ payments received across 7+ days from 3+ distinct payers, or 10+ vet402-verified deliveries across 7+ days | 50% | 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
l1PaidNeverSettledand can only lower the ceiling). - The raw inputs are in the response.
signals.receivingreportspaymentCount / uniqueDays / distinctPayers,signals.walletHealthreportsageDays / txCount / isBurner, andsignals.drainPatternreports the in/out counts and ratio — each with its own 0–100score, so the weighted arithmetic above can be re-run from the payload. degraded: trueis a refusal, not a reading. It means an input could not be read at all. Callers receive a fail-closedBLOCK; 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.dataDepthanswers 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.outcomeHistorycarries 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
| Event | Fires when | data fields |
|---|---|---|
| watch.verdict_changed | A 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.recorded | An outcome (auto-detected or partner-reported) lands on a verdict you requested. | trustEventId, outcomeType, source, wallet, agentId |
| list.changed | Your own manual whitelist/blacklist changes (also on import) — a team audit trail. | action, wallet, listType |
| endpoint.delisted | An 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
idas 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
*_unavailableflag and penalized rather than guessed — a degraded lookup returns a more cautious verdict, not a confidently wrong one. Each response'sdataCoveragereports indexer and settlement freshness so you can see what the score could draw on. - Monitoring. A public health endpoint,
GET /api/health, returns200/503for uptime pollers. It probes both scoring engines — seller-side and buyer-side — and reports the worse of the two.200means both answered from complete inputs; anything else is503, including adegradedverdict 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 returns503only 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/healthin the meantime.
Error codes
| Status | Meaning | Detail |
|---|---|---|
| 400 | Bad request | Malformed body/params (e.g. invalid wallet format, empty batch). |
| 401 | Unauthorized | Missing or invalid API key on the Authorization: Bearer header. |
| 403 | Forbidden / plan upgrade required | e.g. score history on a plan below Pro. |
| 429 | Rate limited | Two 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 }.