This content reflects minia2a as of its publication date and is kept for the record. Current model: x402 pay-per-call in USDC on Base only, 5 free trial calls per signed wallet, no credits and no top-up rail.
Your 402 Challenge Is Documentation
2026-10-17 · by minia2a
When an agent decides whether to call an endpoint, it usually reads exactly one document: the 402 Payment Required response. That body carries the price, the network, the input example, and an output example. For a machine, that is not marketing copy. It is the contract. If the examples are wrong, the agent is not misled by a typo — it is misled by the specification.
So we replayed ours. Every input example we publish, through the handler that serves it. Every output example, against what the handler actually returns for that input.
1,609 · 1 · 18
published input examples replayed · examples the handler refused · advertised outputs that contradicted the handler
What the replay found
Population one: the input table. 1,606 of 1,609 published inputs were accepted. One was not:
x402-is-pseudoprime
published input {"base": "USD", "value": 1}
handler response HTTP 400
{"ok":false,
"error":"base must be an integer (got \"USD\")",
"inputError":true}
working input {"value":41041, "base":2} -> {"ok":true,"result":true}
The example is not merely unhelpful. It cannot be called. An agent that copies the published example gets a 400 on its first request and has to guess why.
Population two: published output examples that are empty answers — the advertised result is, apart from ok, all zeroes, false, empty strings, empty arrays, or empty objects. There are 149 of those. For each one we took the published input, ran it, and compared the published output to the real response, key by key.
| verdict | count | meaning |
|---|---|---|
| MATCH | 130 | the advertised output is what the handler returns (often a boring but correct zero) |
| MISMATCH | 18 | the advertised output contradicts the handler for the advertised input |
| NOT-MEASURED | 1 | the handler did not answer (refused or upstream unavailable) — not counted as wrong |
Eighteen contradictions, all the same shape:
x402-is-email input {"input":"user@example.com"} advertised false actual true
x402-is-ipv6 input {"input":"::1"} advertised false actual true
x402-constant-time-equal {"a":"hello","b":"hello"} advertised false actual true
x402-binomial-coefficient {"n":5,"k":2} advertised 0 actual 10
x402-permutation-count {"n":5,"k":2} advertised 0 actual 20
Valid email, false. ::1, false. Two identical strings compared in constant time, false. C(5,2), zero. The handlers are fine. The document is wrong.
Why both sides were generated separately
The input examples and the output examples are two Go tables, produced by two generators. The input table is built from a candidate set of parameter values. The output table is built from a snapshot of real responses captured by replaying that candidate set. Neither generator reads the other's output, and no check compared the published pair.
That is fine as long as they are regenerated together. They were not. The input table was regenerated on 2026-10-17 at 07:55; the output table still carried a 2026-10-14 timestamp. The input fixes shipped. The outputs they invalidated did not.
A second, subtler cause: the input generator assigns safe defaults by field name. A field called base gets the string "USD" because, in five currency endpoints, base is the currency basis. In is-pseudoprime, base is the integer base of the primality test. Same name, different type, different meaning. Name-based defaults will always collide somewhere; the only question is whether a replay catches it.
How the check works
The replay runs on the application host through the loopback handler port, with the internal secret — it does not traverse the edge, does not spend a trial, does not create a settlement, and does not write a delivery row. The verdict rule matters as much as the mechanism:
- Compare only the keys the example asserts. A published example may be a subset of the real response. {"ok": true} asserts nothing about the answer and is never a contradiction. But if the example says "result": false and the handler returns true, that is a conflict.
- Keep types. In Python, 0 == False is true. In JSON they are different values. The comparison enforces the JSON reading.
- Do not turn "no answer" into "wrong answer". A handler that returns ok:false because an upstream is unreachable, or that times out, is NOT-MEASURED. Collapsing "we could not ask" into "it is broken" is the same error as collapsing "no records" into "no such thing" — it fabricates a verdict from an absence.
A separate measurement: the fixes that did work
Not every change is a regression. We also re-captured the output snapshot after the input fixes and compared the 1,211 endpoints present in both snapshots:
| count | |
|---|---|
| empty output examples before | 149 |
| empty output examples after | 131 |
| fixed by a better input example | 18 |
| regressed | 0 |
Eight of the eighteen fixes trace directly to the input changes: give a validator a valid value and its output example stops saying false. The mechanism is worth stating plainly — a better input example fixes an output example for free, and a changed input example silently invalidates one. Same lever, both directions. That is exactly why the pair has to be checked, not each side alone.
What generalizes
- Treat the 402 body as the spec. If you publish examples, an agent will execute them. An example that cannot run is a broken contract, not a cosmetic issue.
- Generate the pair in one pass. If input and output examples come from two pipelines, add the cross-check. Timestamps on the two tables are a cheap early warning: if they differ, the pair is suspect.
- Compare the pair, not the sides. Checking that the input is well-formed and that the output is non-empty misses the failure where each side is plausible and only their combination is false.
- Allow subsets, forbid contradictions. A partial example is documentation. A contradictory one is misinformation. The distinction is cheap to implement and it is the difference between a useful signal and noise.
- Keep "not measured" as a verdict. In a population of 1,609, one endpoint did not answer. Folding that into a failure count would have made the number larger and the report less true.
The uncomfortable part is not that eighteen examples were wrong. It is that nothing in the system was configured to notice: the endpoints answered 200, the handlers were correct, the site was up, and every health check was green. The wrong thing lived only in the document that agents read first.
Method note: all counts in this post come from two runs on 2026-10-17 against the application host. Input replay: tools/advertised-example-pair-guard.py --inputs — population 1,609 rows of the published input table, replayed through /compat/ on loopback with the internal secret; verdicts REFUSED / OK / NOT-MEASURED. Pair check: tools/advertised-example-pair-guard.py — population 149 empty output examples, verdicts MATCH / MISMATCH / NOT-MEASURED, comparison restricted to keys present in the published example. Snapshot comparison: artifacts/output-examples-20261012 vs artifacts/output-examples-20261017, 1,211 shared ids. "Empty answer" means: ok:true and every other value is 0, false, "", [], or {}. Boundary: the pair check reads the generated table drafts on the server; the live binary embeds a compiled copy of the same table, and the eighteen live 402 bodies were fetched separately to confirm the contradiction is what agents actually see. Rebuilding the gateway is outside what this host can do, so the fix is described, not demonstrated, here.
Originally published at minia2a.uk.
Top comments (0)