{"slug": "migrating-a-live-x402-service-from-v1-to-v2-on-base-mainnet", "title": "Migrating a live x402 service from V1 to V2 on Base mainnet", "summary": "A developer migrating a live x402 payment-gated API from V1 to V2 on Base mainnet documented undocumented gotchas, including that V1 services are rejected by x402scan and silently lose requests from V2-capable clients. The migration requires switching from frozen V1 packages to the scoped @x402/* namespace, using subpath imports like @x402/core/server, and registering a scheme with eip155:8453.", "body_md": "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.\n\nWritten from [x402ai](https://github.com/ukenal/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.\n\n**Last verified:** July 2026 · `@x402/*`\n\n2.19.0 · `@coinbase/x402`\n\n2.1.0\n\n**In a hurry?** → Migration checklist\n\nIf 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:\n\n**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.\n\n**Agents are already failing against you silently.** Check your logs for `invalid_payload`\n\nerrors 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.\n\nThat second one is the part I'd flag hardest. I had those errors in my logs for weeks and filed them mentally as noise.\n\nFive things, in rough order of how much time they'll cost you:\n\n`x402-hono`\n\nis frozen at 1.2.0. The live line is the scoped `@x402/*`\n\nnamespace.`payment-required`\n\nheader.`eip155:8453`\n\nrather than the old string form.`X-Forwarded-Proto`\n\nis ignored.`http://`\n\nunless you set them explicitly.This tripped me up before I wrote a line of code, because searching for x402 packages surfaces the frozen ones first.\n\n**Frozen (V1):** `x402-hono`\n\n@ 1.2.0 — final release, do not build on it.\n\n**Live (V2):** the scoped namespace —\n\n`@x402/hono`\n\n(or your framework's equivalent)`@x402/core`\n\n`@x402/evm`\n\n`@x402/extensions`\n\n`@coinbase/x402`\n\n— for CDP facilitator configAt time of writing the `@x402/*`\n\npackages were at 2.19.0 and `@coinbase/x402`\n\nat 2.1.0. Check current versions; this line moves.\n\n```\nnpm install @x402/hono@2 @x402/core@2 @x402/evm@2 @coinbase/x402@2 --legacy-peer-deps\n```\n\nYou will likely need `--legacy-peer-deps`\n\n. 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.\n\n**Before (V1):** middleware took a routes object and a facilitator, with auth headers you assembled yourself.\n\n**After (V2):** you build a resource server, register a scheme against a network, then hand that to the middleware.\n\nThis 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:\n\n``` js\nimport { paymentMiddleware } from '@x402/hono'\nimport { x402ResourceServer, HTTPFacilitatorClient } from '@x402/core/server'\nimport { createFacilitatorConfig } from '@coinbase/x402'\nimport { ExactEvmScheme } from '@x402/evm/exact/server'\n```\n\n`@x402/core/server`\n\n, not `@x402/core`\n\n. `@x402/evm/exact/server`\n\n, not `@x402/evm`\n\n.\n\n``` js\nconst facilitatorClient = new HTTPFacilitatorClient(\n  createFacilitatorConfig(\n    process.env.CDP_API_KEY_NAME,\n    process.env.CDP_API_KEY_PRIVATE_KEY\n  )\n)\n\nconst resourceServer = new x402ResourceServer(facilitatorClient)\n  .register('eip155:8453', new ExactEvmScheme())\n\nconst routes = {\n  'POST /api/embed': {\n    accepts: {\n      scheme: 'exact',\n      price: prices['/api/embed'],\n      network: 'eip155:8453',\n      payTo: process.env.WALLET_ADDRESS,\n    },\n    resource: new URL('/api/embed', baseUrl).toString(),\n    description: 'Text embeddings (768-dim) via nomic-embed-text. POST {input: string}.',\n    mimeType: 'application/json',\n  },\n  // ...one entry per paid route\n}\n\nreturn paymentMiddleware(routes, resourceServer)\n```\n\n`register()`\n\nis fluent — it returns the resource server, so it chains off the constructor.\n\nV1 nested `description`\n\nunder a `config`\n\nkey — and silently ignored a top-level `description`\n\n, which is a fun thing to discover after wondering why your route descriptions never showed up anywhere.\n\nV2 keys routes by `METHOD /path`\n\n, takes `accepts`\n\nas a single object rather than an array, and puts `description`\n\nand `mimeType`\n\nat the top level as siblings of `accepts`\n\n.\n\nIf you were on CDP's facilitator under V1, there's a decent chance you did what I did: imported `generateJwt`\n\nthrough `@coinbase/cdp-sdk`\n\n's internal `_cjs`\n\npath via `createRequire`\n\n, then hand-built auth headers on every request.\n\nThat was never supported. It worked, but it depended on the internal module layout of someone else's package.\n\n`createFacilitatorConfig(apiKeyId, apiKeySecret)`\n\nfrom `@coinbase/x402`\n\nis the supported replacement. The entire hack — the `createRequire`\n\nshim, the internal import, the `createAuthHeaders`\n\nfunction — 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.\n\n`https`\n\ngotcha — and the workaround you can now delete\nUnder V1, if you terminated TLS upstream (Cloudflare tunnel, nginx, anything), the middleware saw the internal request as plain HTTP and advertised your resource URLs as `http://`\n\n. The standard fix was middleware that injected forwarded headers:\n\n``` js\n// V1-era workaround\napp.use('*', async (c, next) => {\n  c.req.raw.headers.set('X-Forwarded-Proto', 'https')\n  c.req.raw.headers.set('X-Forwarded-Host', baseUrl.host)\n  await next()\n})\n```\n\n**V2 ignores those headers.** So the workaround silently stops doing anything, your resource URLs revert to `http://`\n\n, and the middleware that was supposed to prevent exactly that is still sitting in your file looking load-bearing.\n\nThe V2 fix is the explicit `resource`\n\nfield per route, shown above. Runtime resolution is effectively `routeConfig.resource || adapter.getUrl()`\n\n— anything you set explicitly wins, anything you don't falls back to the derived (wrong) URL. Set it on every paid route; there is no global override.\n\nOnce that's in place, delete the forwarded-header middleware. I still had mine months after the migration, which is how this section came to be written.\n\nThis is where people think the migration broke something.\n\nA correct V2 402 returns an **empty JSON body** — literally `{}`\n\n. The challenge lives in the `payment-required`\n\nresponse header, base64-encoded. If you're used to V1's body-carried challenge, an empty body reads like a bug.\n\nTo check it:\n\n```\ncurl -s -i -X POST https://your-api.example/api/endpoint \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"input\":\"check\"}' \\\n  | grep -i payment-required \\\n  | sed 's/payment-required: //' | tr -d '\\r' | base64 -d | jq .\n```\n\nYou're looking for `x402Version: 2`\n\n, your CAIP-2 network (`eip155:8453`\n\nfor Base mainnet), the correct asset and `payTo`\n\naddress, and a `resource`\n\nfield that starts with `https://`\n\n.\n\nNeither is required by V2, but both are easier to do during a migration than to retrofit.\n\n**Validate input before the payment gate.** Register your validation middleware ahead of the payment middleware so a malformed request gets a\n\n`400`\n\ninstead of a `402`\n\n. Otherwise you're asking an agent to pay before you tell it the request was never going to work — which is bad behavior, and worse when the payer is an automated client with no human to notice.**Guard the startup window.** The payment handler is built asynchronously (it constructs a facilitator client), so there's a window where the server can accept connections before the handler exists. A delegate middleware that returns `503 service initializing`\n\nuntil the handler resolves is cheap insurance against serving unprotected routes during a restart.\n\nThe client rewrite is smaller than the server one, and the same subpath trap applies:\n\n``` python\nfrom x402 import x402ClientConfig, SchemeRegistration\nfrom x402.mechanisms.evm.exact.client import ExactEvmScheme\nfrom x402.http.clients.requests import wrapRequestsWithPaymentFromConfig\n\nconfig = x402ClientConfig(\n    schemes=[\n        SchemeRegistration(\n            network=\"eip155:8453\",\n            client=ExactEvmScheme(signer=account),\n        ),\n    ],\n)\n\nsession = wrapRequestsWithPaymentFromConfig(requests.Session(), config)\nresp = session.post(ENDPOINT, json={\"input\": \"...\"}, timeout=90)\n```\n\nThat's the entire integration. The wrapper handles the whole dance — sends the unpaid request, receives the 402, decodes the header challenge, signs the EIP-3009 authorization, retries. You never hand-parse a challenge.\n\nSymmetric with the request side, and easy to miss: on success, settlement details come back base64-encoded in an `X-PAYMENT-RESPONSE`\n\nheader (some stacks emit `payment-response`\n\n— check both). Decode it for the transaction hash:\n\n```\nsettle = resp.headers.get(\"X-PAYMENT-RESPONSE\") or resp.headers.get(\"payment-response\")\ninfo = json.loads(base64.b64decode(settle))\nprint(f\"https://basescan.org/tx/{info.get('transaction')}\")\n```\n\nIf you're logging anything about payments, that header is where the on-chain proof lives.\n\nWorth stating plainly because it surprises people: **EIP-3009 is gasless for the payer.** The buyer signs a transfer authorization; the facilitator submits and pays gas. A payer wallet needs USDC on Base and nothing else — no ETH, ever.\n\nThis is also why a legitimate agent wallet can show zero self-submitted transactions on a block explorer while having successfully paid you many times. When I got my first payment from an unfamiliar address, its transaction history was empty, which reads as suspicious until you remember the payer never submits anything.\n\nKeep your V1 client around as a `.bak`\n\nuntil the V2 one settles. Mine earned its keep.\n\nMigrating to V2 isn't sufficient for x402scan. It also wants a discovery document, and in practice `/openapi.json`\n\nis what gets crawled.\n\nAn OpenAPI 3.1 document, served *before* your payment gate, with per-endpoint payment metadata:\n\n`x-payment-info`\n\ncarrying a fixed decimal USD amount (`0.010000`\n\n, not `0.01`\n\n— use consistent decimal precision)`protocols: [{ x402: {} }]`\n\n`/health`\n\n, discovery routes) marked `security: []`\n\nThree things I'd flag:\n\n**Match your response field names to your actual handlers.** My OpenAPI advertised generic field names while the handlers returned `embedding`\n\n, `summary`\n\n, and `answer`\n\n. A crawler probing the documented shape gets a mismatch.\n\n**Unify your request field names before you publish the spec.** My three endpoints had grown independently and took `input`\n\n, `text`\n\n, and `question`\n\nrespectively — while the OpenAPI doc advertised `input`\n\nfor all three. Two of the three would have failed any automated probe. Standardizing on one field name across every endpoint is worth doing regardless; a public spec makes it mandatory.\n\n**Two different price representations coexist, and it's easy to mix them up.** The V1-shaped `/.well-known/x402`\n\nmanifest expresses price in atomic token units as a string — `'10000'`\n\nfor $0.01 USDC, which has six decimals. The OpenAPI `x-payment-info`\n\nblock expresses it as decimal USD — `'0.010000'`\n\n. Same price, two encodings, and no error if you put the wrong one in the wrong place.\n\nWorth noting: my `/.well-known/x402`\n\nmanifest was still V1-shaped (`x402Version: 1`\n\n, `network: 'base'`\n\nrather than `eip155:8453`\n\n) at registration time and nothing complained, because registration went through `/openapi.json`\n\n. If you're short on time, prioritize the OpenAPI doc — but know you're carrying a stale manifest.\n\n**The CDP facilitator has a minimum price floor.** Price anything below it and settlement fails. I ended up at $0.01 / $0.02 / $0.04 across three endpoints, which sits just above it. If your business model assumed sub-cent pricing, verify against the current floor before building around it.\n\n** x402.org's facilitator is testnet-only.** It's the obvious default when you're reading the spec, and it will not settle on mainnet. For Base mainnet you need CDP's, or another mainnet-capable facilitator.\n\n`invalid_payload`\n\nerrors in your logs)`@x402/*`\n\nscoped packages with `--legacy-peer-deps`\n\n`@x402/core/server`\n\n, `@x402/evm/exact/server`\n\n`HTTPFacilitatorClient`\n\n→ `x402ResourceServer`\n\n→ `.register('eip155:8453', new ExactEvmScheme())`\n\n→ `paymentMiddleware`\n\n`createFacilitatorConfig`\n\n`accepts`\n\nobject, sibling `description`\n\n/`mimeType`\n\n`resource`\n\nfield per paid route (fixes `http://`\n\n)`X-Forwarded-Proto`\n\nmiddleware`payment-required`\n\nheader, not the body`.bak`\n\n`X-PAYMENT-RESPONSE`\n\nheader`/openapi.json`\n\n, before the payment gateDo the OpenAPI document first, even before the code migration. It forces you to confront your API's inconsistencies — mismatched field names, undocumented response shapes, endpoints that grew independently — while you can still fix them freely. I did it last and had to go back and standardize things I'd have caught earlier.\n\nAnd migrate before you need to. The V1 stack still works right up until the moment discovery matters, and by then you're doing an urgent migration instead of a calm one.\n\n*Written by Landy Ukena. I run x402ai — three payment-gated AI inference endpoints on Base mainnet, self-hosted and settling real USDC via the Coinbase CDP facilitator. LinkedIn*", "url": "https://wpnews.pro/news/migrating-a-live-x402-service-from-v1-to-v2-on-base-mainnet", "canonical_source": "https://dev.to/ukenal/migrating-a-live-x402-service-from-v1-to-v2-on-base-mainnet-1mg9", "published_at": "2026-07-23 21:53:13+00:00", "updated_at": "2026-07-23 22:33:45.851130+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["x402", "Base", "Coinbase", "CDP", "x402scan", "@x402/hono", "@x402/core", "@x402/evm"], "alternates": {"html": "https://wpnews.pro/news/migrating-a-live-x402-service-from-v1-to-v2-on-base-mainnet", "markdown": "https://wpnews.pro/news/migrating-a-live-x402-service-from-v1-to-v2-on-base-mainnet.md", "text": "https://wpnews.pro/news/migrating-a-live-x402-service-from-v1-to-v2-on-base-mainnet.txt", "jsonld": "https://wpnews.pro/news/migrating-a-live-x402-service-from-v1-to-v2-on-base-mainnet.jsonld"}}