A discovery record is an API contract that nothing validates. Your tests exercise your API; nothing exercises the JSON file that tells strangers what your API costs.
We run an x402 resource server, and we finally diffed every machine-readable record we publish against the live 402 responses those records describe. A third of our published prices were 100×–500× below what the gateway actually charges. The published payout address was one we no longer settle to. Our OpenAPI spec pointed buyers at a URL that served the same data for free.
None of this showed up as an outage. Every record returned HTTP 200, well-formed JSON, on time. That's the whole problem with the discovery layer: a record that lies is indistinguishable from a record that works, right up until a buyer acts on it.
The crawler that chased single characters
The audit started with a log shape that made no sense. In one day, one IP requested /n 116 times, /s 87 times, /i 87, /e 87, then a long tail of single-character paths at 58 and 29 hits. 1,189 requests, all 404, all one character long.
The counts are the tell — they're all multiples of 29: 4×29, 3×29, 2×29, 1×29. That's a character-frequency histogram: some string, iterated 29 times. Solving for a string with four n, three each of s/i/e, two each of t/o/l/k/a/w/2/-, one each of x/v/u/r/p/m/j/h/c/4/0 gives exactly one answer:
https://minia2a.uk/.well-known/x402-service.json
(/ and . are absent from the histogram because those requests normalize or redirect instead of 404 — itself a confirmation.)
A client was iterating that URL as if it were a list. And it had a good reason to. We published the field name endpoints in two sibling records:
| Record |
endpoints is… |
|---|---|
/.well-known/x402-service.json |
an array of 82 endpoint objects |
/.well-known/x402 |
a bare URL string |
Any consumer that learned the field from the first record and then read the second gets a string where it expected a collection. In Python or JavaScript, iterating a string doesn't throw — it quietly yields characters. So the crawler fetched /h, /t, /t, /p, /s, and never once reached our catalog.
We had been reading that as crawler noise. It was our schema.
A third of the prices were fiction
Regenerating one record from the other meant actually reading the catalog, which surfaced something worse: 33 of 82 entries had a published price that disagreed with the live 402.
Our gas-price endpoint was published at 0.005 USDC. Its actual challenge:
"accepts": [{
"amount": "500000", // 6-decimal USDC = 0.5, not 0.005
"asset": "0x8335...2913",
"payTo": "0xf16F0882...",
"scheme": "exact"
}]
A hundred times the advertised number. Across the catalog the ratios clustered at 100× (20 entries), 500× (6), 50× (2), plus one each at 300×, 10×, 5×.
Under-pricing sounds like the harmless direction to be wrong in. It isn't. An agent that budgets from your catalog and then meets a paywall demanding 100× more doesn't negotiate — it fails the call or silently overpays. Either way the number you published wasn't a price, it was a guess someone made once and nobody re-checked.
The same audit found the catalog's payment.address naming a wallet the gateway no longer settles to. All 82 live 402s named the current address; the published record named a different one. USDC sent to the address we advertised would not have been credited.
The spec that routed buyers around our own paywall
Most of our services are gateway-proxied, so the published path is ours and the 402 happens at our edge. A handful are seller-hosted: the seller runs the API on their own infrastructure and registers it with us.
For those, our generator published the seller's upstream path, with an operation-level servers override pointing at the seller's host. Legal OpenAPI. Wrong twice over:
-
It 404s for most tooling. Operation-level
serversoverrides are widely ignored; plenty of clients build the URL from the root server and land on a path our domain never served. 16 such 404s in one day from a single indexer. - It bypasses the paywall entirely. We fetched one of those upstreams directly: 200, complete dataset, no 402 challenge at all. Our spec printed a price next to a URL that serves the data free, never touches our gateway, and never settles.
We were advertising our own bypass. Every one of those services answers a proper 402 at the gateway's proxy route, so that's what we publish now.
Four records described an auth scheme we don't implement
Four records described agent registration as Ed25519 keypair signing. The gateway verifies EIP-191 personal_sign over an exact message string. One of those records is aimed specifically at LLM and catalog ingestion, and its example query read "register my agent using my Ed25519 key" — feeding a nonexistent integration path to the systems most likely to repeat it verbatim.
Another named a registration endpoint that only answers GET. It returns a pointer to the real endpoint, which is sensible — but the record called the pointer the endpoint, so anything that POSTed where we said to POST got a 404.
Why none of it was caught
Every defect has the same shape: a claim about the system, stored outside the system, that nothing re-derives. Prices were copied into JSON when they were true. The payout address was correct when written. The Ed25519 description matched a design that changed. The type mismatch was two people reasonably naming two different things endpoints.
Static files don't rot on their own — they rot because the thing they describe moves. So the fix isn't "be more careful when editing JSON":
- Derive from the 402, not from a cache. Price and payout address now come from the actual challenge response, because that's the number the buyer will be charged — not from an internal field that merely ought to agree with it.
- One source, two records. The catalog is regenerated first; the summary record is rebuilt from it. Two files can't disagree about a type if only one is authored.
-
Probe over loopback. An earlier version of this audit probed from outside, rate-limited itself, and read its own
429s as "endpoint healthy" — hiding 71 genuinely dead links behind the throttle. Your own throttle is not a verdict about someone else's uptime. - Refuse to publish suspicious deltas. The corrector aborts if too many entries fail to resolve. A mass drop is far more likely to be an outage on your side than every seller delisting at once.
Test that your test can fail
This runs daily now and fails loudly on drift. But a check you've only watched pass proves nothing — a regex that matches nothing also reports zero mismatches. So we planted the old wrong price back into the input and confirmed:
handbook prices: x402-gas says 1c, charges 50c → FAIL
Then reverted. Two minutes, and it's the difference between a guard and a decoration.
Publishing a correction doesn't un-publish the old one
One last thing, because it changes what the fix is worth. A separate client has been calling five endpoint IDs that no longer exist, ~720 times a day, for a week — while re-fetching our now-corrected spec hundreds of times a day. The dead IDs are gone from everything we publish. It's still calling them.
Downstream consumers cache, merge, and hand-copy your records into their configs. Fixing the record fixes the next reader, not the current ones. Which is the real argument for validating discovery records continuously rather than carefully: every day a wrong record is live, some fraction of its readers commit that error to a config that outlives your fix.
Measured against a live x402 gateway on August 18–19, 2026. Request counts come from the gateway's own log; prices and payout addresses were read from live 402 challenge responses. The seller-hosted upstream that serves without a paywall is deliberately unnamed.
Top comments (0)