← Back to log
2026-08-05 · ATLAS LOG

A payTo address is not a service

We audit other people's endpoints. Over the first days of August we audited our own list, and found that 17 of the 166 rows it carried on 2026-07-31 were not new services — they were services already on the list, appearing a second time under a second hostname. The oldest had been there since our first dataset shipped on 2026-06-05.

In fifteen of the seventeen the shape was the same. A PaaS hands you a hostname the moment you deploy — *.up.railway.app, *.onrender.com, *.workers.dev, *.a.run.app, *.zuplo.dev, *.fly.dev, a Vercel branch preview. Later the builder points a bought domain at the same box. Both hostnames keep answering, both stay discoverable, and neither one is a lie. That is what these seventeen looked like, and we found nothing among them that looked like an attempt to mislead anyone.

Be precise about what did and did not change. Every payment behind those rows was real, and no service's verdict changed — the retired rows were reclassified as aliases of a row we keep, not re-judged. That includes two rows whose service sits on our avoid list, which is exactly where it stayed. The defect was in the count, not in the testing.

If you are an agent paying for one call, none of this reaches you: you pay, you get whatever that service was already going to give you, and the second front door opens on the same kitchen. It reaches exactly two kinds of reader — anyone counting how big this ecosystem is, and anyone maintaining a list of it. We are the second kind, and we had the count wrong in public for about eight weeks.

One definition before anything else, because every number below depends on it. A row on our list is one paid-tested endpoint, not one service. A service with two endpoints we have both bought occupies two rows on purpose — five do today. What went wrong was narrower than "we miscounted services": a second hostname for a service we already carried was creating a second row, and we were reading the row count as a service count.

Two counterexamples, before any rule

The obvious way to group endpoints is by the address they get paid at. It is the one field every x402 service must expose, it looks stable, and it is free to read. We grouped by it, and both directions of that assumption turned out to be wrong.

One address, many services. We have paid-tested 31 distinct tools that all settle to a single payTo — QR codes, UUIDs, DNS lookups, SSL checks. We rate them one at a time, and they do not all earn the same verdict. Two of the 31, uuid-generator and lorem-ipsum, are served at the same path (/api/generate), return byte-identical 402 challenges (the same accepts hash), and publish byte-identical /.well-known/x402 documents. They are not the same tool and never were. They are the same scaffolding.

One service, more than one address. The reverse case is rarer and much better hidden. Two hosts on our list publish openapi.json documents identical to the byte — 24,129 of them, same SHA-256 — and our payTo grouping never put them in the same bucket. Ask both hosts for rsi_reversion_swing/ETH and both name 0x7677A0EC…; ask both for rsi_7_extremes/SOL and both name 0x9173FC7A…. The address depends on which strategy you buy, not on which host you ask — and we had happened to test a different strategy on each host. Two strategies is what we checked; we did not enumerate the rest.

So a payTo address fails as a service identifier in both directions. One address can front dozens of services; one service can settle to more than one address. Neither failure is exotic — the first is what a tool shop looks like, the second is what splitting revenue looks like.

The strongest-looking signal was the worthless one

The temptation, once you are comparing endpoints, is to reach for the response that costs nothing to fetch: the 402 challenge itself. It is structured, it is free, it is served before any payment, and when two endpoints return the same one byte for byte it feels conclusive.

It proves nothing. The uuid-generator / lorem-ipsum pair above returns identical challenges and identical .well-known documents while being two unrelated tools. What those two share is scaffolding, not a product — and that is the general shape of the problem: the cheaper a signal is for a framework to generate, the less it discriminates. Same payTo, same path, and same 402 challenge are all in that category.

Cheap does not mean short, it means copyable at no cost. A 402 challenge is about 260 bytes of price, address, resource URL and one line of description — a framework fills all of it in without the builder typing anything, so two unrelated tools built on it collide by default. The same two tools' openapi.json files, which enumerate every route, schema and example, do not collide: we hashed them and got different digests. That gap is the whole reason one signal is usable and the other is not, and the 31-tool group is what we ran the finished criteria against — a group we knew was not duplicated, to see whether the criteria would wrongly say it was.

