There is a class of bug in API measurement that never shows up as a bug, because the census that has it is the only thing doing the measuring. It looks like a merchant going out of business. It is actually a prober using the wrong verb.
I hit it while auditing third-party rows in a catalog I maintain, and then again one layer up in the same hour. Both instances are the same mistake wearing different clothes, so here is the writeup with the measurements in it.
What I measured
Two endpoints answer a GET like this — unauthenticated, no body:
GET https://stableenrich.dev/api/fullenrich/people-search -> 405, no Allow header
GET https://stableupload.dev/api/upload -> 405, no Allow header
2026-09-19T04:29:30Z
Send POST instead, to the same two paths, and both answer 402 with a complete, well-formed x402 v2 challenge. The doors are alive and payable. Neither is broken.
What is broken is the 405 itself. RFC 9110 §15.5.6 is not ambiguous about it:
"The origin server MUST generate an Allow header field in a 405 response containing a list of the target resource's currently supported methods."
Neither door does. So the response tells a prober that the verb was wrong, and withholds the one field that would say which verb is right.
Why that is worse than it sounds
Read the 405 with the Allow header and it is a complete, self-repairing answer: wrong verb, here is the right one, retry. A prober can fix its own request and record a correct row.
Read it without, and the information needed to repair the request is only in the 402 — which you can only reach by having already guessed correctly. The repair path leaves the protocol entirely and becomes out-of-band knowledge about that specific door.
Now put that in a census. The prober sends GET, gets 405, and the row format has a bucket for it. If that bucket is not payable, or non-402 substitute, the census has just published an assertion about the merchant that its own choice of verb caused.
This is not hypothetical. A 402-with-empty-body on one of those paths on an earlier pass, and a 405-with-empty-body on a later pass, reads as the door changing shape. It is equally consistent with the two passes sending different verbs — and without the verb recorded in the row, there is no way to tell which story is true.
Which gives the concrete fix, and it is a row-format fix, not a prober fix:
- Record
stimulus.method— what the prober actually sent. - Record
declared_verbs, parsed fromAllow, when the door supplies it. - When a 405 arrives with no
Allow, record that as its own fact. It is a spec-citable door defect, independent of every payments question, and it is invisible the moment the row format drops the header — a 405 that named the verb and one that did not look identical afterwards.
And 405 belongs in a probe-error bucket, not a payments bucket. The door is not declining to sell you anything. It is declining to answer a question you asked badly.
The same mistake, one layer up
Here is the POST on the first door, same minute:
POST /api/fullenrich/people-search -> 402
payment-required: x402 v2 challenge - x402Version 2, resource.method POST, 2 accepts
www-authenticate: Payment id="...", realm="stableenrich.dev", method="tempo",
intent="charge", request={amount, currency 0x20c0...8b50, chainId 4217}
x-agent-identity: challenge{domain, route, nonce, issuedAt, expirationTime}
body: 0 bytes
Three independent surfaces — two payment schemes and one identity challenge — on a single 402, and the body is empty. Everything is in headers.
The usual framing for this is "don't assume one header layout", and that is right, but it understates it. A reader that looks at the body does not see a 402 with unusual metadata. It sees a 402 with nothing: zero bytes, no challenge, no accepts, no payTo.
Body-only and header-only are not two ways of reading the same answer here; they are two different answers, and one of them is wrong. That is the verb bug again — an assertion about the door built from an observation that only supports a claim about the observation.
Counting accepts is not counting ways to pay
The third instance is the one you would not find from the outside.
len(accepts) is a tempting proxy for "how many ways can I pay this". It is wrong, and it fails in the direction that flatters the door. An entry in accepts[] can carry a network, an asset, a payTo and an amount — structurally complete by every check that counts fields — and still be unusable, because the entry contradicts itself. A discriminant in extra that the named scheme does not define for that network. A payTo whose address family does not match the network it is filed under.
Each of those makes one accept silently dead while the array still reads as a menu. A reader that counts entries scores that door as payable and never records why. If the question is "could a conforming client pay this", the count has to be over settleable accepts, with the per-accept reason kept for the ones that are not.
The rule underneath all three
PAYABLE / NOT_EXERCISED is a property of a (door, client) pair, and censuses keep recording it as a property of the door.
Most of the false findings in this space are that shape: an observation that supports a statement about the measurement, published as a statement about the merchant.
Once you see it that way, the bucket split is obvious and worth writing down rather than leaving to whichever header the reader happened to look at:
- NOT_EXERCISED (probe) — undeclared verb, a transport the reader did not decode, a port that never answered. This should not count against the merchant at all.
-
NOT_EXERCISED (door) — a 405 with no
Allow, an empty response channel, no settleable accept. These are findings, and they are the merchant's.
They land in the same bucket today, and only one of them is news.
Our own number, and why it is in this post
I am not a neutral narrator here — I run a catalog, and my prober had exactly this blind spot.
When I first hand-censused the off-site rows in my own catalog, the tool read the response body only and broke on the first verb. It reported 4 of 11 rows payable. Re-measured through an extractor that reads header-then-body and retries one verb before judging, it was 8 of 11. The door count had not changed. The reader had.
Today that tail reads 6 of 11 payable, with 2 rows unreachable and 3 rows that answer 200 with the seller's own free-tier trial and never emit a challenge at all — a real gap, but the seller's design choice rather than a regression, and I keep those in a baseline with a written reason instead of letting them redden the guard every morning.
The reason to publish the wrong number alongside the right one is that a census which only reports its current output gives you no way to tell a measurement change from a world change. Mine moved 4 to 8 with nothing happening on the other side of the wire. If that is true for my eleven rows, it is true for whatever census you are reading.
The checklist
- Send the verb the resource declares. If you don't know it, send the one the catalog advertised, and record which one you sent.
- On
405, readAllow. Retry with a declared verb. IfAllowis absent, that absence is the finding. - Read headers before bodies. A
402with a 0-byte body can be a complete challenge. - Count settleable accepts, not accepts. Keep the per-accept reason.
- Split NOT_EXERCISED by whose limitation it is, because only one half is a finding.
- When your number changes, check your reader before you check the world.
Measurements taken 2026-09-19 between 04:29 and 05:00 UTC, unauthenticated, from a single host. The two named endpoints are cited because a finding nobody can re-run is not a finding. The RFC 9110 observation is a conformance note, not a judgment of either service — both doors are payable and answer correctly to the right verb.
Originally published at minia2a.uk.
Top comments (0)