Building a paid trust-checker, a paid certificate, and a buyer that verifies both
A technical walkthrough of x402-trust-merchant.
Every agent demo shows the same thing: an agent finds an API and pays for it. It is
impressive for about ninety seconds, and then someone in the room asks the only question
that matters — "why would I let it pay for that?"
Because price is not the hard part any more. A 402 with a machine-readable amount is
twenty lines of code. What is missing is the thing a buyer needs before it signs, and
the thing it needs after the bytes arrive:
- before: is this seller the thing it claims to be, and does it publish enough to be buyable?
- after: can I prove to a third party what I received, without asking the seller to still exist?
x402-trust-merchant is a working answer to both, in about 1,400 lines of JavaScript with one
dependency. It is the safer of our two demo variants: the agent buys a commodity from one
stranger, and then buys the trust check and the result from us.
01 DISCOVER 8 candidates for "timezone" bazaar
02 SELECT geospatial.openverbs.com Resolve the timezone… $0.0020
03 PAY geospatial.openverbs.com · $0.0020 · settled 230ms
└ 402 header · x402 v2 · 1 offer(s) · also MPP rail "evm"
04 RESULT Asia/Kolkata +05:30
05 DISCOVER trust check — served by us, not the bazaar owned
06 SELECT our checker · target geospatial.openverbs.com $0.0020
07 PAY our checker · $0.0020 · settled 640ms
08 RESULT verdict: caution · 1/3 · 3 probes timed
09 CHECK ERC-8004 identity #1 on base-sepolia 0x21fd…e235
10 PAY our seller · $0.050 · settled 18ms
11 RESULT certificate 9c1f… · buyer 0x21fd…e235
12 ATTEMPT agent402.tools wants $0.030 for one image over policy
13 REFUSE price above per-call cap · 30000 > 20000 stopped
TOTAL SPENT $0.054 · 2 sellers touched · 2.2s
One external dependency. Two seconds. And still a real 402 from a server nobody in the
room owns, which is what makes the demo credible.
Architecture
┌────────────────────────────────┐
│ run.mjs │
│ goal → find → check → pay │
│ → verify → receipt │
└──────┬─────────────────┬───────┘
│ │
▼ ▼
┌────────────────────┐ ┌───────────────────────┐
│ lib/discovery.mjs │ │ lib/guard.mjs │
│ Bazaar search │ │ Budget.approve() │
└─────────┬──────────┘ │ ./STOP kill switch │
│ └──────────┬────────────┘
▼ │ every signature
┌─────────────────────┐ │
│ lib/x402.mjs │◄────────────┘
│ readChallenge │
│ chooseAccept · sign │
└────┬───────────┬────┘
│ │
┌────────────┘ └──────────────┐
▼ ▼
┌──────────────────────┐ ┌───────────────────────────────────────┐
│ stranger's 402 │ │ seller/server.mjs (:8787) │
│ geospatial.openverbs │ │ /llms.txt │
│ (only external dep) │ │ /.well-known/x402 · schema · agent │
└──────────────────────┘ │ /check/endpoint 2000 μ$ ← ours │
│ /buy/certificate 50000 μ$ ← ours │
└──────────────┬────────────────────────┘
▼
┌───────────────────────────────────────┐
│ checkEndpoint(): 3 probes, in parallel│
│ certificate.mjs: ed25519, offline │
└───────────────────────────────────────┘
The product is a judgement, and it has to be real
A trust check that returns a constant is theatre, and a room full of engineers will spot it
in one second. So /check/endpoint?target=host does actual work: it fetches the three
documents a buyable service is supposed to publish, times each one, and scores what came
back.
{ "target": "geospatial.openverbs.com", "verdict": "caution", "score": 1,
"evidence": [
{ "probe": "llms.txt", "status": 404, "ms": 118, "present": false },
{ "probe": ".well-known/x402", "status": 200, "ms": 141, "present": true },
{ "probe": ".well-known/agent-card.json","status": 404, "ms": 122, "present": false }
] }
score 3 → clean, 1–2 → caution, 0 → unknown. Three documents, three timings,
one verdict, two thousand micro-dollars.
Two engineering details that only matter in front of an audience:
The probes are concurrent. Three sequential four-second timeouts is twelve seconds of
dead air, and nobody watches that on a stage.
const [llms, x402, card] = await Promise.all([
probe(`${base}/llms.txt`),
probe(`${base}/.well-known/x402`),
probe(`${base}/.well-known/agent-card.json`),
]);
The scheme is chosen from the target. Our first version hardcoded https://, which
meant the checker scored our own local seller 0/3 — a bug the contract test caught, and
one that would otherwise have been discovered live. localhost, 127.*, 0.0.0.0 and
[::1] get http, everything else gets https.
That bug is the case for the whole testing section below: the checker is the product, so
its contract is worth asserting.
Publish what you expect others to publish
If we sell trust checks on other people's discovery documents, our own shop has to serve
them. So the seller exposes:
| Route | Purpose |
|---|---|
/llms.txt |
the human-and-agent front door: what we sell, what it costs |
/.well-known/x402 |
machine-readable price list, both paid resources |
/.well-known/schema.json |
typed input and output, versioned 1.0.0
|
/.well-known/agent-card.json |
identity, skills, contact |
/check/endpoint |
2000 μUSDC — the verdict |
/buy/certificate |
50000 μUSDC — the signed result |
{ "x402Version": 2,
"resources": [
{ "resource": "http://localhost:8787/buy/certificate", "price": "50000" },
{ "resource": "http://localhost:8787/check/endpoint", "price": "2000" } ] }
A buyer that can read that document knows what we sell, what it costs and which chain it
settles on, without loading a pricing page. That is the whole difference between "an API"
and "an API an agent can buy".
The money path, briefly
The buyer signs an EIP-3009 TransferWithAuthorization, sends it, and gets bytes plus a
settlement header. Two implementation notes that took real debugging:
The header name is not settled. v1 sellers read X-PAYMENT, v2 read
PAYMENT-SIGNATURE, MPP reads Authorization: Payment. One live seller answers a request
that offers both x402 and MPP with:
400 ambiguous_payment: provide either x402 (PAYMENT-SIGNATURE) or MPP (Authorization), not both.
So the client negotiates instead of guessing:
const attempts = [
{ 'payment-signature': header, 'x-payment': header },
{ 'payment-signature': header },
];
if (challenge.mpp) attempts.push({ authorization: `Payment ${header}` });
The authorisation is single-use. usedNonces makes a replayed credential fail with
authorisation already used instead of minting a second certificate — and the contract
test asserts the status and the reason.
Gate the spend before you sign
if (BigInt(amount) > this.limits.maxPerCall) {
throw new Refused('price above per-call cap', `${amount} > ${this.limits.maxPerCall}`);
}
Refused carries a .reason and a .detail, so the tape prints a sentence instead of a
stack trace:
12 ATTEMPT agent402.tools wants $0.030 for one image over policy
13 REFUSE price above per-call cap · 30000 > 20000 stopped
The cap belongs to the buyer, not the seller. A service that can charge anything will
eventually charge everything; the only durable protection is a client-side gate in front of
the signing function, plus touch STOP for the moment you want everything to stop.
Verify the result offline
The certificate is canonical JSON, hashed, and signed with ed25519:
const { certificate, publicKey } = mint({ buyer: account.address, item, result });
const verification = check(certificate, publicKey); // → { valid: true, digest }
The buyer never calls back. A receipt that requires the seller to still be online is a
promise, not a proof.
Three test suites, no funded wallet required
node test/e2e.mjs # 12 checks — spawns a seller on :8899, ephemeral wallet
node test/contract.mjs # 30 checks — the refusal matrix + the checker's contract
node test/offline.mjs # 3 checks — fixtures mode with fetch replaced by a landmine
The extra three checks in this variant are the ones that matter here: /check/endpoint is a paid call, its verdict carries three timed evidence entries, and pointed at our own seller it scores clean. The last one is what found the https-only bug.
test/offline.mjs deserves a note. It replaces globalThis.fetch with a landmine that throws, then runs --fixtures. If the backup demo ever needs the network, the test fails instead of the stage.
Next
Weighted scoring (a service with a valid price list but no llms.txt is more buyable than one with neither), deeper probes, verdict caching with freshness, refunds for post-payment failure, real facilitator settlement, and exposing find / check / pay as MCP tools so a model can drive the same engine under the same budget guard.
But the smallest useful thing is the one everyone can do: publish the four documents. llms.txt, /.well-known/x402, a typed schema, an agent card. An agent that has to guess what you sell is an agent that will buy from someone else.
Run node run.mjs --fixtures for the whole tape offline, then node test/contract.mjs —
the failure states are the interesting part.
Code & more: https://www.dailybuild.xyz/project/302-x402-trust-merchant

Top comments (0)