This article was generated by an AI system; the executed outputs shown were independently re-run and verified before publication.
x402 puts payment inside the HTTP exchange itself: the server answers 402 Payment Required with the terms, the client signs a payment, and retries. This tutorial builds both sides - a paid endpoint and a client that signs and retries - using the official packages (github.com/coinbase/x402) and a public test facilitator. No account, no API key, no billing page.
What you need
- Node.js 20 or later.
- Five npm packages: express, @x402/express, @x402/core, @x402/evm and viem (tested here with @x402/* 2.28.0).
- A facilitator URL. For testing, https://x402.org/facilitator - the official seller quickstart documents it for Base Sepolia and Solana devnet; this article used it against Base Sepolia.
Everything runs locally on a test network. The last step - an actual settled payment - needs testnet USDC from a faucet; everything up to it works with a random throwaway key.
Part 1: a paid endpoint
Save as server.mjs:
import express from "express"; import { paymentMiddleware, x402ResourceServer } from "@x402/express"; import { ExactEvmScheme } from "@x402/evm/exact/server"; import { HTTPFacilitatorClient } from "@x402/core/server"; // Any address you control; on testnet this random one is fine for a demo. const payTo = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; const app = express(); app.use( paymentMiddleware( { "GET /weather": { accepts: { scheme: "exact", price: "$0.001", network: "eip155:84532", payTo }, description: "Weather data", mimeType: "application/json", }, }, new x402ResourceServer(new HTTPFacilitatorClient({ url: "https://x402.org/facilitator" })) .register("eip155:84532", new ExactEvmScheme()), ), ); app.get("/weather", (req, res) => res.json({ weather: "sunny", temperature: 70 })); app.listen(4021, () => console.log("listening on http://localhost:4021")); Run it and ask for the weather:
$ curl -i http://localhost:4021/weather HTTP/1.1 402 Payment Required PAYMENT-REQUIRED: eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJQYXltZW50IHJlcXVpcmVkIi... Cache-Control: no-store {} Two things to notice. First, in x402 v2 the payment terms travel in the PAYMENT-REQUIRED header, not the body - the v2 HTTP transport spec states that response bodies are a server implementation concern and all protocol information moves in headers. Second, the decoded header tells the client everything it needs:
{ "x402Version": 2, "resource": { "url": "http://localhost:4021/weather", "description": "Weather data", "mimeType": "application/json" }, "accepts": [{ "scheme": "exact", "network": "eip155:84532", "amount": "1000", "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "payTo": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "maxTimeoutSeconds": 300, "extra": { "name": "USDC", "version": "2" } }] } amount "1000" is $0.001 in USDC's 6 decimals; the asset is the USDC contract on Base Sepolia; the client will use maxTimeoutSeconds below to bound how long its signed authorization stays valid.
Part 2: the client
Save as client.mjs. It reads the 402, signs an EIP-3009 transferWithAuthorization (a gasless USDC transfer authorization) for exactly the requested amount, and retries with the signature in the PAYMENT-SIGNATURE header:
import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; import { randomBytes } from "node:crypto"; const TARGET = "http://localhost:4021/weather"; // Random throwaway test key: signs fine, holds no testnet USDC. const account = privateKeyToAccount(generatePrivateKey()); console.log("test payer:", account.address); // 1. First request: expect 402 + PAYMENT-REQUIRED header. const r1 = await fetch(TARGET); console.log("step1 status:", r1.status); const pr = JSON.parse(Buffer.from(r1.headers.get("PAYMENT-REQUIRED"), "base64").toString()); const req = pr.accepts[0]; console.log("server asks:", req.amount, "of", req.extra.name, "on", req.network, "to", req.payTo); // 2. Sign an EIP-3009 transferWithAuthorization for exactly that amount. const chainId = Number(req.network.split(":")[1]); const auth = { from: account.address, to: req.payTo, value: BigInt(req.amount), validAfter: 0n, validBefore: BigInt(Math.floor(Date.now() / 1000) + req.maxTimeoutSeconds), nonce: "0x" + randomBytes(32).toString("hex"), }; const signature = await account.signTypedData({ domain: { name: req.extra.name, version: req.extra.version, chainId, verifyingContract: req.asset }, types: { TransferWithAuthorization: [ { name: "from", type: "address" }, { name: "to", type: "address" }, { name: "value", type: "uint256" }, { name: "validAfter", type: "uint256" }, { name: "validBefore", type: "uint256" }, { name: "nonce", type: "bytes32" } ] }, primaryType: "TransferWithAuthorization", message: auth, }); // 3. Retry with the signed PaymentPayload in the PAYMENT-SIGNATURE header. const payload = { x402Version: 2, resource: pr.resource, accepted: req, payload: { signature, authorization: Object.fromEntries(Object.entries(auth).map(([k, v]) => [k, typeof v === "bigint" ? v.toString() : v])) }, }; const r2 = await fetch(TARGET, { headers: { "PAYMENT-SIGNATURE": Buffer.from(JSON.stringify(payload)).toString("base64") } }); console.log("step2 status:", r2.status); const settle = r2.headers.get("PAYMENT-RESPONSE"); if (settle) console.log("PAYMENT-RESPONSE decoded:", Buffer.from(settle, "base64").toString()); const again = r2.headers.get("PAYMENT-REQUIRED"); if (again) console.log("PAYMENT-REQUIRED decoded:", Buffer.from(again, "base64").toString()); Part 3: what actually happens
Real output from the run behind this article:
test payer: 0xe92979F8937686205B2f1f7053EBD3121c17C07d step1 status: 402 server asks: 1000 of USDC on eip155:84532 to 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 step2 status: 402 PAYMENT-REQUIRED decoded: {"x402Version":2,"error":"invalid_exact_evm_insufficient_balance", ...} Read the second 402 carefully: it is success, not failure. The client built a well-formed payment, the server accepted it at the wire level and passed it to the facilitator, and the facilitator rejected it for exactly one reason: the throwaway wallet holds no testnet USDC. The protocol work is done; only the money is missing. A malformed signature or wrong shape would have failed differently, before the balance check.
Part 4: settling for real
Fund the test address with Base Sepolia USDC and rerun the client. Two documented faucet sources: the CDP faucet (portal.cdp.coinbase.com/products/faucet), which the official buyer quickstart (docs.cdp.coinbase.com/x402/buyer/quickstart) describes as funding test wallets for exactly this flow, and Circle's faucet.circle.com, which lists testnet USDC. Faucet availability at reading time is not guaranteed. Per the v2 spec, the server then settles the payment and answers 200 with the weather JSON and a PAYMENT-RESPONSE header carrying the settlement details, including the transaction hash. Honest boundary: this article executed everything up to the funded step; the settled-payment behavior is from the specification, not from a run shown here.
One safety rule before you fund anything: use a throwaway test key for this tutorial, never a key that holds real funds. The client above generates a fresh one on every run for exactly that reason.
Where to go next
- The official repo: https://github.com/coinbase/x402 - server and client examples in TypeScript, Python, Go and Java. The code above is the author's minimal version, executed for this article; the repo examples themselves were not run here.
- The v2 HTTP transport spec, the canonical reference for the three headers: https://github.com/coinbase/x402/blob/main/specs/transports-v2/http.md
- The seller quickstart that documents the test facilitator: https://docs.cdp.coinbase.com/x402/seller/quickstart
Feedback welcome: should part 2 go deeper on the server side, or on how agents discover paid endpoints in the first place?
