Building and testing an x402 integration, end to end
x402 turns a status code that HTTP reserved and never used into a payment handshake. RFC 9110 still says, in section 15.5.3, that "The 402 (Payment Required) status code is reserved for future use." The x402 documentation describes what it now carries: it "is built around the HTTP 402 Payment Required status code and allows clients to programmatically pay for resources without accounts, sessions, or credential management."
What follows is the working version: real package names, real bodies, real error strings, and a way to run the exchange without spending anything.
The integration in five steps
- Put an x402 server SDK in front of the route you want to charge for and give it a facilitator URL. Your server never holds a private key and never makes a chain call.
- Advertise terms: a scheme, a network, a price and the address you want paid.
- An unpaid request gets
402with those terms in the JSON body and, base64 encoded, in thePAYMENT-REQUIREDresponse header. The two are byte-identical once decoded. - The client signs and retries with a
PAYMENT-SIGNATURErequest header. Your middleware callsPOST /verify(free, moves nothing) and thenPOST /settle. - Serve the resource after
success: true, not after verify. Verification proves the payment *can* settle. Settlement proves it did.
Testing is the part most guides skip. Steps 1 to 5 can be exercised against a live merchant that refunds every payment in full, in the same request, so a client runs end to end for nothing.
Server side: charging for a route
The facilitator is a URL you pass to the SDK. Everything else is ordinary middleware. Node, Express, using the official packages @x402/core, @x402/evm and @x402/express:
import express from "express";
import { paymentMiddleware, x402ResourceServer } from "@x402/express";
import { ExactEvmScheme } from "@x402/evm/exact/server";
import { HTTPFacilitatorClient } from "@x402/core/server";
const server = new x402ResourceServer(
new HTTPFacilitatorClient({ url: "https://pay.neuronto.com" }),
).register("eip155:8453", new ExactEvmScheme());
const app = express();
app.use(paymentMiddleware({
"GET /premium": {
accepts: { scheme: "exact", price: "$0.001", network: "eip155:8453", payTo: "0xYourWallet" },
description: "One premium answer",
mimeType: "application/json",
},
}, server));
app.get("/premium", (req, res) => res.json({ ok: true }));
app.listen(3000);Python and FastAPI, with the x402 package:
from fastapi import FastAPI, Request
from x402 import x402ResourceServer
from x402.http import FacilitatorConfig, HTTPFacilitatorClient
from x402.http.middleware.fastapi import payment_middleware
from x402.mechanisms.evm.exact import register_exact_evm_server
server = x402ResourceServer(HTTPFacilitatorClient(FacilitatorConfig(url="https://pay.neuronto.com")))
register_exact_evm_server(server, "eip155:8453")
middleware = payment_middleware({
"GET /premium": {
"accepts": {"scheme": "exact", "payTo": "0xYourWallet", "price": "$0.001",
"network": "eip155:8453"},
"description": "One premium answer",
"mimeType": "application/json",
}
}, server)
app = FastAPI()
@app.middleware("http")
async def x402(request: Request, call_next):
return await middleware(request, call_next)
@app.get("/premium")
def premium():
return {"ok": True}Do not hard-code the network. GET /supported lists every (x402Version, scheme, network) the facilitator settles right now, plus the asset address and the EIP-712 domain a payer signs over:
{"kinds":[{"x402Version":2,"scheme":"exact","network":"eip155:8453"},
{"x402Version":1,"scheme":"exact","network":"base"}],
"extensions":["bazaar"],
"networks":{"eip155:8453":{"label":"Base","v1":"base","testnet":false,
"usdc":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","name":"USD Coin",
"version":"2","decimals":6,"available":true}}}Read available at startup. A network is withdrawn from /supported before it starts refusing, so a server that consults it never advertises a price it cannot take. The name and version fields matter more than they look: Base USDC signs as USD Coin version 2, Base Sepolia USDC as USDC version 2. Get that domain wrong and every signature fails verification for a reason that reads like a client bug.
The 402 your route will emit
Here is a real one, from a live route:
{"x402Version":2,
"error":"PAYMENT-SIGNATURE header is required",
"resource":{"url":"https://pay.neuronto.com/echo","mimeType":"application/json",
"serviceName":"Neuronto Payments"},
"accepts":[{"scheme":"exact","network":"eip155:8453","amount":"1000",
"asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo":"0xb648c75bC8303062Bd4A6311808827E08a52c61D",
"maxTimeoutSeconds":60,
"extra":{"name":"USD Coin","version":"2"}}],
"extensions":{"bazaar":{"info":{"input":{"type":"http","method":"GET"},
"output":{"type":"application/json"}}}}}amount is atomic, and USDC carries six decimals, so "1000" is a tenth of a cent. The same JSON arrives base64 encoded in the PAYMENT-REQUIRED header, and the response sets access-control-expose-headers: PAYMENT-REQUIRED, PAYMENT-RESPONSE, X-PAYMENT-RESPONSE so a browser client can read them.
Client side: paying for a route
A paying client never talks to the facilitator. It talks only to the server that answered 402. In Node, @x402/fetch wraps fetch and performs the whole exchange:
import { wrapFetchWithPaymentFromConfig, decodePaymentResponseHeader } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm";
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount(process.env.PAYER_KEY); // never hard-code a key
const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, {
schemes: [{ network: "eip155:8453", client: new ExactEvmScheme(account) }],
});
const r = await fetchWithPayment("https://pay.neuronto.com/echo", { method: "GET" });
console.log("status:", r.status);
const receipt = r.headers.get("payment-response");
if (receipt) console.log("settled:", JSON.stringify(decodePaymentResponseHeader(receipt)));Python, with x402, eth-account and httpx:
import asyncio, base64, json, os
from eth_account import Account
from x402 import x402Client
from x402.http.clients.httpx import x402HttpxClient
from x402.mechanisms.evm.exact import register_exact_evm_client
from x402.mechanisms.evm.signers import EthAccountSigner
account = Account.from_key(os.environ["PAYER_KEY"])
async def main():
client = x402Client()
register_exact_evm_client(client, EthAccountSigner(account), networks="eip155:8453")
async with x402HttpxClient(client, timeout=90) as http:
r = await http.get("https://pay.neuronto.com/echo")
print("status:", r.status_code)
receipt = r.headers.get("payment-response") or r.headers.get("x-payment-response")
if receipt:
print("settled:", json.dumps(json.loads(base64.b64decode(receipt))))
asyncio.run(main())Set the client timeout generously. Ninety seconds is not paranoia: a settlement is a chain write.
Testing a 402 flow without spending real money
Four options, cheapest first.
1. Look at the handshake with curl. The unpaid half costs nothing and needs no wallet at all:
curl -i https://pay.neuronto.com/echoYou get a real 402, real terms, and the PAYMENT-REQUIRED header to decode. That alone will catch a reverse proxy that strips headers, which is the most common reason an otherwise correct integration never gets past step three.
2. Pay a merchant that refunds. /echo is an ordinary x402 resource that happens to send the money straight back in the same request, and the gas is not yours. GET /echo/status publishes exactly what it will do:
{"enabled":true,"network":"eip155:8453","priceAtomic":"1000","priceUsd":0.001,
"refundPolicy":"in full, in the same request; a failed refund is retried until it lands",
"caps":{"perPayerPerDay":5,"perDay":200},"available":true}Five payments per address per day, two hundred per day overall. Point either client above at that URL and you have driven signing, verification, settlement and receipt decoding against a live chain, for a net cost of zero.
3. Base Sepolia, when it is accepting. /supported is the authority on which networks are testnets. It marks eip155:84532 (Base Sepolia) with "testnet": true and eip155:8453 (Base) with "testnet": false, and /pricing lists the Sepolia rate at "credits": "0" with "basis": "testnet", because testnet settlements are free. The same object carries an available flag per network, and /status.json carries accepting. Read one of them before you point a test suite at Sepolia: at the time of writing Base mainnet is the accepting network, and Sepolia is listed but not available. USDC on a testnet comes from a faucet, so nothing there costs anything either.
4. Fork mainnet locally. For a suite that must run offline, fork Base at a recent block: real USDC contract, real EIP-712 domain, funded accounts, and nonces that actually get consumed. It is the only honest way to test replay, because a mock facilitator cannot spend a nonce, so a mock lets a replayed payment through and the test passes for the wrong reason.
Error handling and retries
POST /verify and POST /settle keep the x402 body shape on every status code, because SDK clients parse the body regardless. Verify answers {"isValid": true, "payer": "0x..."} or:
{"isValid":false,"invalidReason":"invalid_payload",
"invalidMessage":"paymentPayload and paymentRequirements must both be JSON objects"}Settle answers {"success": true, "transaction": "0x...", "network": "eip155:8453", "payer": "0x...", "amount": "10000"}, or the same shape with success: false, errorReason, errorMessage, and transaction set to an empty string.
Two field names to note, because they differ between the endpoints: verify reports invalidReason and invalidMessage, settle reports errorReason and errorMessage. Code that reads errorReason off a verify response gets undefined and reports "no error" on a refused payment.
settlement_pending is unresolved, not failed
This is the single most consequential thing to get right. A settlement answers within roughly 25 seconds or returns errorReason: "settlement_pending", with the transaction hash if it was already broadcast. The transfer continues in the background. It has not failed. Treating it as a failure and re-signing produces a second payment for the same resource.
The correct handling is to re-submit the identical body. Settlement is idempotent: the canonical JSON of the request is the settlement's identity, an identical body settles once, and a re-submission returns the recorded outcome with an Idempotency-Replayed: true header. A pending outcome is deliberately not cached, so re-posting the same body polls the real state; a final outcome is cached, so re-posting after success is free and safe.
async def settle_with_poll(http, body, attempts=6, gap=10):
for _ in range(attempts):
r = await http.post("https://pay.neuronto.com/settle", json=body) # identical body
out = r.json()
if out.get("success") or out.get("errorReason") != "settlement_pending":
return out
await asyncio.sleep(gap)
return outThe optional Idempotency-Key header binds to the first body it arrives with; the same key with a different body is refused with 422. That pattern is not local invention. The IETF agentic payments draft describes the same model: it "uses an idempotency key to associate retries with one task, a single-use nonce to reject credential replay, and a receipt linking the task to operator-controlled authority and settlement records."
Reasons a payer can act on begin with invalid_: a wrong signature, a used nonce, an expired window, an insufficient balance. Reasons that belong to the facilitator, such as transaction_failed and service_unavailable, never charge anyone. One more, sanctioned_address, is refused before any chain call, so nothing is charged and no transaction is created; the list screened against and its age are published on /status, currently 124 addresses from the public OFAC SDN crypto export.
Everything outside /verify and /settle, including unknown paths, answers RFC 9457 problem details rather than the x402 shape:
{"type":"https://pay.neuronto.com/developers#not-found","title":"Not Found","status":404,
"detail":"No resource is served at GET /no-such-path. There is no /v1/ prefix; the endpoints are /verify, /settle, /supported, /discovery/resources."}Going to mainnet: a checklist
- Read
/supportedat startup and advertise only what it lists asavailable. - Take the EIP-712
nameandversionfrom the advertisedextra, never from a constant in your code. - Serve the resource after
success: truefrom settle, never after verify. - Poll
settlement_pendingby re-posting the identical body. Never re-sign. - Keep client timeouts above the settlement window. Ninety seconds is a reasonable floor.
- Size requests under 65536 bytes; larger bodies are refused.
- Respect the rate buckets:
settle50 per second (burst 100),payments-readcovering/verify,/supportedand/healthat 30 per second (burst 60), everything else 10 per second (burst 20). A refusal is a429problem withRetry-After, and every response names its bucket inRateLimit-Policy. - Log the
transactionhash from every settle response. It is the only thing that survives an argument. - Keep the payer key in the environment, not in a file.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
402 on every retry, server never sees the payment | a proxy or CDN strips unknown request headers | allow PAYMENT-SIGNATURE through inbound, and expose PAYMENT-RESPONSE outbound |
invalid_payload, "the body is not valid JSON" | a form body, or a string posted where an object belongs | post JSON |
invalid_payload, "paymentPayload and paymentRequirements must both be JSON objects" | the base64 PAYMENT-SIGNATURE value was forwarded as-is | decode it first, then send the object |
| Every signature fails verification | wrong EIP-712 domain for the network | use extra.name and extra.version from the terms |
404 with a #not-found type | a /v1/ prefix in the path | there is no version prefix; the version is the x402Version body field |
422 unprocessable content | an Idempotency-Key reused with a different body | one key per body |
429 with Retry-After | a rate bucket exceeded | read RateLimit-Policy on the response to see which |
settlement_pending | broadcast, not yet confirmed | re-post the identical body; it is not a failure |
| Verify passed, settle failed | the wallet was emptied between the two | expected: the transfer is simulated again immediately before broadcast, so it fails cleanly and costs no gas |
405 on GET /mcp | wrong verb, not a missing endpoint | the MCP endpoint is POST; GET and DELETE answer 405 so a probe can tell the difference |
| Double charge for one resource | a pending settlement was retried with a fresh signature | poll with the identical body instead |
FAQ
Can I test a 402 payment flow without real funds? Yes, in three ways: read the unpaid 402 with curl, pay a refunding merchant such as /echo, or run against a testnet network that /supported marks "testnet": true. A local mainnet fork covers what the others cannot, namely nonce and replay behaviour.
Which networks are testnets? /supported says so per network. Base Sepolia (eip155:84532) carries "testnet": true; Base (eip155:8453) carries "testnet": false. Check available in the same object before pointing a suite at either.
Is settlement idempotent? Yes. The canonical JSON of the body is the settlement identity. An identical body settles once; re-submission returns the recorded outcome with Idempotency-Replayed: true.
What does verification cost? Nothing, and it moves nothing. /pricing publishes "verificationUsd": "0".
Do I need a wallet on my server? No. A server advertises payTo and receives USDC transfers there. It holds no key and makes no chain call.
Where is a working x402 example I can run? The official SDKs and protocol source are at github.com/coinbase/x402. Runnable starters for Express, Hono, Next.js and FastAPI, plus paying clients in Node and Python, are at github.com/neuronto/x402-starters under Apache-2.0.
Why use a facilitator at all? The alternative is your API server holding a key and talking to a chain. The x402 site puts the flow plainly: "If a request arrives without payment, the server responds with HTTP 402, prompting the client to pay and retry." The facilitator is the part that checks the signature and broadcasts the transfer.
Sources
- RFC 9110, HTTP Semantics, section 15.5.3: https://www.rfc-editor.org/rfc/rfc9110.html
- RFC 9457, Problem Details for HTTP APIs: https://www.rfc-editor.org/rfc/rfc9457
- x402 documentation: https://docs.x402.org/
- x402 protocol site: https://www.x402.org/
- Coinbase, "Introducing x402": https://www.coinbase.com/en-gb/developer-platform/discover/launches/x402
- IETF draft, agentic payments: https://datatracker.ietf.org/doc/draft-king-yew-choo-agentic-payments/
- x402 SDKs and protocol source: https://github.com/coinbase/x402
- Runnable starters: https://github.com/neuronto/x402-starters
- Developer reference: https://pay.neuronto.com/developers
- Live capability list: https://pay.neuronto.com/supported
- Settlement pricing: https://pay.neuronto.com/pricing
- Availability and screening: https://pay.neuronto.com/status.json
- Refunding test merchant: https://pay.neuronto.com/echo/status