We run FiatDock — a non-custodial USDC ↔ bank on/off-ramp where AI agents pay $0.05 per call over x402. This week we migrated the whole stack (Express server, fetch client, MCP server) from x402 v1 to protocol v2. It took an evening, killed all 24 of our transitive npm vulnerabilities, and almost none of it was documented anywhere. Here is the map we wish we'd had.
1. v2 is not an upgrade — it's a different scope
The packages you're using (x402-express, x402-fetch, x402) are the v1 line and they stop at 1.2.0. There is no v2 of them. Protocol v2 lives under the @x402 scope:
npm rm x402-express x402-fetch
npm i @x402/express @x402/fetch @x402/evm @x402/core
@coinbase/x402 is a separate CDP-flavoured package — not the core.
Trap: @x402/express has an optional peer dependency on @x402/paywall. Do not install it unless you want the browser paywall UI — it pulls wagmi/walletconnect/solana, which is the exact dependency jungle (and the uuid/ws vulnerabilities) you're escaping by leaving v1.
2. The server side: resource server + scheme registration
v1's one-liner becomes explicit wiring — and you gain discovery metadata per route:
import { paymentMiddleware, x402ResourceServer } from "@x402/express";
import { ExactEvmScheme } from "@x402/evm/exact/server";
import { HTTPFacilitatorClient } from "@x402/core/server";
const facilitator = new HTTPFacilitatorClient({ url: "https://x402.org/facilitator" });
const server = new x402ResourceServer(facilitator)
.register("eip155:84532", new ExactEvmScheme());
app.use(paymentMiddleware({
"POST /v1/offramp/session": {
accepts: { scheme: "exact", price: "$0.05", network: "eip155:84532", payTo: PAY_TO },
description: "Sell agent USDC to fiat in the owner's bank account",
mimeType: "application/json",
serviceName: "FiatDock",
tags: ["offramp", "usdc"],
},
}, server));
Three things to notice:
-
Networks are CAIP-2 now.
base-sepolia→eip155:84532,base→eip155:8453. Keep a friendly-name map if your env files saybase-sepolia. -
The facilitator URL did not change.
https://x402.org/facilitatorserves v2 (/supported,/verify,/settle) behind a 308 redirect. Thefacilitator.x402.orghost you'll see in some READMEs does not resolve. -
The middleware must sync supported kinds at startup (the last
paymentMiddlewarearg). Without it, even issuing a challenge fails withFacilitator does not support exact on eip155:84532. Your offline tests now need network access — plan for it.
3. The 402 challenge moved into a header
v1 put payment requirements in the JSON body. v2 402 responses have an empty body; the challenge is base64 JSON in the PAYMENT-REQUIRED header:
const challenge = JSON.parse(atob(res.headers.get("payment-required")));
// { x402Version: 2, resource: {...}, accepts: [{ scheme, network, amount, asset, payTo, ... }] }
If you surface 402s to agents (we decode them into MCP tool errors), update that path — your users will otherwise see {} and file confused issues.
4. The client side: schemes, not signers
import { wrapFetchWithPaymentFromConfig } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm";
import { privateKeyToAccount } from "viem/accounts";
const payFetch = wrapFetchWithPaymentFromConfig(fetch, {
schemes: [{ network: "eip155:*", client: new ExactEvmScheme(privateKeyToAccount(KEY)) }],
});
The eip155:* wildcard means the client follows whatever EVM network the server's challenge names — our testnet→mainnet switch later requires zero client changes.
5. Free bonus: Bazaar discovery
v2 has a first-class discovery story. Register the extension and your paid routes get catalogued by the facilitator (which is what indexers like x402scan and the x402 Bazaar read):
import { bazaarResourceServerExtension, declareDiscoveryExtension } from "@x402/extensions";
server.registerExtension(bazaarResourceServerExtension);
// per route: extensions: declareDiscoveryExtension({ method: "POST", input: {...}, inputSchema: {...}, bodyType: "json", output: { example: {...} } })
This is also the v2 answer to the old outputSchema.output field scanners used to ask for.
6. Did it work?
We verified the full handshake against the real facilitator with an unfunded throwaway key: challenge → parse → sign → retry with X-PAYMENT → facilitator verify. The only rejection was invalid_exact_evm_insufficient_balance — i.e. the wire format was accepted end to end.
And npm audit: 24 moderate → 0, in both the server and our published MCP package (fiatdock-mcp).
FiatDock is machine-first: llms.txt · OpenAPI · MCP npx fiatdock-mcp · source mirror. If your agent earns USDC and you want it in a bank account, that's literally our whole product.
Top comments (0)