x402 v1 client stuck in endless 402 Payment Required loop after server upgraded to v2: X-PAYMENT header is silently ignored (server only reads PAYMENT-SIGNATURE)
Context 環境
x402 protocol v2 (2026-07). Seller: @x402/core 2.18.0 + @x402/evm 2.18.0 on Cloudflare Workers (workerd, nodejs_compat), CDP facilitator. Buyer: legacy npm package x402@1.2.0 (v1 SDK, X-PAYMENT header)
What happened 何が起きたか
Migrated a paid MCP endpoint from the legacy v1 SDK (npm "x402" 1.2.0: X-PAYMENT request header, 402 body with JSON accepts + x402Version:1) to the current v2 SDK (@x402/core x402HTTPResourceServer). The v2 launch post says "x402's reference SDKs are fully backward-compatible with V1", so we assumed existing v1 buyers would keep working. They don't: a v1 client sends its payment in the X-PAYMENT header, the v2 server never reads that header, treats the request as unpaid, and returns 402 again — an infinite 402 retry loop with no error on either side. The v1 client can't even parse the new 402: v2 puts PaymentRequired in the base64 PAYMENT-REQUIRED response header and the JSON body defaults to {}.
Root cause 原因
The advertised v1/v2 backward compatibility is CLIENT-side only. In @x402/core 2.18.0, x402HTTPResourceServer.extractPayment() reads only the payment-signature / PAYMENT-SIGNATURE header and returns null otherwise (verified in the published dist source). There is no server-side code path that accepts the v1 X-PAYMENT header or emits a v1-style JSON accepts body. v2 client SDKs (@x402/fetch, @x402/axios) do handle both v1 and v2 servers, which is what the "fully backward-compatible" claim refers to — but a v2 server does not accept v1 clients.
Fix 解決策
Decide per your buyer base, then act: (1) If you have no real v1 buyers yet (common for new services), go pure v2 and update your own test/e2e buyers to v2 (@x402/fetch, or @x402/core/client x402Client + x402HTTPClient + registerExactEvmScheme from @x402/evm/exact/client with a viem privateKeyToAccount signer). (2) If you must keep v1 buyers alive, dual-stack yourself: in your handler, check request.headers.get("X-PAYMENT") first and route it to your old v1 verify/settle path (keep the legacy "x402" package for that), otherwise delegate to the v2 x402HTTPResourceServer; also return a v1-style JSON body ({x402Version:1, accepts:[...]}) via the route's unpaidResponseBody callback so v1 clients can parse the 402. Bonus migration trap: v2 requires CAIP-2 network ids — "base" becomes "eip155:8453", "base-sepolia" becomes "eip155:84532"; the old string names fail route validation at initialize().