Test x402 Payments Locally with a 20-Line Mock Facilitator

The hardest part of x402 to test is the money. A real payment needs a funded wallet, a network and a live facilitator. But almost everything that can go wrong in your service is not about money: the challenge you return, the retry with a signature, the receipt, the error paths. A mock facilitator lets you test all of that on your laptop in a minute. This is the one we use, about 20 lines of Python, plus the checks that matter.
What a facilitator does, and what the mock fakes
Your server asks the facilitator three things: what it supports, whether a payment signature is valid (/verify) and, after your handler succeeded, to settle the payment on chain (/settle). The mock answers yes to all of them and logs every request.
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
LOG = "mock_facilitator.log"
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def send(self, obj, code=200):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
open(LOG, "a").write(f"GET {self.path}\n")
if self.path.startswith("/supported"):
self.send({
"kinds": [{"x402Version": 2, "scheme": "exact", "network": "eip155:8453"}],
"extensions": ["bazaar"],
"signers": {"eip155:*": ["0x0000000000000000000000000000000000000001"]},
})
else:
self.send({"error": "not found"}, 404)
def do_POST(self):
size = int(self.headers.get("content-length", 0))
body = json.loads(self.rfile.read(size) or b"{}")
open(LOG, "a").write(f"POST {self.path} {json.dumps(body)[:1500]}\n")
auth = (body.get("paymentPayload", {}).get("payload", {}).get("authorization", {})) or {}
payer = auth.get("from", "0x" + "11" * 20)
if self.path.startswith("/verify"):
self.send({"isValid": True, "payer": payer})
elif self.path.startswith("/settle"):
self.send({"success": True, "transaction": "0x" + "ab" * 32, "network": "eip155:8453", "payer": payer})
else:
self.send({"error": "not found"}, 404)
HTTPServer(("127.0.0.1", 9911), Handler).serve_forever()
Save it as mock_facilitator.py and run python3 mock_facilitator.py. Note that /supported must list your network, because the x402 server reads it when it starts up.
Point your Worker at it
Read the facilitator URL from an environment variable in your Worker (env.FACILITATOR_URL, falling back to the real one), then run the Worker locally with the mock:
npx wrangler dev --local --port 8791 --var FACILITATOR_URL:http://127.0.0.1:9911
The server setup itself is in How to Add x402 Payments to a Cloudflare Worker with Hono.
Run a real client against it
The client from How an AI Agent Pays an x402 API works unchanged. Use a throwaway key: with the mock, nothing is checked on chain, so the wallet needs no funds.
import { x402Client, wrapFetchWithPayment, decodePaymentResponseHeader } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
const signer = privateKeyToAccount(generatePrivateKey()); // throwaway wallet, no funds
const pay = wrapFetchWithPayment(fetch, new x402Client().register("eip155:*", new ExactEvmScheme(signer)));
const res = await pay("http://127.0.0.1:8791/v1/echo", {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ hi: 1 }),
});
console.log(res.status, await res.json());
console.log(decodePaymentResponseHeader(res.headers.get("payment-response")));
A passing run prints status 200 and a receipt like { success: true, payer: "0x...", transaction: "0xabab...", network: "eip155:8453" }.
The checks worth automating
- Without payment you get a 402 with a
PAYMENT-REQUIREDheader. Check the scheme, the network and the amount in base units. - With payment you get a 200 and a
PAYMENT-RESPONSEheader. - A rejected request is not charged. Send a paid request that your handler answers with a 400, for example an unsupported language. The response must have no
PAYMENT-RESPONSE, and the mock's log must show a/verifycall but no/settlecall. In our run, the mock's log showed 14 verify calls and 9 settle calls: the five rejected requests were verified and never settled. - Every paid route is covered. Loop over your routes with a valid body, and over the routes that validate input with an invalid body too. Our test calls all eight routes with a valid request and five of them with an invalid one. Writing it also found a real bug: one route answered 200 with an empty list when the model returned an unexpected format, which would have charged the caller for nothing. It now answers 502, which is never settled.
- The client's spend limit works. Point the client at a fake 402 server that asks for more than the cap and check that it refuses. The default cap in
@x402/fetchis $1 per payment, andsetSpendControls({ maxAmountPerPayment: "$0.05" })tightens it.
What the mock cannot tell you
It does not validate signatures and it does not move money. For that, do one more test with the real facilitator and a brand-new wallet that holds no USDC. The real facilitator answers invalid_exact_evm_insufficient_balance. That is a successful result: your challenge, the client's payload and the facilitator's checks all lined up, and only the funds are missing. When you are ready for the real thing, a payment of a tenth of a cent is enough to prove the whole path.
FAQ
Why test x402 with a mock facilitator?
The facilitator verifies the payment signature and settles the transfer on chain, which needs real funds and a network. A mock that answers verify and settle with success lets you test your own code, the 402 challenge, the retry, the receipt and the error paths on your laptop for free.
What does the mock not prove?
It does not check signatures or move money. To check that your challenge and the client's payload are accepted by a real facilitator, call it with a brand-new wallet that has no USDC: it answers invalid_exact_evm_insufficient_balance, which means everything lined up except the funds.
How do I point my server at the mock?
Make the facilitator URL configurable, for example an environment variable, and set it to the mock when you run wrangler dev: wrangler dev --var FACILITATOR_URL:http://127.0.0.1:9911.
How can I check that failed requests are not charged?
Send a paid request that your handler rejects with a 400. The response must not carry a PAYMENT-RESPONSE header, and the mock's log must show a verify call but no settle call.
Found this useful? Tip the studio in crypto
Every EVM chain works. USDC on Base is recommended: fees are a fraction of a cent. No account needed — it goes straight to the creator's wallet.
0x13dd72Fa0E7504790585D92bD98c720f6fD2aBa6More from DevNotes

Create a Cloudflare API Token for Workers with One Pre-Filled Link
Skip the permission hunt: a dashboard link that pre-fills a least-privilege API token for deploying Workers…

How to Add x402 Payments to a Cloudflare Worker with Hono
A tested, minimal example of charging per request in USDC on Base with x402, Hono and Cloudflare Workers…

How an AI Agent Pays an x402 API: a 20-Line Client in JS
A working x402 client in JavaScript: sign the USDC payment, read the receipt, cap what your agent can spend…

Cloudflare Cron Triggers Not Firing? Use a Durable Object Alarm
A reliable clock for Cloudflare Workers: a Durable Object alarm that re-arms itself, with a minimum gap and a…