This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.
The protocol looked finished when curl printed 402. The catalog disagreed. An ephemeral Cloudflare tunnel was a perfect Payment Required and still not a listing. The smash was realizing those are different bugs.
HTTP 402 was the easy part — x402scan still refused the tunnel
The Setting
Tek Labs needed an HTTP service that speaks x402 v2 on the wire and can lint any URL for the same shape. The service is x402-linter: it is itself gated by Payment Required. POST /lint advertises 10000 atomic USDC ($0.01). POST /health advertises 1000 ($0.001). Both use scheme exact, network eip155:8453 (Base), asset 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 (Base USDC), and payTo 0xAe51ada17E9C47B4de6E1b2405049C79D1BBE3fA.
The first build was local-only. No wallet keys, no facilitator, no settlement. A missing PAYMENT-SIGNATURE always 402s unless a local bypass header is set. The job was never "ship a paid product." It was: return a valid v2 Payment Required, then see whether a public catalog (x402scan) would accept the origin. Those two checks are not the same.
The live origin now is https://tek-labs-402-linter.labstek9.workers.dev. Getting there is the story.
The First Sign
Unpaid POST /lint already returned HTTP 402 with a JSON PaymentRequired body. A local smoke against our own /health passed the status check. That felt like the protocol was done.
It was not. Two failures showed up in order.
First, our own linter scored the same 402 as incomplete. Status 402 is only one finding. The v2 HTTP transport also wants a PAYMENT-REQUIRED header: base64 of the same JSON as the body. Without it the lint report marks missing_header. Cross-origin clients never see a custom header unless CORS exposes it. Returning 402 and stopping there is a protocol bug even when the body looks right.
Second, we pointed a trycloudflare hostname at the same process so a catalog could fetch it. x402scan did not argue about x402Version or accepts. It refused the origin:
Tunnel URLs are ephemeral and can't be reliably discovered by agents. Deploy your API to a permanent URL to register.
The origin was https://arranged-players-computation-pipeline.trycloudflare.com. That is a tunnel, not a listing. registerFromOrigin was never going to stick on a hostname that dies with the process.
A correct 402 on localhost, and even a correct 402 on a public tunnel, still failed the thing we actually wanted: durable discovery.
The Investigation
We treated the 402 as a checklist, not a status code.
Status. Unpaid POST /lint and POST /health must be 402. Anything else is http_status and a hard score cut. That part was already green.
Header. The linter reads payment-required (HTTP is case-insensitive). It base64-decodes the value and JSON.parses it. If that fails, the finding is header_b64. If it succeeds, the same object is walked as the body: x402Version === 2, resource.url present, accepts a non-empty array, first accept using exact, CAIP-2 network (colon required), amount a string of digits, asset, payTo, maxTimeoutSeconds a number. Then we compare decoded header to the JSON body. Match is body_matches_header. Mismatch is a warn. Body-only 402s can still be graded, but they lose the header points.
CORS. A browser or a browser-shaped indexer will not expose PAYMENT-REQUIRED unless Access-Control-Expose-Headers lists it. We also needed OPTIONS 204 and Access-Control-Allow-Origin: * so a fetch from another origin could read the 402 at all. Without expose-headers, the Node server can send the header and a JS client still reports it missing.
Discovery documents. Status-plus-header is the payment challenge. Catalogs do not only POST your paid route and hope. They look for a machine-readable map: OpenAPI 3.1 at /openapi.json with x-payment-info and x-discovery.ownershipProofs, plus GET /.well-known/x402 listing resource URLs and the same ownership address. Resource URLs are built from the request host and x-forwarded-proto, so a proxy does not advertise http://127.0.0.1:8402 to the world.
The catalog. After the wire format was complete, the remaining failure was policy, not JSON. x402scan's message is explicit: ephemeral tunnels are not a discovery target. Agents cannot bookmark a hostname that dies with the process. Cheap permanent hosts we actually had: latest Wrangler wanted Node 22 (the box is Node 20.19.2). GitHub Pages is static and cannot POST 402. *.vercel.app is disallowed by the catalog. A Cloudflare Worker on a stable *.workers.dev hostname was the path that worked.
Wrong theories we dropped: "x402scan is down," "the 402 body is invalid v2," "we need a real PAYMENT-SIGNATURE to register." None of those matched the tunnel error. The catalog never got as far as verifying payment. It rejected the hostname class.
The Root Cause
Two layers, one story.
Layer 1 — incomplete 402. HTTP 402 means Payment Required. x402 v2 means a specific object: version 2, a resource, an accepts list with scheme/network/asset/amount/payTo, plus the same payload in PAYMENT-REQUIRED so intermediaries and clients that do not parse the body still see the challenge. CORS that does not expose that header makes the challenge invisible to browsers. A server that only writeHead(402) and dumps JSON is "402-correct" and still lint-wrong.
Layer 2 — discovery is not the 402. OpenAPI and /.well-known/x402 tell an agent what is paid and who owns it. They do not make a trycloudflare name stable. A catalog that indexes agents has to refuse origins that vanish. That is not a parse error. It is an origin lifetime check. We confused "the challenge is well-formed" with "this URL can be listed."
The payTo address is part of both layers: it is accepts[].payTo on the 402, ownershipProofs on well-known, and x-discovery.ownershipProofs on OpenAPI. Same address everywhere. That is ownership, not hosting.
local POST /lint --> 402 + PAYMENT-REQUIRED + CORS (lint can pass)
well-known + OpenAPI --> what/who to pay (agents can parse)
trycloudflare origin --> ephemeral (x402scan refuses)
workers.dev origin --> permanent HTTPS (catalog accepts)
The Fix
We completed the wire format, then moved the same process onto a permanent Worker.
send402 writes the header and the body from the same object:
function send402(req, res, pathname) {
const body = paymentRequired(pathname, req);
const encoded = Buffer.from(JSON.stringify(body), "utf8").toString("base64");
res.writeHead(402, {
"Content-Type": "application/json",
"PAYMENT-REQUIRED": encoded,
...corsHeaders(),
});
res.end(JSON.stringify(body));
}
CORS exposes the header:
function corsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, HEAD",
"Access-Control-Allow-Headers": "*",
"Access-Control-Expose-Headers": "PAYMENT-REQUIRED",
};
}
Discovery is two GETs. /openapi.json is OpenAPI 3.1 with x-payment-info ($0.01 lint, $0.001 health) and x-discovery.ownershipProofs: [payTo]. /.well-known/x402 returns { version: 1, x402Version: 2, resources: [origin/lint, origin/health], ownershipProofs: [payTo] }.
The Worker is https://tek-labs-402-linter.labstek9.workers.dev. Unpaid POST /lint is still HTTP 402 with the same payTo. GET /openapi.json is 200. x402scan checkDiscovery found the OpenAPI map:
{"found":true,"source":"openapi","resourceCount":2,"resources":[{"url":"https://tek-labs-402-linter.labstek9.workers.dev/lint","method":"POST","authMode":"paid","description":"Lint a URL"},{"url":"https://tek-labs-402-linter.labstek9.workers.dev/health","method":"POST","authMode":"paid","description":"Health"}],"ownershipProofs":["0xAe51ada17E9C47B4de6E1b2405049C79D1BBE3fA"]}
registerFromOrigin then split the two routes:
{"success":true,"registered":1,"failed":1,"total":2,"source":"openapi","failedDetails":[{"url":"https://tek-labs-402-linter.labstek9.workers.dev/health","error":"validation: Missing input schema — add a requestBody or parameter schema to your OpenAPI spec"}],"originId":"ef9b1c25-6a34-4420-8de4-d114187475d8"}
/lint was accepted on that register call. /health was not, because OpenAPI had no requestBody. That is a leftover discovery bug, not a payment bug. We did not buy a listing and did not re-pay the catalog search to re-confirm the row. We did not settle USDC. Tradeoff: the Worker still does not verify PAYMENT-SIGNATURE. Local lint still uses X-TEK-UNPAID-OK: 1. That is a lab bypass, not a facilitator.
The Aftermath
Local unpaid 402 is now a real v2 challenge: status, header, body match, schema fields, CORS. Discovery documents live on the same origin. x402scan can fetch them today (checkDiscovery found the OpenAPI map). A prior registerFromOrigin accepted POST /lint as originId ef9b1c25-6a34-4420-8de4-d114187475d8.
What we will not claim: there is no unpaid permalink. /origin/ef9b1c25-6a34-4420-8de4-d114187475d8 and /resource?url=…/lint both 404. The Marketplace HTML does not currently show the name. Catalog search APIs themselves 402 if you do not pay. Discovery working is not the same as a public listing page.
/health still fails registration for a missing input schema. The process change is the useful part: treat "can an agent find this tomorrow?" as a separate acceptance test from "does curl get 402?" and treat "does OpenAPI describe the body?" as a third test after the origin is stable.
What I Learned
A status code is not a protocol. x402 v2 lives in the header, the body, and the CORS expose list. If you only test curl -w '%{http_code}', you will ship a 402 that your own linter fails.
Discovery is not payment. OpenAPI and well-known answer what and who. A registry also asks where, and it will reject a tunnel that answers 402 perfectly. Do not treat a trycloudflare URL as a listing candidate.
The useful split for the next paid HTTP endpoint: (1) challenge correctness, (2) discovery documents, (3) origin lifetime, (4) OpenAPI request/response schemas the catalog can validate. We finished 1–3. 4 still bites /health.
Submitted for DEV Summer Bug Smash 2026, Smash Stories track.
Top comments (0)