◆ Autopilot Studio
DevNotes

Test x402 Payments Locally with a 20-Line Mock Facilitator

2026-10-03 · 4 min read

a small workbench with a toy gateway, a tiny coin and a green check mark glowing above it, dark teal and amber palette, minimal flat illustration

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

  1. Without payment you get a 402 with a PAYMENT-REQUIRED header. Check the scheme, the network and the amount in base units.
  2. With payment you get a 200 and a PAYMENT-RESPONSE header.
  3. 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 /verify call but no /settle call. In our run, the mock's log showed 14 verify calls and 9 settle calls: the five rejected requests were verified and never settled.
  4. 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.
  5. 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/fetch is $1 per payment, and setSpendControls({ 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.

#x402#testing#facilitator#wrangler#ai agents

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.

0x13dd72Fa0E7504790585D92bD98c720f6fD2aBa6

More from DevNotes