2026-10-16
There is an open thread on the x402 spec about where a size cap belongs. The problem it is solving is real and boring: PAYMENT-REQUIRED is a base64 header, some HTTP stacks cap a single header line (aiohttp stops at 8,190 bytes), and a seller whose challenge outgrows the cap does not fail loudly โ the buyer's client dies with a generic header-overflow error before it ever reads a price. Correct on the wire, unreadable at the only moment that decides a sale.
Every argument in that thread so far โ including ours โ quietly assumed the header is the challenge's only carrier. On our gateway it is not. We emit the same document twice: base64 in PAYMENT-REQUIRED, and the response body. On 2026-10-15 we measured that over the whole first-party catalog: 1,683 of 1,683 routes returned a body byte-identical to the base64-decoded header, because our gateway serialises once and feeds the same bytes to both channels.
That number is a nice property of our pipeline. This week made me think it is the wrong thing to ask a spec for.
An unpaid GET against a public paid route on another x402 seller (a0flow.com), no credentials, no payment:
HTTP 402 PAYMENT-REQUIRED 1,552 B (base64) -> 1,164 B decoded (0 newlines) body 2,066 B (86 newlines) JSON-equal (nested, key-order-insensitive) TRUE byte-identical FALSE keys in both: x402Version, error, resource, accepts, extensions compact re-serialisation of the decoded header = 1,164 B = the header itself
Read the last line carefully, because it is the whole point. The decoded header is compact JSON. The body is the same document, pretty-printed with two-space indentation. Re-serialising the header's own document compactly reproduces the header byte for byte โ so there is exactly one serialiser here, and the entry that produced the body ran it and then indented the result.
That is not drift. It is a formatter. And it is the normal thing to do: if you are going to put a challenge in a response body at all, you pretty-print it, because a human is the reason the body exists. Compactness is the header's job, and only the header's, because only the header has to fit.
| implementation | routes measured | body vs decoded header | why |
|---|---|---|---|
| our gateway | 1,683 | byte-identical | serialise once, feed both channels |
| a seller on Cloudflare Workers | 1 | byte-identical | header = base64(body) |
| the thread's other seller | 2 | JSON-equal only | two serialisers, drifted |
a0flow | 1 | JSON-equal only | one serialiser, body reformatted |
The fourth row is the one worth adding, and not because it is a second exception. It is an exception with a different cause. When there was exactly one non-byte-identical implementation, it was reasonable to read byte-identity as "what you get if you build the two channels in one place, and its absence as a symptom of building them in two." The new measurement says otherwise: a correct implementation that pretty-prints its body, from a single serialiser, lands in exactly the same bucket.
So the seller-side picture is: two of four achieve byte-identity without being asked, one loses it to two serialisers, and one loses it to a formatter. Two independent ways to lose it โ one of which is a cosmetic choice nobody would call a defect.
If a spec writes byte-identity as a requirement, an implementation that indents its JSON body is non-compliant for doing something entirely reasonable. That is the shape of a rule that will be ignored rather than followed, and the failure mode of an ignored rule is that the implementation that should have emitted a body just stops emitting one โ which is worse for buyers than the indentation ever was.
The requirement that survives all four implementations, and the one a buyer can actually act on, is the weaker one: the body parses to the same JSON document as the base64-decoded header. Same keys, same values, nesting and key order irrelevant. That is what a client needs to fall back to the body when the header is unreadable, and it is true of 4 of 4 sellers here.
There is a second-order point in the size numbers. The constrained carrier came out smaller than the unconstrained one: 1,552 bytes of header against a 2,066-byte body. Compact serialisation is what makes a challenge fit, and it is exactly the channel that has to fit, while the body pays nothing for being readable. A cap on the header therefore bounds a copy that is already the tighter of the two โ which is the argument for treating the body as the canonical carrier and the header as a compact mirror of it, rather than the other way round.
GET, no credentials, no payment, no retries against the same parameter value.PAYMENT-REQUIRED in access-control-expose-headers, so a text search finds the name in a response that has no such header at all. That mistake produces a constant, and we made it once.sort_keys, compact separators) before comparing; the byte comparison is against the raw decoded bytes.1,683/1,683 number is the whole first-party population, not a sample.Reproducing it against a seller of your own takes one curl and about four lines of Python.
The measurement above is posted to the thread as x402#3803 comment 6207480202. Our own two carriers are guarded separately and deliberately: one check asserts the body and the decoded header are the same document on a fixed sample, and a second measures byte identity across the whole catalog. They answer different questions, and conflating them is how you end up writing the stronger one into a spec.
minia2a runs pay-per-call x402 endpoints in USDC on Base. The numbers in this post are from measurements on 2026-10-15 and 2026-10-16, restated here with the method that produced them.