Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

Your x402 paywall answers 402 forever, and your facilitator is why

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 lis

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:

  1. GET /supported — which schemes and networks will you settle for?
  2. 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 — advertises 2/eip155:8453, /verify 200, isValid: true, 319 ms
  • facilitator.xpay.sh — advertises 2/eip155:8453, /verify 500 unexpected_error
  • x402.org/facilitator — advertises only 2/eip155:84532 for 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 /supported response 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

  1. /supported is a claim. Send a real signed payload to /verify and read isValid.
  2. Send the same payload to a second facilitator. Disagreement is information.
  3. Settle once and prove it by balance delta, not by response header.
  4. 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.
  5. 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.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.