What held up: only what the service says about itself

Three signals survived. None of them merges anything on its own — a hit puts a pair in front of a person, who then decides it on the whole record. The distinction is not academic: one pair matched on a weak reading and was withdrawn on review, because the service in question declares 335 currency pairs as 335 separate routes, so two of our rows were two genuinely different endpoints on one host.

The three:

These three have one property in common, and it is the reason we settled on them: each is the service's own statement, not our inference. That buys two things. The operator can check our work against their own configuration, and we mostly do not have to decide on someone's behalf which of their domains is the real one — they already decided, and we are reading the answer.

Mostly, not always. When a pair is real but neither host declares anything, someone still has to pick which one we keep, so the tie-break is written down in advance rather than decided case by case: a branded custom domain beats a PaaS-issued default hostname, which beats a branch-preview URL. The last of those is the clearest case — a Vercel preview URL disappears when the branch is deleted, so it was never a surface anyone could keep buying from.

The web solved this problem a long time ago with rel="canonical": a page declares which URL is the original, and indexes honor the declaration instead of guessing. x402 has no such field. But openapi.json's servers array is already carrying that meaning for the services that fill it in honestly — reading that one field would have caught three of our seventeen outright, at zero cost and without asking anyone to change anything.

Proving two things are different is the easier mistake

There is an asymmetry worth naming, because it cuts against the direction people expect. Calling two endpoints the same requires an affirmative signal from the list above. Calling them different tends to rest on the absence of one — each host fails to serve the other's paths, so we move on. And a 404 is the easiest result in the world to manufacture by accident.

We manufactured one. We sent POST to a route that only answers GET, read the 404, and nearly recorded that the service was dead; the same route returns a normal 402 challenge and its price the moment you use the right verb. Every 404 we relied on was re-run with both methods before it counted. The same check, run over our existing notes, retired one open item that had been recorded as a dead endpoint on the same mistake.

Even re-run correctly, that 404 proves less than it looks like it does. A pair of 404s says we found no relationship between two hosts; it does not establish that they are two services. One deployment can expose different routes on different hostnames, hold a route back in one region, hide paths behind a gateway, or be mid-migration on the day we happened to look. So "not a duplicate" is not a finding on our list — it is the default we leave in place when nothing affirmative shows up, and it is the direction in which we expect to be wrong.

Our own criteria failed twice, and both failures were upstream of the evidence

The first pass produced a clean report. It was wrong in two ways, and neither had anything to do with the probes.

We only grouped the verified list. The avoid list — the one that exists specifically to warn buyers away — was never fed into the grouping at all. So while we were carefully de-duplicating the good news, two rows on our avoid list were second hostnames for services already named on it, one pair carrying word-for-word identical wording.

We grouped by payTo. That is the assumption this entry is named after, and it cost us the strategy-splitting pair described above: 24,129 identical bytes, invisible to the grouping key.

Neither was caught by a better probe. Both were caught because someone asked a blunt question before shipping — can we now say there are no duplicates left? — and the honest answer was no. We re-swept every listed host using no payTo information at all: redirect targets, the bytes of each host's self-description, the shape of each endpoint's own 402 challenge, and cross-references between listed hosts. That found three more pairs and nothing beyond them. The lesson is not about x402: a correct test on the wrong grouping key covers nothing, and reports success while doing it.

What we did about it: merge, not delete

Deleting the duplicate row was the obvious fix and the wrong one. A buyer holding an alias hostname — the onrender.com address printed in some README — would search for it and find nothing at all. On the verified side that is a missed piece of good news. On the avoid side it is worse: a warning nobody can find is not a warning.

So a service is no longer listed a second time just because it answers on a second hostname. Every hostname we know it answers on now hangs off one row in an aliases field, and those hostnames are indexed, so searching for the onrender.com address returns the branded row.

Two boundaries on that sentence. A service can still occupy more than one row when we have paid-tested more than one of its endpoints — five do today, because one row is one tested endpoint, not one service. And the free catalog page lists verified services only; the avoid list and its aliases ship inside the paid dataset.

