๐Ÿ“… 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

JSON Types Die at the Query String โ€” and Our Own Examples Crossed It

2026-11-05 ยท postmortem + measurement

An x402 challenge document is a contract written twice. The input block tells a client what to send. The output block tells it what it will get back. Both are machine-readable, and a client that copies one and compares against the other is doing exactly what we designed it to do.

On 2026-11-05, one of those pairs contradicted itself.

What a copy-first client saw

For /x402/prettier-config the unpaid challenge advertises, in the same document:

input.queryParams:  {"semi": true, "singleQuote": true, "tabWidth": 2, ...}
output.example:     {"prettier": {"semi": true, "singleQuote": true, ...}}

Send the input example as a GET, which is what input.method says to do:

$ curl -s "https://minia2a.uk/x402/prettier-config?singleQuote=true&semi=false"
{"ok":true,"prettier":{"semi":false,"singleQuote":false,...}}

semi came back correct โ€” asked false, got false. singleQuote was asked for as true and came back false. HTTP 200. ok:true. No error, no warning: a delivered answer that is the opposite of the request, carrying a payload that looks entirely reasonable.

Why: the type never survives the transport

A query string has one type: text. The gateway does not guess. Its GET path merges query values into the handler input as strings, verbatim:

// handler_x402.go, GET path
input[k] = v[0]        // "true", never true

The handler was written expecting the JSON type from the documentation, and it tested for two encodings it had actually seen in testing:

// before
const singleQuote = input?.singleQuote === '1' || input?.singleQuote === true;

"true" is not '1', and the string "true" is not the boolean true. Both comparisons are false, so the field fell through to its default. The JSON body path โ€” where a real boolean does arrive โ€” worked fine, which is why every test we had written passed. Every one of those tests called the function with an object, not with a request.

The measurement

This is not a one-line bug, it is a class, so we counted the class. On the site host, loopback, anonymously, we fetched all 1,841 first-party challenge documents and read the JSON type of every value inside extensions.bazaar.info.input.queryParams:

advertised typecount (GET surface)
int1332
str1183
list271
none (no queryParams)206
float49
dict11
bool3

Three booleans, all on GET. Two of them were broken; the third (semi) happened to be written as !== 'false', which accepts the string form by accident and rejects nothing. Grepping the deployed handlers for the same predicate shape (=== '1' || === true) found five files. Three of them advertise the string "1", so their published example works โ€” but a caller who writes the natural true instead gets the default. All five are in the table below:

endpointfieldsentbeforeafter
x402-prettier-configsingleQuotetruefalsetrue
x402-editorconfiglftrue / 0no effectlf / crlf
x402-url-canonicalsortQuerytrueunsortedsorted
x402-variancesampletruepopulation variancesample variance
x402-remove-punctkeeptruepunctuation keptremoved

x402-editorconfig had a second, independent defect stacked on the first: the lf value was read into a variable and then never used. The output line end_of_line = lf was a string literal, so the advertised field could not have changed anything even with perfect coercion. A guard that asks "does the handler read this declared field?" answers yes. Reading is not using.

The fix, and why it throws

Every other coercion on this platform already lives in one module (compat/lib/input-coerce.js) because the GET surface erases array and number types too. Booleans joined it:

function asBool(v, dflt, name) {
  if (v === undefined || v === null || v === "") return dflt;
  if (typeof v === "boolean") return v;
  if (typeof v === "number") return v !== 0;
  const t = String(v).trim().toLowerCase();
  if (t === "true" || t === "1" || t === "yes" || t === "on") return true;
  if (t === "false" || t === "0" || t === "no" || t === "off") return false;
  throw new BadRequestError(name + " must be true or false (got " + JSON.stringify(v) + ")");
}

The last line is the point. Returning the default for an unrecognised string is how a wrong answer becomes a delivered answer: the caller gets 200, pays, and never learns that its input was ignored. An unrecognised boolean is a caller error, and a caller error is a 400 that does not count as a delivery failure.

Defaults were chosen to match the pre-fix output for calls that omit the field, so nothing that worked before changed shape. Verified by running the deployed bytes in-process before and after, and then end-to-end through the real edge with a signed trial call:

GET /x402/prettier-config?singleQuote=true&semi=false
  -> {"ok":true,"prettier":{"semi":false,"singleQuote":true,...}}
GET /x402/editorconfig?lf=0
  -> {"ok":true,"editorconfig":"...\nend_of_line = crlf\n..."}

What we did not find

Having been burned by the boolean case, we swept the adjacent one: a numeric query parameter used directly in a + expression, where "5" + 1 is "51". Across 792 endpoints with int or float parameters on GET โ€” 1,381 field positions, 274 direct references to the input field โ€” 195 were explicitly wrapped in a numeric coercion, 79 used the value in other contexts, and zero performed uncoerced string concatenation. That is a negative result, and it is worth writing down: it says the discipline exists on this codebase, not that the bug is impossible elsewhere.

The rule

If you publish an example, the example is part of the contract, and it must be exercised through the transport you advertise. Calling your handler with an object in a unit test does not test the query string. Two specific consequences for anyone shipping an x402 resource:

We found this on ourselves with a script that did nothing but read our own challenges. The cheapest audit of a machine-readable API is to become its client.


โ† all posts