On 12 November we moved three services off a URL that could not take money. The interesting part was not the fix. It was discovering, one surface at a time, how many places a catalogue had already told buyers to go there.
Three Solana endpoints — x402-sol-report, x402-sol-onchain, x402-sol-find — were listed at /x402/sol/<action>. That path is served by a handler with three exits: a trial branch, a prepaid-credit branch, and a 4xx pass-through. It never reads X-PAYMENT or PAYMENT-SIGNATURE, and the route is registered without the payment verifier that the sibling /x402/:id registration passes in.
The 402 it returned was not obviously broken. Same message, same accepts[0], same payTo as the good door. Its nextSteps[0] said:
Pay: include PAYMENT-SIGNATURE (V2) or X-PAYMENT (V1)
So an agent following our own discovery documents did exactly what the challenge said, and the header it sent was never read. Two doors, one character apart in the path, one able to settle and one not — and nothing machine-readable said which was meant.
402 the whole time. Liveness checks the door is open. It does not check that the person standing behind it can take cash.A catalogue is not one document. These all named an endpoint URL for the same services:
| Surface | What it is | Population on 12 Nov |
|---|---|---|
/api/services | the catalogue itself | 1,875 rows |
/.well-known/x402 | the discovery manifest | 1,956 resource URLs |
/openapi.json | generated from the catalogue | 1,875 paths |
/health.json | a daily liveness report, rendered by a public page | 1,875 rows |
| served pages | docs, listings, examples | 690 literal /x402/ references |
When we fixed the catalogue, three of these agreed within the hour. The fourth did not: /health.json is rewritten once a day by a probe job, and between 05:43 and the next 05:13 it kept handing the old alias URL to a page a buyer can actually read. The window is bounded — at most one probe cycle — but it is a window in which our own buyer-facing page pointed at a door that cannot settle, and no guard existed to say so.
That is the general shape worth stealing: the failure lived in the join between two things we already had. The catalogue knew an endpoint per service. The route table knew, per route, whether the handler can settle. Nobody had ever joined them.
for each published endpoint URL:
path = url after the host
route = first match in the route table (literal segments first)
require route.class == "paywalled"
Two details do most of the work.
Literal segments before parameters. A table that matches /x402/:id before /x402/uuid-go will judge a free fast-path as a paid door and hide exactly the exception you were looking for. Sort candidates by number of literal characters, not by the order you happened to write them down.
The class has to mean "can settle", not "is named paywalled". Alias families — /svc/:name, prepaid top-up doors, redirect shims — commonly return a 402 that looks payable and still do not read a payment header. If the route table calls them paywalled, the table is the bug. We keep the settlement capability as a separate class and treat it as a failure when an advertised endpoint lands there.
Run over today's data, the predicate says: 1,875 catalogue rows, 1,956 manifest resources, 1,875 OpenAPI paths, 1,875 health rows, and every non-blog /x402/ path on a served page, all resolve to a route that has a settlement branch. Before the fix, three rows did not.
Two footnotes on the page scan, because it is the messiest population. Of the literal paths found in served pages, 690 matched a paywalled route; three matched a free Go fast-path (/x402/uuid-go, documented in a discovery table and returning a UUID without payment); and a dozen were GitHub URL fragments that a regex picked up and no reader would mistake for an endpoint. Dated posts under /blog/ were excluded — they record what was true when they were written.
Three honest limits, because a published audit that hides its blind spots is a brochure:
If you publish a catalogue, write the join before you write the next listing:
The alias door still exists at the gateway, and it still answers with a 402 whose instructions assume a payment header is read. Removing it is a routing change we have not made; the catalogue, the manifest, OpenAPI and the health report now all point at the settle-capable spelling. That is an honest partial fix, and it is written down here as one.
← All posts