Migrating a live x402 service from V1 to V2 on Base mainnet

Migrating a live x402 service from V1 to V2 on Base mainnet

在 Base 主网上将现有的 x402 服务从 V1 迁移至 V2

x402 V1 → V2 Migration Guide Field notes from migrating a live, payment-gated API from x402 V1 to V2 on Base mainnet — the undocumented gotchas, working code from a service in production, and a checklist at the bottom. Written from x402ai, which settles real USDC on Base via the Coinbase CDP facilitator. Every code sample here is running in production, not reconstructed from the spec. Last verified: July 2026 · @x402/* 2.19.0 · @coinbase/x402 2.1.0 In a hurry? → Migration checklist

x402 V1 → V2 迁移指南。这是一份关于在 Base 主网上将现有的支付网关 API 从 x402 V1 迁移到 V2 的实战笔记,包含了未记录的坑、生产环境中的可用代码以及文末的检查清单。本文基于 x402ai 编写,该服务通过 Coinbase CDP 协调器在 Base 上结算真实的 USDC。文中的每个代码示例均在生产环境中运行,而非根据规范重构。最后验证时间:2026 年 7 月 · @x402/* 2.19.0 · @coinbase/x402 2.1.0。赶时间?→ 查看迁移检查清单。

Why you’re probably being forced into this

为什么你可能被迫进行迁移

If you built an x402 service before mid-2026, you’re on V1. You may not have noticed, because V1 services keep serving 402s and keep settling payments. Nothing crashes. The migration pressure comes from two directions instead: Discovery rejects you. Submitting a V1 origin to x402scan returns, in effect, “x402 v1 response detected — migrate to v2 spec”, plus a second complaint about the absence of a discovery document. Since x402scan is where autonomous agents actually find services, a V1 service is invisible to the thing that generates traffic. Agents are already failing against you silently. Check your logs for invalid_payload errors you never explained. Those are V2-capable clients attempting payment against V1 middleware and bouncing. You’ve been losing real requests, and the error message doesn’t say why. That second one is the part I’d flag hardest. I had those errors in my logs for weeks and filed them mentally as noise.

如果你在 2026 年年中之前构建了 x402 服务,那么你目前使用的是 V1 版本。你可能没有察觉,因为 V1 服务仍在继续响应 402 状态码并处理支付,一切运行正常。然而,迁移压力来自两个方面:首先是发现机制的拒绝。将 V1 源提交给 x402scan 时,实际上会返回“检测到 x402 v1 响应 — 请迁移至 v2 规范”,并提示缺少发现文档。由于 x402scan 是自主智能体寻找服务的入口,V1 服务对于流量生成器来说是不可见的。智能体已经在静默地对你的服务发起失败请求。检查你的日志,看看是否有无法解释的 invalid_payload 错误。那是支持 V2 的客户端在尝试向 V1 中间件支付时被拒绝的结果。你一直在丢失真实的请求,而错误信息却未说明原因。我最想强调的是第二点:我的日志里几周前就出现了这些错误,但我当时把它们当作噪音忽略了。

What actually changed

实际发生了哪些变化

Five things, in rough order of how much time they’ll cost you: The package line moved. x402-hono is frozen at 1.2.0. The live line is the scoped @x402/* namespace. The challenge moved from the body to a header. A V2 402 has an empty JSON body and carries the challenge base64-encoded in a payment-required header. Network identifiers are CAIP-2 now. eip155:8453 rather than the old string form. CDP facilitator auth is supported natively. If you were reaching into SDK internals to mint JWTs, you can delete that. X-Forwarded-Proto is ignored. If you terminate TLS at a tunnel or proxy, your resource URLs will advertise as http:// unless you set them explicitly.

有五件事需要注意,按耗时程度大致排序:包路径发生了变化。x402-hono 冻结在 1.2.0 版本,当前活跃的是 @x402/* 命名空间。挑战(Challenge)从响应体移到了响应头中。V2 的 402 响应具有空的 JSON 体,并将 base64 编码的挑战信息放在 payment-required 响应头中。网络标识符现在使用 CAIP-2 标准(例如 eip155:8453,而不是旧的字符串格式)。CDP 协调器认证现在是原生支持的,如果你之前是通过深入 SDK 内部来生成 JWT,现在可以删掉这些代码了。X-Forwarded-Proto 被忽略,如果你在隧道或代理处终止 TLS,除非显式设置,否则你的资源 URL 将会以 http:// 开头。

The package landscape

包生态概览

This tripped me up before I wrote a line of code, because searching for x402 packages surfaces the frozen ones first. Frozen (V1): x402-hono @ 1.2.0 — final release, do not build on it. Live (V2): the scoped namespace — @x402/hono (or your framework’s equivalent), @x402/core, @x402/evm, @x402/extensions, @coinbase/x402 — for CDP facilitator config. At time of writing the @x402/* packages were at 2.19.0 and @coinbase/x402 at 2.1.0. Check current versions; this line moves.

这在我写第一行代码之前就坑了我,因为搜索 x402 包时,首先出现的是已冻结的版本。冻结版 (V1):x402-hono @ 1.2.0 — 这是最终版本,请勿基于此构建。活跃版 (V2):使用作用域命名空间 — @x402/hono(或你所用框架的对应包)、@x402/core@x402/evm@x402/extensions 以及用于 CDP 协调器配置的 @coinbase/x402。撰写本文时,@x402/* 包版本为 2.19.0,@coinbase/x402 为 2.1.0。请检查最新版本,因为版本更新很快。

Install gotcha

安装时的注意事项

npm install @x402/hono@2 @x402/core@2 @x402/evm@2 @coinbase/x402@2 --legacy-peer-deps

You will likely need --legacy-peer-deps. The optional paywall peer dependency wants React 19; if your project is on React 18 (or has any other React in the tree), npm refuses the install outright. The paywall is optional and unrelated to server-side payment gating, so the flag is safe here — but know why you’re using it rather than reflexively adding it.

你很可能需要使用 --legacy-peer-deps。可选的 paywall 对等依赖项要求 React 19;如果你的项目使用的是 React 18(或树中存在其他 React 版本),npm 会直接拒绝安装。由于 paywall 是可选的,且与服务端支付网关无关,因此使用该标志是安全的,但请务必了解你使用它的原因,而不是条件反射式地添加它。

The server rewrite

服务端重写

Before (V1): middleware took a routes object and a facilitator, with auth headers you assembled yourself. After (V2): you build a resource server, register a scheme against a network, then hand that to the middleware.

重写前 (V1):中间件接收一个路由对象和一个协调器,认证头需要你自己组装。重写后 (V2):你需要构建一个资源服务器,针对特定网络注册方案(scheme),然后将其传递给中间件。

Watch the import subpaths

注意导入的子路径

This cost me time before a single line of logic ran. The V2 packages use subpath exports, and the obvious top-level import is not the one you want:

这在我运行第一行逻辑之前就浪费了我不少时间。V2 包使用了子路径导出,显而易见的顶层导入并不是你想要的:

import { paymentMiddleware } from '@x402/hono'
import { x402ResourceServer, HTTPFacilitatorClient } from '@x402/core/server'
import { createFacilitatorConfig } from '@coinbase/x402'
import { ExactEvmScheme } from '@x402/evm/exact/server'

@x402/core/server, not @x402/core. @x402/evm/exact/server, not @x402/evm.

注意是 @x402/core/server 而不是 @x402/core;是 @x402/evm/exact/server 而不是 @x402/evm

Building the handler

构建处理器

const facilitatorClient = new HTTPFacilitatorClient(
  createFacilitatorConfig(
    process.env.CDP_API_KEY_NAME,
    process.env.CDP_API_KEY_PRIVATE_KEY
  )
)

const resourceServer = new x402ResourceServer(facilitatorClient)
  .register('eip155:8453', new ExactEvmScheme())

const routes = {
  'POST /api/embed': {
    accepts: {
      scheme: 'exact',
      price: prices['/api/embed'],
      network: 'eip155:8453',
      payTo: process.env.WALLET_ADDRESS,
    },
    resource: new URL('/api/embed', baseUrl).toString(),
    description: 'Text embeddings (768-dim) via nomic-embed-text. POST {input: string}.',
    mimeType: 'application/json',
  },
  // ...one entry per paid route
}

return paymentMiddleware(routes, resourceServer)

register() is fluent — it returns the resource server, so it chains off the constructor.

register() 是流式接口(fluent interface)——它返回资源服务器实例,因此可以直接在构造函数后链式调用。

Route config shape changed

路由配置结构的变化

V1 nested description under a config key — and silently ignored a top-level description, which is a fun thing to discover after wondering why your route descriptions never showed up anywhere. V2 keys routes by METHOD /path, takes accepts as a single object rather than an array, and puts description and mimeType at the top level as siblings of accepts.

V1 将 description 嵌套在 config 键下,并静默忽略了顶层的 description,这在你纳闷为什么路由描述从未显示时会是一个有趣的发现。V2 使用 METHOD /path 作为路由键,将 accepts 作为一个单一对象而非数组,并将 descriptionmimeType 放在与 accepts 同级的顶层。

Deleting the JWT hack

删除 JWT 破解方案

If you were on CDP’s facilitator under V1, there’s a decent chance you did what I did: imported generateJwt through @coinbase/cdp-sdk’s internal _cjs path via createRequire, then hand-built auth headers on every request. That was never supported. It worked, but it depended on the internal module layout of someone else’s package. createFacilitatorConfig(apiKeyId, apiKeySecret) from @coinbase/x402 is the supported replacement. The entire hack — the createRequire shim, the internal import, the createAuthHeaders function — deletes cleanly. This was the single most satisfying part of the migration and the strongest argument for doing it even if discovery weren’t forcing your hand.

如果你在 V1 下使用 CDP 协调器,很有可能你和我一样:通过 createRequire@coinbase/cdp-sdk 的内部 _cjs 路径导入 generateJwt,然后在每个请求上手动构建认证头。这从来都不是官方支持的做法。它虽然能用,但依赖于他人包的内部模块布局。现在,来自 @coinbase/x402createFacilitatorConfig(apiKeyId, apiKeySecret) 是官方支持的替代方案。整个破解方案——createRequire 垫片、内部导入、createAuthHeaders 函数——都可以直接删除。这是迁移过程中最令人满意的一部分,也是即使发现机制没有强制要求,也值得进行迁移的最有力理由。