A paid endpoint that never sells anything usually gets blamed on traffic. Sometimes the rail is
broken in a way that looks perfectly healthy from the outside: the 402 is well-formed, the
crawlers index it, /supported lists your network, and every payment attempt dies inside the
facilitator before it reaches the chain.
I hit this on my own service last night and then tested the public facilitators from the same
machine, with the same wallet and the same signed payload. The results are the point of this
post.
The failure mode
x402 has a two-step handshake with a facilitator:
-
GET /supported— which schemes and networks will you settle for? -
POST /verify— here is a signed authorization, is it payable?
Most integrations only ever run step 1. That is a mistake, because step 1 is a claim and step 2
is the check, and a facilitator can pass the first and fail the second for every valid
payment:
GET https://facilitator.example/supported -> 200, lists eip155:8453
POST https://facilitator.example/verify -> 500 unexpected_error
Nothing in your own logs looks wrong. You emit 402, the client signs, the client posts, the
facilitator throws, your middleware turns that into another 402 or a 500, and the buyer leaves.
The service looks up, the revenue is zero, and there is no error you can grep for because the
error belongs to someone else.
Tested 2026-10-18, Base mainnet, verify-only
Same EIP-3009 payload shape (TransferWithAuthorization, USDC 0x833589…2913, chain 8453),
signed fresh per facilitator, verify-only so no funds moved:
-
x402.roundhouseai.io— advertises2/eip155:8453,/verify200,isValid: true, 319 ms -
facilitator.xpay.sh— advertises2/eip155:8453,/verify500unexpected_error -
x402.org/facilitator— advertises only2/eip155:84532for EVM; a Base mainnet payload gets 500 as well, but that one is not a bug, it is a testnet-only facilitator that people assume is mainnet because of the domain -
facilitator.payai.network,x402-facilitator.quicknode.com,facilitator.b402.ai— no usable/supportedresponse from this host
The second and third rows are the ones that cost money. The first is a facilitator that lies;
the third is a facilitator that never claimed anything and gets used anyway.
Check it yourself, in thirty lines
You do not need my matrix. Sign one authorization and ask each facilitator whether it is valid.
If the same payload is true on one facilitator and 500 on another, the payload is not your
problem.
import json, secrets, time, urllib.request
from eth_account import Account
from eth_account.messages import encode_typed_data
USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
PAY_TO = "0xYourPayToAddress"
acct = Account.from_key("0xYourKey") # a wallet with no money is fine for verify
now = int(time.time())
auth = {"from": acct.address, "to": PAY_TO, "value": 10000,
"validAfter": now - 60, "validBefore": now + 300,
"nonce": "0x" + secrets.token_hex(32)}
typed = {"types": {
"EIP712Domain": [{"name": "name", "type": "string"}, {"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"}],
"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",
"domain": {"name": "USD Coin", "version": "2", "chainId": 8453, "verifyingContract": USDC},
"message": auth}
sig = acct.sign_message(encode_typed_data(full_message=typed)).signature.hex()
sig = sig if sig.startswith("0x") else "0x" + sig
payload = {"x402Version": 2, "scheme": "exact", "network": "eip155:8453",
"payload": {"signature": sig, "authorization": {
"from": auth["from"], "to": auth["to"], "value": str(auth["value"]),
"validAfter": str(auth["validAfter"]), "validBefore": str(auth["validBefore"]),
"nonce": auth["nonce"]}}}
reqs = {"scheme": "exact", "network": "eip155:8453", "maxAmountRequired": "10000",
"resource": "https://your.service/paid", "description": "probe",
"mimeType": "application/json", "payTo": PAY_TO, "maxTimeoutSeconds": 300,
"asset": USDC, "extra": {"name": "USD Coin", "version": "2"}}
body = json.dumps({"x402Version": 2, "paymentPayload": payload,
"paymentRequirements": reqs}).encode()
r = urllib.request.Request("https://facilitator.example/verify", data=body,
headers={"content-type": "application/json"})
print(urllib.request.urlopen(r, timeout=20).read().decode())
Run it against two facilitators before you pick one. A 200 with isValid: true is the only
answer that means anything.
The other half: prove settlement on-chain, not in a header
X-PAYMENT-RESPONSE: {"status":"settled"} is the facilitator's word. The balance is the chain's.
Diff the payer and the payee around a paid call:
before: balanceOf(payer) - balanceOf(payTo)
call: GET /paid with X-PAYMENT
after: balanceOf(payer) - balanceOf(payTo) # must move by exactly the price
I keep a small watcher that does this for every wallet I care about and stays silent unless a
balance moves. It has caught more truth in one line of output than any dashboard: when the
facilitator said "settled" and nothing moved, it said nothing at all.
Two traps in the 402 body itself
While a reviewer was replaying my proofs (fair play, and worth doing), they found two defects in
the response body that no facilitator would ever tell me about:
1. Legacy top-level fields describing a different rail. The body carried
maxAmountRequired, payTo and network from the Stacks configuration while accepts[0] was
Base USDC. A v1 client that reads the top level — which is exactly what a lot of clients do —
would try to pay STX for a Base-priced call. Fix: make the top level mirror accepts[0], and
keep the old shape in a clearly named legacyStacks object so nothing breaks.
2. A unit lie. One rail reported amount_usdc: "0.001000", computed by dividing micro-STX
by a million and labelling the result dollars. The other rail was 0.01 USDC. Same call, a 10x
price gap that existed only in the label. Fix: report the amount in the asset it is actually
denominated in (amount_in_asset: "0.001000 STX").
Both are the kind of bug you cannot see from your own side, because your own client reads the
field you wrote and agrees with you.
What I would check before trusting any paid endpoint
-
/supportedis a claim. Send a real signed payload to/verifyand readisValid. - Send the same payload to a second facilitator. Disagreement is information.
- Settle once and prove it by balance delta, not by response header.
- Read your own 402 body as a stranger: which rail does the top level describe, and does every amount say what asset it is in.
- Keep the probe running on a schedule. Facilitators change under you.
The matrix from this post is re-tested daily and free to read:
x402.ggez.my.id/facilitator-health. If your rail
answers 402 and never settles, that is the exact thing I fix.
Top comments (0)