API reference
Authenticate with Authorization: Bearer API key. Base URL: https://vet402.com/api/v1
Keys look like vouch_live_… — send them as Authorization: Bearer vouch_live_….
API keys and webhook headers retain the vouch_ / Vouch- prefixes for backward compatibility.
Full machine-readable schema: docs/openapi.yaml on GitHub.
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. - 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. A
429withRetry-Aftermeans the monthly quota is spent (retry after the reported seconds, i.e. next month). - Abuse throttles (IP-based). To blunt key-guessing and scraping, some paths carry an IP burst cap independent of the quota: authentication failures are limited to 60/minute/IP, the unauthenticated demo scorer to 10/minute/IP, and the public
/accuracyand payee-verify endpoints to 20 and 10/minute/IP respectively. Valid authenticated traffic does not hit these.
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.2, "contribution": 25 },
"reputation": { "score": 66, "weight": 0.3, "contribution": 24.75 },
"wallet": { "score": 75, "weight": 0.2, "contribution": 18.75 },
"x402": { "score": 83, "weight": 0.1, "contribution": 10.38 }
},
"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."
}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, no rate limit. The same message is echoed back in a failed POST's expectedMessage field, so you never have to reverse-engineer the format.
Response
{ "message": "Vouch verified payee registration\nwallet: 0x…\nname: Acme API\nThis signature only proves control of the wallet above." }POST/api/v1/payees/verify
Verified payee registration — free, no API key. Sign the canonical message above (fetch it via GET on this same path, or build it yourself: 4 lines, newline-joined — see the response schema) 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://…", "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": "Vouch agent passport registration\nagentId: 42\nwallet: 0x…\nname: Acme Agent\nThis signature only proves control of the wallet above." }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://…", "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": "…" } }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,x402reports its 0–100score, itsweight, and itscontribution(score × weight ÷ 0.8; the four contributions sum toweightedSubtotal). Weights are identity 0.2, reputation 0.3, wallet 0.2, x402 0.1 — divided by 0.8 because the customer whitelist/blacklist is a policy layer, not a signal. - 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.
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 |
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 in closed beta, run by a single operator. 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. 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 | Monthly quota spent (or an IP abuse throttle tripped). Response includes "retryAfter" (seconds); see Rate limits above. |
Error bodies are shaped as { "error": string, "details"?: object }.