An x402 payment settles on-chain, and the server does not reverse it because you later decide the data was not what you expected. The only moment when you can still walk away for free is the unpaid request: the 402 response. That response is the real specification of a paid endpoint — the price, the network, the asset, the recipient and the fact that the endpoint answers at all.
The problem is that a catalogue entry is not that specification. It is a copy of what the seller typed when the resource was listed. I spent a day sampling a public x402 catalogue every 15 minutes, and the gap between "what a listing says" and "what the endpoint actually answers" is the most useful thing I measured.
The checks that cost nothing
Everything below comes out of one unpaid request. Nothing here requires a wallet.
1. Version and header must agree with each other. x402 v1 uses X-PAYMENT and a plain network name; v2 uses PAYMENT-SIGNATURE and a CAIP-2 network id. On the facilitator I sample, /supported lists 20 network × scheme rows: 16 exact rows declare x402Version: 1 and 4 batch-settlement rows declare x402Version: 2. Both versions live behind the same facilitator, so "v1 or v2?" is a property of the resource you are calling, not of the provider. A client that assumes one version will fail on a resource that happens to use the other.
2. Which resource is this challenge for? resource.url in the challenge must equal the URL you called. If it does not, you may be about to pay for something else.
3. The price is in atomic units, and its meaning depends on the asset. 10000 with 6 decimals is 0.01. The number in the 402 header is live; the number in the listing is a copy. In the page window I sampled, 96 of 100 resources had a price my reader could classify, and 4 of 100 did not — a client that requires a parseable price cannot buy from those at all.
4. The network is a CAIP-2 string, and testnets share the same facilitator. In one page of 100 listings the split was 91 × eip155:8453 (Base), 7 × Solana and 2 × eip155:137 (Polygon). The facilitator's own /supported list also carries eip155:84532 (Base Sepolia). A buyer who checks only "is this Base?" can end up signing on a chain where the funds are worthless.
5. The recipient is the check that protects the money. Compare payTo against the address the seller publishes somewhere you trust. Do not eyeball it; compare the strings, case-insensitively, in code.
6. Liveness is not "HTTP 200". An unpaid GET that answers 402 with a PAYMENT-REQUIRED header is alive and payable. 200 means the resource was free (or you already paid) and you cannot read its price from that call. 404 means the catalogue URL is wrong. 405 means you probed the wrong method — and this one is worth dwelling on: probing a POST-only resource with GET proves nothing, because the server rejects the method before it ever builds a payment challenge. Send a plausible body instead.
7. Sign for a window you can actually meet. maxTimeoutSeconds in my sample window was 120 s for 83 of 100 listings, 300 s for 12, 60 s for 2, 1200 s for 2 and 900 s for 1.
What 98 samples of the same catalogue showed
The collector is a plain script on a local host. Every 15 minutes it reads four public endpoints of one x402 facilitator: the discovery page limit=100&offset=0, /supported, /pricing, and a watch list of 8 individual resource URLs. From 2026-10-11T12:31Z to 2026-10-12T11:30Z it made 98 cycles and 388 target observations, all of them HTTP 200 — no failed fetches, no gaps longer than 16 minutes.
The count grows in lumps, not smoothly. Total indexed resources went from 19,080 to 19,460, with 39 distinct values across 98 samples. The change was not gradual: the largest single step was +142 (19,081 → 19,223) inside one 15-minute interval, followed by +102 in the next. If your code caches "about 19k", fine; if a test asserts an exact resource count, it will flake.
Watching the total hides almost everything. The composition of the page window changed in 93 of 98 cycles, while the total changed in only 41. Inside the same 100-item window: distinct hosts moved in 67 cycles, price bands in 73, network mix in 64, asset mix in 68, max-timeout mix in 79. "The count did not move" is not the same statement as "nothing changed".
The facilitator's capability surface was flat. /supported returned identical 20 network × scheme rows in all 98 cycles, with extensions bazaar, eip2612GasSponsoring and erc20ApprovalGasSponsoring. /pricing never changed either: credit price $0.001, published margin 3000 bps, 29 published rates of which 14 are non-zero (Base exact/eip3009 = 2.18 credits ≈ $0.00218). A re-reading today returned the same credit price, margin and rate count. Two samples a day would have been enough for those two endpoints; 98 samples a day are for inventory, not for the capability list.
"Still listed" is not "unchanged". All 8 tracked resources stayed listed in every one of the 94 cycles that covered them, and none ever fell outside the scanned window — yet the per-resource records (last update time, accepted amount, terms fingerprint) changed in 16 cycles. If you diff presence only, you miss price and term moves.
The last sample, in full (2026-10-12T11:30:38Z): 100 returned of 19,460 total, 96 priced, 18 distinct hosts; networks 91 Base / 7 Solana / 2 Polygon; every option used scheme exact; price bands 1 in $0.005–0.01, 91 in $0.01–0.05, 1 in $0.05–0.1, 3 in $0.1–1, 4 unclassifiable.
What a 15-minute sample cannot see. A change that appears and reverts between two samples is invisible. Zero failed fetches is not zero blind spots, and one day is far too short to say anything about weekly patterns.
A pre-flight script you can run
This is dependency-free Node. It never pays: it reads the challenge and reports what can and cannot be verified without spending. Run it as node x402-preflight.mjs <url> [--post '<json>'] [--pay-to 0x...].
// x402-preflight.mjs — unpaid pre-flight check for an x402 resource (no dependencies).
const argv = process.argv.slice(2);
const url = argv[0];
const flag = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
const body = flag('--post', null);
const expectPayTo = flag('--pay-to', null);
const expectNetwork = flag('--network', null);
if (!url) { console.error('usage: node x402-preflight.mjs <url> [--post <json>] [--pay-to 0x..]'); process.exit(2); }
const KNOWN = {
'eip155:8453': { name: 'Base mainnet', usdc: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' },
'eip155:84532': { name: 'Base Sepolia (testnet)', usdc: null },
'eip155:137': { name: 'Polygon mainnet', usdc: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359' },
'eip155:42161': { name: 'Arbitrum One', usdc: '0xaf88d065e77c8cc2239327c5edb3a432268e5831' },
};
const say = (ok, label, detail) => console.log(`${ok === null ? 'INFO' : ok ? 'OK ' : 'WARN'} ${label}${detail ? ' — ' + detail : ''}`);
const init = body ? { method: 'POST', headers: { 'content-type': 'application/json' }, body } : { method: 'GET' };
const t0 = Date.now();
const res = await fetch(url, init);
console.log(`${init.method} ${url} -> ${res.status} (${Date.now() - t0} ms)`);
if (res.status === 405) say(false, 'wrong method', `Allow: ${res.headers.get('allow') || '?'} — a GET probe on a POST resource proves nothing`);
if (res.status === 404) say(false, 'not found', 'the URL in the catalogue does not exist');
if (res.status >= 500) say(false, 'server error', `status ${res.status}`);
if (res.status !== 402) {
say(res.status === 200, 'payment challenge',
res.status === 200 ? 'no 402: this call was free (or already paid) — the price cannot be read from here'
: `expected 402, got ${res.status}`);
process.exit(res.status === 200 ? 0 : 1);
}
const b64 = res.headers.get('payment-required');
if (!b64) { say(false, 'PAYMENT-REQUIRED header', 'missing — a client cannot build a payment from this'); process.exit(1); }
let ch;
try { ch = JSON.parse(Buffer.from(b64, 'base64').toString('utf8')); }
catch (e) { say(false, 'PAYMENT-REQUIRED header', `not base64 JSON: ${e.message}`); process.exit(1); }
say(true, 'PAYMENT-REQUIRED header', `top-level keys: ${Object.keys(ch).join(', ')}`);
const v = ch.x402Version;
const wants = v === 2 ? 'PAYMENT-SIGNATURE' : v === 1 ? 'X-PAYMENT' : null;
say(wants !== null, 'x402Version', wants ? `${v} -> send ${wants}` : `unknown version ${JSON.stringify(v)}`);
say(!!ch.resource?.url, 'resource.url', ch.resource?.url || 'missing');
say(ch.resource?.url === url, 'resource.url matches the URL you called',
ch.resource?.url === url ? '' : 'MISMATCH — the challenge may be for another resource');
for (const [i, a] of (Array.isArray(ch.accepts) ? ch.accepts : []).entries()) {
const net = KNOWN[a.network];
const dec = a.extra?.decimals ?? 6; // USDC-style default; read the asset if it differs
const usd = Number(a.amount) / 10 ** dec;
console.log(` [${i}] ${a.scheme} on ${a.network}${net ? ' (' + net.name + ')' : ' (unknown network)'}`);
say(Number.isFinite(usd) && usd > 0, ' amount', `${a.amount} atomic / 1e${dec} = ${usd} ${a.extra?.name || 'tokens'}`);
if (net?.name.includes('testnet')) say(false, ' testnet', `${a.network} — real funds cannot be spent here`);
if (a.asset && net?.usdc) say(a.asset.toLowerCase() === net.usdc, ' asset is the expected USDC',
a.asset.toLowerCase() === net.usdc ? '' : `got ${a.asset}, expected ${net.usdc}`);
if (expectNetwork) say(a.network === expectNetwork, ' network matches --network', a.network || 'missing');
const payTo = a.payTo || '';
say(/^0x[0-9a-fA-F]{40}$/.test(payTo), ' payTo is a 20-byte address', payTo || 'missing');
if (expectPayTo) say(payTo.toLowerCase() === expectPayTo.toLowerCase(), ' payTo matches --pay-to',
payTo.toLowerCase() === expectPayTo.toLowerCase() ? '' : 'DO NOT PAY: recipient differs from the expected address');
say(typeof a.maxTimeoutSeconds === 'number' && a.maxTimeoutSeconds > 0, ' maxTimeoutSeconds', String(a.maxTimeoutSeconds));
}
console.log('\nNot verifiable without paying: that the seller delivers, that the data is correct, and that a second call costs the same.');
Running it against a real paid endpoint (my own, quoted from the actual run) looks like this — and note that the last line is the honest boundary of the whole exercise:
GET https://<your-worker-host>/history -> 402 (805 ms)
OK PAYMENT-REQUIRED header — top-level keys: x402Version, error, resource, accepts, extensions
OK x402Version — 2 -> send PAYMENT-SIGNATURE
OK resource.url matches the URL you called
OK accepts[] — 1 option(s)
[0] exact on eip155:8453 (Base mainnet)
OK amount — 10000 atomic / 1e6 = 0.01 USD Coin
OK asset is the expected USDC
OK payTo matches --pay-to
OK maxTimeoutSeconds — 60
If you point the same script at a POST-only resource with GET, it returns 405 and tells you the probe proved nothing. That failure mode is easy to miss when you are testing by hand.
One reading is not a baseline
A single unpaid probe tells you the shape of a payment today. It cannot tell you that the price moved since the catalogue row was written, that a resource quietly dropped a network, or that a provider answering yesterday is gone today. For that you need two dated readings of the same fields — which is exactly what the collection above is.
I keep that series for one facilitator and publish each state as a fixed snapshot with its own id and period: GET https://x402-catalog-history.ai-business-surface.workers.dev/history, 0.01 USDC on Base (eip155:8453), one GET per delivery. The same host serves its full catalogue and the current snapshot id for free at /, so you can read exactly which period a delivery covers before you decide to pay for it. It is read-only, it makes no live upstream call, and observation is a 15-minute sample — if you need a decision better than that, run your own collector against your own watch list.
What I could not verify
- I never paid a third-party resource. Every price, network, asset and recipient detail above comes from unpaid 402 challenges and public facilitator endpoints.
- Nothing here says a listed resource will deliver. That is only knowable by paying.
- This is one facilitator, one day, and a watch list of 8. It is not a random sample of the ecosystem, and a day is too short for weekly patterns.
Top comments (0)