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.
Published 2026-10-17 · x402, API design, verification
An x402 402 Payment Required response can advertise what a paid call looks like: an example input and an example output. That is how an agent decides whether an endpoint is worth paying for before it pays. The examples travel in the response body and, base64-encoded, in the PAYMENT-REQUIRED header.
Headers have hard limits. Ours grew to examples over 4 KB. This is the story of capping them at 2,000 bytes — and of the first cap being wrong in three different ways.
The constraint nobody budgets for
The numbers on our deployment:
- aiohttp refuses a single header line over 8,190 bytes (its own limit, not ours).
- Base64 inflates a payload by 4/3 before it becomes a header value.
- One advertised example reached 4,064 bytes on its own. Serialized into the challenge and base64-encoded, it pushed that route to roughly 92% of the header limit — before any other field was added.
An example that cannot be read is not documentation. If the challenge itself returns a 400 because a header line is too long, the buyer never sees the price, the schema, or the example. The route is simply broken for anyone who tries it.
The rule
We capped the examples at emission time, not in the table:
serialized example ≤ 2,000 B → send as-is
serialized example > 2,000 B → truncate strings to 512 B + "…"
keep 1 element of every array
if still > 2,000 B → send {"ok": true}
The table keeps the real response. The wire gets a bounded view of it. Out of 1,603 stored output examples, 12 needed the cap and 1,591 go out byte-for-byte unchanged.
The fallback matters as much as the rule: {"ok": true} asserts nothing. It is the one example that cannot be wrong, because it makes no claim about the answer.
Three ways the first version was wrong
1. The test called the helper, not the emitter
The invariant test imported capExample(value) and checked it directly. The emitter called the same helper — so the test passed. Then the cap was removed from the emitter and the test still passed. The helper was fine; the wire was not capped.
A test that exercises a function is not a test that the function is on the path. The fix was a mutation control: remove the cap from the emission path, and the test must fail. If it does not, the test is measuring a copy.
2. The size was measured on the wrong object
It is tempting to measure len(json.Marshal(example)). But the thing that hits the 8,190-byte wall is not the marshaled struct — it is the base64-encoded challenge document carried in the header, plus every other field that shares the line. The budget check has to use the same serialization and the same envelope the emitter uses. A number measured one layer early is a number about a different object.
3. Unconditional truncation corrupted 223 rows
The first draft truncated every array to one element, always. That is a shape change on every example with a list, including the 1,591 that fit comfortably under the limit: 223 rows were rewritten for a budget problem only 12 of them had. The condition has to gate the transformation, not the other way around.
Our own guard had the same class of bug
To verify that the wire matches the table, we compare the served 402 against the stored example — capped with the same rule first. The first implementation of that check used Python's json.dumps to measure size. Eleven of the twelve capped rows matched. One did not: x402-ai-tests.
Its stored example serialized to 1,988 bytes in Python — under the limit, so our cap left it alone. The live 402 truncated it to 565 bytes. The difference was HTML escaping: Go's json.Marshal writes < as \u003c, and that example contains JavaScript with comparison operators. Measured the way the server measures it, the value was 2,050 bytes — over the limit.
Two implementations of "the size of this JSON" disagreed by 62 bytes, and the disagreement was exactly one row wide. The live response was the only oracle that could settle it.
After matching Go's escaping rules — &, <, >, U+2028, U+2029 — all twelve rows match byte-for-byte. A full sweep of the 1,603-row table found 1,602 matches, 0 drifts, and one 404: an example still stored for a service that had been deactivated. The table was right; the endpoint was gone.
What this changes for a buyer
- The 402 always fits in a header. You will not get a 400 instead of a price.
- For examples under 2 KB — 1,591 of 1,603 — nothing changed at all.
- For the twelve that were too large, long strings end in … and lists carry their first element. The keys and types are still real; the length is not a promise the wire can keep.
- When even truncation is not enough, the example degrades to {"ok": true} rather than to a claim the server cannot honour.
An advertised example is a promise about shape. A size limit is part of the contract, not an implementation detail. The honest version is not "here is the whole answer" — it is "here is as much of the answer as the transport can carry, and here is exactly where it stops".
You can read any endpoint's current example without paying: curl -s https://minia2a.uk/x402/math | jq .extensions.bazaar.info. The first five calls per signed wallet are free — no registration.
Originally published at minia2a.uk.
Top comments (0)