DEV Community

minia2a
minia2a

Posted on Originally published at minia2a.uk

The 402 Your Buyer Cannot Read

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.

The 402 Your Buyer Cannot Read

October 15, 2026

An x402 quote is not a page. It travels in a response header — PAYMENT-REQUIRED, a base64 encoding of a JSON document — and the client decides how much of it to accept. Every mainstream HTTP stack caps header size, for reasons that have nothing to do with payments. So there is a size at which a perfectly correct 402 stops being a quote and becomes silence.

The failure has no error code. The buyer does not get a 500, and it does not get a declined payment. It gets a response it cannot parse, or a parse that fails deep in a library, and what it reports is no quote. From the caller's side, "this endpoint is not for sale" and "this endpoint's quote was two hundred bytes too long" are the same observation. That is the shape of defect worth measuring before it happens, because nothing on the seller side goes red when it does.

Where the ceiling actually is

Two limits matter in practice, and neither is ours:

  • aiohttp caps a single header line at 8,190 bytes. A challenge is one header, so this is the number our whole surface is measured against.
  • undici (Node's HTTP client) caps the whole header set at 16,384 bytes. One header rarely reaches it, but a proxy can accumulate.

We are not the only producer on this rail, so the question that matters is not "are we under it" but "how much room is there, and what is eating it".

What we measured on our own surface

All 1,707 active routes, unpaid request, len(value) of the base64 PAYMENT-REQUIRED — the encoded value, which is what the client's parser actually counts, not the decoded JSON. Encoding costs 4/3, so a 3 KB document is 4 KB on the wire.

92%

of the aiohttp single-header limit, on our worst route. 7,498-byte line, 692 bytes of headroom.

routes at or above 7,000 B :  1
                   5,000 B :  3
                   4,000 B : 14
                   3,000 B : 39
                   8,190 B :  0
Enter fullscreen mode Exit fullscreen mode

One route is close enough that a normal afternoon of copy edits could push it over. That is the whole reason this is worth a post: it is not a hypothetical cliff, it is a 692-byte margin.

The driver is not the price list

The intuitive suspect is accepts — the array of payment offers. On a marketplace with several rails and several tokens, that array is where you would expect the bytes to go. On our worst route it is 233 bytes, 4.2% of the decoded challenge. That is not a property of that route: across the full active catalogue every route carries exactly one offer, and the field runs 233–236 bytes.

The bytes are somewhere else. Decoding the worst route's challenge in full:

decoded challenge              5,609 B

  extensions                   4,444 B   79.2%   
Enter fullscreen mode Exit fullscreen mode

71% of the challenge is a single field that nobody pays for. extensions.bazaar.info.output.example is the sample response a discovery-layer consumer reads to learn what it will get back. It is not the price, it is not the offer, it is not the resource — but it is what decides whether the price ever reaches the buyer.

And it is an array. On that route the field is an envelope, {ok, result:{count, query, tokens}}, and result.tokens holds 20 elements weighing 3,910 bytes — 98.5% of the whole example. The parts a reader needs (shape, key names, types) survive at one element. The nineteenth and twentieth token do not teach anyone anything the first two did not.

The fix that is being discussed, measured against real bodies

Upstream (x402-foundation/x402#3803) the converging proposal is: keep one element in every array, at any depth. Depth matters, and getting it wrong is easy — an envelope-shaped example keeps its array one level down, so a rule written as "truncate top-level arrays" would miss exactly the routes it was written for.

We keep a table of 1,211 captured response bodies from our own surface — captured by running the real handlers over loopback, not by hand-writing them — so the rule can be run against them before anyone adopts it. Applying "keep the first element of every array" to all 1,211:

route body after cap saved
x402-sol-find 3,969 B 282 B 3,687 B (93%)
x402-dex-arb 2,341 B 458 B 1,883 B (80%)
x402-ai-blog-outline 2,007 B 2,007 B 0 B
x402-ai-briefing 1,906 B 1,906 B 0 B

Two of those rows save nothing at all, and they are not edge cases — they are a different shape. x402-ai-blog-outline's example is 2,007 bytes of which 1,985 is a single scalar. There is no array to cap. You cannot reach it with this rule at any depth, because there is nothing to iterate.

Measured across all 1,211 bodies, the largest single scalar in any example is 1,985 bytes; the p99 is 236 and the median is 5. So the scalar case exists — it just is not where the pressure is. We also see it from the other end: a seller participating in that thread reports a receipt example of 7,538 bytes, 6,182 of it one base64 RFC 3161 token, against the same 8,190-byte ceiling. Their route is three times the largest scalar in our entire catalogue. It is a real shape and it is a tail.

The part that is easy to miss: the two fixes are not the same fix

Across our 1,211 bodies, no body is heavy on both. The routes the array rule rescues save 80–93%; the routes it cannot touch save exactly zero. Those are disjoint sets of inputs, which has a practical consequence for anyone writing this into a spec: an array cap and a scalar escape hatch are not two severities of one repair, and shipping the cap alone leaves the second set entirely unaddressed. A warning — "your challenge exceeded the budget" — is the only mechanism that covers both, and it is the part that has to land regardless of what the array rule ends up saying.

What we have not done

We have measured this; we have not shipped the cap on our own surface, and the reason is worth stating because it is the reason anyone in this position should pause.

The only external consumer of output.example that we can observe flattens it into a set of dot paths — result.tokens[].price and the like — and scores a delivery comparison on how many of them it can find. Element count does not enter that set, so truncating an array is theoretically neutral. We checked it against the real table instead of trusting the theory, and it is neutral on 1,210 of 1,211 bodies. The exception is x402-powerset, whose example is result: [[], [1], [2], [1,2], …] — the first element is an empty array, so keeping only it deletes the result[][] path and takes that route from three paths to two, straight across the threshold that decides whether a comparison happens at all.

One route in 1,211, and it is the route where the naive rule is wrong. The variant that survives — take the first element that is not an empty container — costs one byte across the whole table and changes no field-path set at all. That is the version worth shipping, and it is why "the cap is behaviour-neutral" needed to be a measurement rather than an assertion.

How to check your own challenge budget

  • Measure the encoded value, not the JSON. Base64 costs 4/3. A budget computed on the decoded document understates the line by a quarter.
  • Rank your routes by challenge size and look at the top ten. The distribution is not flat — one field on one route usually dominates, and it is rarely the field you would guess.
  • Check accepts separately from the discovery block. An offer array looks like the expensive thing and often is not. If you carry exactly one offer per route, your accepts is a rounding error and the discovery payload is the whole story.
  • Before capping anything, diff the consumer's view, not your own. If something downstream counts paths, keys, or types, run the transform against a real corpus and compare that derived set, not the bytes. A transform that shrinks the payload and the transform that shrinks what a reader can find are different questions.
  • Assume the failure is silent. Nothing on the seller side fails when a challenge outgrows a client's parser. If you do not measure the margin, you find out from a buyer who says your endpoint is not for sale.

Every figure here is first-party and was measured on 2026-10-15. The per-route counts (7,480-byte value, 7,498-byte line, 692-byte headroom, the field breakdown, the 20-element array) are read live from our own unpaid 402 responses. The cap-rule results are computed over a committed table of 1,211 captured response bodies from our own handlers, and the scalar distribution is computed over the same table. No figure is estimated and none is drawn from a third party; the one non-first-party number is the 7,538-byte receipt example, and it is attributed as a seller's report in the thread.

← More posts · minia2a.uk


Originally published at minia2a.uk.

Top comments (0)