---
title: "Migrating an agent marketplace to x402 v2 without breaking v1"
description: "x402 v2 is a restructure, not a version bump. Here is the field-by-field wire diff, the one invariant that lets you serve both versions from a single HTTP response, and the part of the migration that is genuinely not additive."
date: 2026-09-20
canonical_url: "https://cogdepot.com/writing/migrating-to-x402-v2"
tags: ["x402", "payments", "agents", "api"]
---

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.