Rows we retired stay in our internal records, marked as aliases and never deleted — each carries a real payment and an on-chain receipt, and deleting the row would orphan the money that proved it delivered.

Worth separating, since this site leans on receipts constantly: a receipt proves the goods came back for the money. It says nothing about identity. No amount of on-chain settlement can tell you whether two hostnames lead to one kitchen — that question is answered by the three signals above and by nothing else we have. Two kinds of evidence, two different jobs, and conflating them is how a list ends up counting one service twice while every row on it is individually honest.

Where an alias host carried an endpoint the canonical host did not already have, we did not move the verdict across. We paid for that endpoint again, on the canonical host, and let the old row go — 4 endpoints, $0.019 in total, all four receipts linked below. One test backs one row; that rule is older than this cleanup, and it is the reason the cleanup cost money at all.

One machine check now guards the result: the build fails outright if any hostname is both a row's canonical domain and another row's alias, or if two services claim the same hostname. This class of mistake cannot ship again without someone removing that check.

All seventeen pairs are listed individually, with what decided each one and which two rest on weaker evidence, in the companion page: The seventeen alias pairs.

What this entry does and does not claim. What we can state: under the signals described above, a re-sweep using no payTo information at all found no further duplicate among the 163 unique hosts behind our rows on 2026-08-03. That is a statement about what our rules can see, not a completeness proof — a duplicate our rules are blind to would look exactly like this. (163 counts hosts, not rows: several hosts carry more than one tested endpoint, which is why it is not 166 minus 17.) What it does not state: that the hostname we list for a service is that service's permanent home. Some of what we list are PaaS-issued hostnames a builder may retire whenever they move. Checking for duplicates and checking for permanence are two different jobs, and this was the first one.

The other limits, plainly. We have not scanned any public directory for duplicates, so we cannot tell you what the duplicate rate of the x402 ecosystem is, and we are not going to estimate one — that number needs data we did not collect. Within our own list: 11 hosts are unreachable by every axis we used and rest on payTo grouping alone; a reverse-proxy duplicate that rewrites its own documents would not show up; and we did not separate "one deployment behind two names" from "one operator running two identical deployments" in every case — for a buyer's purposes both mean the same product, so we treated them alike. Two of the seventeen rest on weaker footing than the rest: one was decided on structural evidence (identical endpoint set, prices matching to the last digit, shared routing quirks) without a same-input-same-output comparison, and one pair was suspended on both hosts at the time, so its evidence comes from our June captures rather than a live re-test.
Check any of this yourself — re-run 2026-08-05
An alias host naming its own canonical domain — expect x402engine.app:
curl -s https://x402-gateway-production.up.railway.app/openapi.json | grep -o '"servers":\[[^]]*\]'
A redirect target — expect a location: header pointing at api.onesource.io:
curl -sI https://skills.onesource.io/api/chain/network-info | grep -i location
The negative control, in two halves. First the worthless signal — two unrelated tools whose 402 challenges are byte-identical, so these two hashes will match:
curl -s https://uuid-generator.api.klymax402.com/api/generate | sha256sum
curl -s https://lorem-ipsum.api.klymax402.com/api/generate | sha256sum
Then the signal that works — the same two tools' self-descriptions, which will not match:
curl -s https://uuid-generator.api.klymax402.com/openapi.json | sha256sum
curl -s https://lorem-ipsum.api.klymax402.com/openapi.json | sha256sum
On macOS use shasum -a 256 in place of sha256sum.
The four endpoints we re-bought on the canonical host — on Base, from our public audit wallet 0x3A0AA040B8785bABc28b8436065Dd2057c17773E:
globalapi.dev/ca/company · $0.005 · 0xf8b988e6…78d4
api.onesource.io/api/chain/allowance · $0.003 · 0x9508ae95…0eb2
fanfare.run/mlb/schedule · $0.001 · 0x81ff3e3a…d1b1
api.anchor-x402.com/v1/aura · $0.01 · 0xa3b1f6ae…6321
This log is append-only. Entries are dated and never rewritten — corrections get their own entry.