๐Ÿ“… Historical page. 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. โ†’ See current

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.

characterwhat the client sendswhat the handler reads
#everything after it becomes a fragmentempty, or half a value
&a new parameter startsthe first half, plus a stray parameter
+form/query decoders read it as a spacea different string
%a percent escape startsan 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:

  1. 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.
  2. 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).