Migrating an agent marketplace to x402 v2 without breaking v1
If you sell an API over x402, you will eventually be told to migrate to v2, and the reason will not be technical: two of the three real x402 directories reject a v1 endpoint at parse time, before they read anything else about it. That was my situation. This post is the migration itself - what actually changes on the wire, how to ship it without breaking the payers you already have, and the one piece that cannot be done additively.
I have written separately about why discovery required v2. This is the how.
It is a restructure, not a rename sweep
The first thing worth internalising is that bumping x402Version from 1 to 2 produces an invalid document. The challenge object is reshaped, the offers inside it are reshaped, the header names change, and the discovery extension moves.
The headers, first, because they are the easiest to get wrong:
| Direction | v1 | v2 |
|---|---|---|
| Challenge, server to client | body only | PAYMENT-REQUIRED |
| Payment, client to server | X-PAYMENT | PAYMENT-SIGNATURE |
| Receipt, server to client | X-PAYMENT-RESPONSE | PAYMENT-RESPONSE |
All three carry base64-encoded JSON in both versions. Only the names move.
Then the challenge object itself. In v1, each offer inside accepts[] carries its own resource, description and mimeType, and the Bazaar discovery hint rides along as a per-offer outputSchema. In v2, all of that is hoisted: there is one resource object for the whole endpoint, and discovery becomes a top-level extensions.bazaar member on the challenge.
And inside a single offer:
| v1 field | v2 field |
|---|---|
maxAmountRequired | amount |
network: "base" | network: "eip155:8453" |
resource | gone, hoisted to resource.url |
description | gone, hoisted to resource.description |
mimeType | gone, hoisted to resource.mimeType |
outputSchema | gone, replaced by extensions.bazaar |
scheme, asset, payTo, maxTimeoutSeconds, extra | unchanged |
The network row is the one that bites quietly. v2 wants CAIP-2, so Base mainnet is eip155:8453 and Base Sepolia is eip155:84532. Treat that as a rendering derived at the wire boundary, not as a second value you configure. If you store eip155:8453 in your config beside the existing base, you now have two sources of truth for one chain and a migration bug waiting for whoever adds the third network.
The per-offer description deserves a moment too. If yours carries per-tier wording - mine described what each credit pack buys - there is nowhere for it to go. v2 has exactly one resource.description for the entire endpoint. You either fold the tier wording into extra, or you accept that the human-readable text becomes endpoint-level. Decide that deliberately rather than discovering it when the field vanishes.
Serve both versions from one response
The obvious migration is a cutover with a deprecation window. There is a better option, and it comes from the spec rather than from cleverness: the v2 HTTP binding calls the PAYMENT-REQUIRED header the canonical transport location for the challenge and says explicitly that response bodies are a server implementation concern.
So the body is yours to keep. Mine still serves the v1 challenge, byte for byte, while the same response carries the v2 challenge in the header.
Here is that response on my production endpoint, fetched with no API key while writing this:
$ curl -sS -D - -o body.json https://api.cogdepot.com/v1/feed
HTTP/2 402
content-type: application/problem+json
payment-required: eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3...The body decodes to x402Version: 1, with three offers carrying maxAmountRequired, network: "base", a per-offer resource and a per-offer outputSchema. The header decodes to x402Version: 2, with the same three offers carrying amount and network: "eip155:8453", a hoisted resource object, and a top-level extensions.bazaar. One request, both protocol versions, no content negotiation and no flag.
A v1 payer sees exactly what it saw last month. A v2 crawler reads the header and never looks at the body. Nothing is deprecated, so nothing has to be un-deprecated when a straggler shows up.
Why this is cheap: the signed material does not change
Dual-serve would be an expensive trick if v2 changed what the payer signs. It does not.
The inner payload - the authorization the wallet actually signs - is identical in both versions. What changes is the envelope around it: in v1 the scheme and network sit at the top level of the payment, and in v2 they move inside an accepted block that echoes the offer the client chose.
That single fact is what makes the whole migration additive on the receiving side. Your signature verification, your nonce handling and your expiry checks are untouched. You add an envelope parser and dispatch on the declared version.
One invariant matters more than the rest of this post put together: build your payment requirements server-side, from your own matched tier, always. v2's accepted block is the first client-controlled structure in the protocol that contains an asset, a payTo, an amount and a network. It is decoration for your logs. Never forward it to your facilitator and never validate against it - render your own matched tier into v2 shape instead, and reject the payment outright when accepted disagrees with that tier on asset, recipient or amount. This is a comparison, not a convenience.
The part that is not additive
Everything above widens existing code. One thing does not: the client that talks to your facilitator.
Mine was v1 at the type level, not merely at one integer. The outbound request struct hardcoded the protocol version and typed its requirements field to the v1 offer shape - maxAmountRequired, per-offer resource, outputSchema. A v2 settlement has to forward v2-shaped requirements, so the fix is not a parameter, it is making that client generic over both shapes.
Budget for this one properly. If you plan the migration as "add a v2 builder and a v2 envelope parser", you will finish both, serve a perfect v2 challenge, accept a v2 payment, and then fail to settle it - because the last hop still describes it as version 1.
What to check on mainnet afterwards
Shipping the challenge is not the outcome. Getting indexed is, and indexing has its own timing.
Swept today across all 150 offset pages of the Coinbase Developer Platform x402 discovery catalog - 14,973 resources - my endpoint appears exactly once, as https://api.cogdepot.com/v1/feed, x402Version: 2, lastUpdated: 2026-09-06T15:03:53.6Z.
Two things in that one line are worth copying into your own checklist. First, sweep every offset page. A single-page query will tell you that you are not listed when you are. Second, look hard at lastUpdated. It still reads the date of the payment that seeded the listing, two weeks ago, because nothing has paid that endpoint since. Catalog entries decay on inactivity, so a listing earned by one self-funded settlement is a listing with a clock on it, not a permanent placement.
That is the honest end of this migration: v2 got me through the parse gate the directories enforce, and being through the gate is a precondition for discovery rather than discovery itself.