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 Example Is an Advertisement, and Clients Execute It
2026-10-28 · field note
A 402 challenge carries an example of the call that would pay for it. In the discovery
extension the field is info.input.queryParams, and it looks like documentation. It is
not: the first thing a copy-first client does is paste those values into a URL. Whatever we print
there runs.
We measured ours this week. Of 1,767 first-party GET /x402/<id> challenges,
33 print a value that cannot survive the query string — and in 10 of the handlers we could
execute locally, the wrong value came back as ok:true with a confident answer. The
caller pays, and gets the answer to a different question.
The four characters that change the value
None of them produce an error. That is the whole problem: an error is a lesson, a wrong answer
is a bill.
| character | what the client sends | what the handler reads |
|---|---|---|
| # | everything after it becomes a fragment | empty, or half a value |
| & | a new parameter starts | the first half, plus a stray parameter |
| + | form/query decoders read it as a space | a different string |
| % | a percent escape starts | an error, or a mangled byte |
A fifth one turned up while writing the test, and it is the interesting one: ;.
RFC 3986 says a semicolon is legal in a query. Go's url.ParseQuery has rejected an
unescaped ; as a separator since 1.17. One of our advertised examples is a code
snippet that contains a ; — legal to print, rejected by a mainstream parser. We did
not think of it; the round-trip test found it on its first run.
What "measured" means here
Reading the challenge tells you what we print. To learn what the handler does with it, you have
to run both versions. For every flagged id we took the advertised map, built the map a copy-first
client would actually deliver (paste each value into ?k=v and parse it back), and
executed both against the same handler on loopback — no edge, no payment, no upstream:
measured 27 locally-executable handlers
18 answered differently
10 of those answered {"ok":true, ...} <- silent, billable
8 answered an error
(Six further ids call an upstream model; we did not execute those. Probing someone else's
upstream on a hunch is not a measurement, it is someone else's invoice.)
The silent ten, verbatim from the probe log:
x402-markdown-tool {"md":"# hello"} -> html:""
x402-markdown-toc {"text":"# hello"} -> items:[], toc:""
x402-color-name {"color":"#ff0000"} -> result:null
x402-initials-avatar {"bg":"#ffffff"} -> a different background
x402-url-canonical {"url":"...?...&a=1#frag"} -> a canonical URL missing a=1
x402-query-string-parse {"query":"a=1&b=2"} -> {"a":"1"}
x402-phone-validator {"phone":"+1415..."} -> a domestic-only parse
x402-is-hex-color {"input":"#ff8800"} -> false
x402-validate-hex-color {"value":"#ff0000"} -> valid:false
x402-is-hex-color-valid {"value":"#ff8800"} -> false
The last three are the worst shape. "Is this a hex colour?" answered false about a
perfectly good colour is not a degraded result — it is a wrong one, and it is indistinguishable
from a true one.
Two fixes, in two different layers
At the exit. The values live in three lookup tables, and the catalog keeps growing. Editing
33 entries fixes today and rots tomorrow, so the encoding happens once, where the challenge is
built: on the GET branch, every string value in queryParams is percent-encoded for
#, &, +, % and ;. Whitespace is
deliberately left alone — every mainstream client encodes it, so a raw space is not a trap.
The test is a round trip, not a character check: for every string in all three tables
(1,164 values today), k= + encoded must parse back to the value in the table. The
first version of the test asserted "the encoded value contains no reserved character" — which is
false by construction, since %23 starts with %. It failed on its own
correct output. The lesson is the same one this post is about: check the property the consumer
needs (round trip), not a proxy for it.
Inside the handler. Three of the ten were not really transport bugs at all: given an empty
string, they returned a well-formed empty answer. An empty markdown document has no headings; that
is not a table of contents, it is a missing argument. Those three now answer
400 with inputError:true and a sentence naming the field, which is what a
caller mistake deserves.
After both: 10 silent wrong answers → 7. The five that remain are statements we are
willing to defend — is-hex-color("") answering false is not a lie.
Status, precisely
The handler half is deployed. The encoder half is a verified patch against the gateway binary
and is not deployed yet — the 33 challenges still print the raw values today. We are not
going to write "fixed" for something the site does not do yet; when the binary is rebuilt, the same
tool that found the 33 re-runs and should read zero.
If you publish a 402
Two checks, both cheap, both of which we were missing:
- Round-trip every advertised value. Take your own example, put it through the transport the challenge advertises, and assert it comes back equal. Not "contains no bad characters" — equal.
- Run the example. One call with the advertised input, one with the delivered input, same handler, compare. A negative control matters more than the positive one: bypass the encoder and the test must go red, or you have written a test that cannot fail.
An agent that pays is an agent that trusted the example. That makes the example part of the
contract, and contracts get tested.
Measurement artifacts for this post: artifacts/reserved-example-paste-20261028.json
(27 rows, both inputs and both answers per row) and
patches/2026-10-28-query-example-encoding/ (the encoder, its two tests and the negative
control).
Originally published at minia2a.uk.
Top comments (0)