Introduction

In an x402 flow, a resource server answers unpaid requests with 402 Payment Required and a set of payment terms. The client signs an EIP-3009 TransferWithAuthorization and retries with the signed payload. The resource server then forwards that payload to a facilitator — this service — which validates it against on-chain state and, on request, settles it by broadcasting transferWithAuthorization to the USDC contract.

The resource server never touches a private key, an RPC endpoint, or any chain-specific logic. The facilitator exposes exactly two payment operations:

Verify read-only

Off-chain validation of the signature, payment terms, validity window, nonce, and on-chain balance. No wallet or gas required — safe to call before serving every paid response.

Settle on-chain

Broadcasts the authorization to the USDC contract, moving funds from payer to recipient. Requires a configured EOA funded with ETH for gas on each active network.

Deployed verify-only, the service holds no keys at all. Deployed with a wallet, the key stays on your machine — settlement is gasless for the payer by design of EIP-3009.

Quickstart

Requires Python 3.12+. The service is configured entirely through environment variables.

shell
# Install
git clone <repo-url> && cd x402-facilitator
python3.12 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

# Configure
cat > .env << 'EOF'
WALLET_PRIVATE_KEY=0xYOUR_PRIVATE_KEY
NETWORKS=eip155:11155111=https://sepolia.drpc.org
EOF

# Run — export env so it survives --reload restarts
set -a && source .env && set +a
uvicorn x402_facilitator.app:create_app --factory --host 127.0.0.1 --port 8788

Confirm the facilitator is up and see which scheme/network pairs it registered:

shell
curl http://localhost:8788/supported
json — 200 OK
{
  "kinds": [
    {
      "x402Version": 2,
      "scheme": "exact",
      "network": "eip155:11155111",
      "extra": {
        "name": "USDC",
        "version": "2",
        "assetTransferMethods": ["eip-3009"]
      }
    }
  ],
  "extensions": [],
  "signers": {}
}

Open http://localhost:8788/ in a browser with MetaMask or Rabby installed for a full end-to-end wallet flow: connect, sign an EIP-3009 authorization, verify, and settle on Sepolia.

Configuration

All configuration is read from the process environment at startup. Two formats are supported for declaring networks.

Environment variables

VariableRequiredDescription
WALLET_PRIVATE_KEY /settle only Hex private key of the EOA that broadcasts settlement transactions. Omit for a verify-only deployment. Also accepted as FACILITATOR_WALLET_PRIVATE_KEY.
NETWORKS yes Comma-separated eip155:<chainId>=<rpc_url> pairs. This is the recommended format.
FACILITATOR_NETWORKS alt. format Comma-separated chain IDs (e.g. 84532,8453). Requires the two variables below per chain.
FACILITATOR_RPC_URL_<chainId> alt. format RPC endpoint for a chain declared in FACILITATOR_NETWORKS.
FACILITATOR_USDC_<chainId> no Override the USDC contract address for a chain. Built-in defaults cover Ethereum, Sepolia, Base, and Base Sepolia.

Example

.env
WALLET_PRIVATE_KEY=0xabc123…
NETWORKS=eip155:11155111=https://sepolia.drpc.org,eip155:84532=https://sepolia.base.org
Note

Export the variables into the process environment (set -a && source .env && set +a) before starting uvicorn. Variables set only in the shell's rc file are lost across --reload restarts, which surfaces as scheme_not_found on verify.

API Reference

POST /verify §7.1

Validate a payment payload without any on-chain state change. This is the endpoint a resource server calls before serving paid content — a valid response means the signature is authentic, the terms match, the window is live, the nonce is fresh, and the payer holds sufficient USDC.

Request body

FieldTypeDescription
x402VersionintProtocol version. Always 2.
paymentPayloadobjectThe client's signed payload (§5.2). Must contain accepted (the terms it signed for) and payload (signature + authorization).
paymentRequirementsobjectThe exact terms the resource server expects (§5.1.2). Compared field-by-field against the authorization.
json — request
{
  "x402Version": 2,
  "paymentPayload": {
    "x402Version": 2,
    "resource": { "url": "https://api.example.com/data" },
    "accepted": {
      "scheme": "exact",
      "network": "eip155:11155111",
      "amount": "1000000",
      "asset": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
      "payTo": "0xMerchant…",
      "maxTimeoutSeconds": 60
    },
    "payload": {
      "signature": "0x5f3a…",
      "authorization": {
        "from": "0xPayer…",
        "to": "0xMerchant…",
        "value": "1000000",
        "validAfter": "1754000000",
        "validBefore": "1754000060",
        "nonce": "0x9c1e…"
      }
    }
  },
  "paymentRequirements": { /* same terms as accepted */ }
}

Response

FieldTypeDescription
isValidbooleanWhether the payload passed every check in the verification pipeline.
invalidReasonstring · nullMachine-readable failure reason when isValid is false. See table below.
payerstring · nullAddress recovered from the authorization (from).
json — 200 OK
{
  "isValid": true,
  "invalidReason": null,
  "payer": "0x7a3F8c2D91eB4f5A6c0E3d7F1b9A8b2E4c5D6e7F"
}

Failure reasons

invalidReason is one of the following, in the order the checks run:

ReasonMeaning
malformed_authorizationAuthorization fields could not be parsed (bad hex, wrong types).
amount_mismatchSigned value differs from paymentRequirements.amount. The exact scheme requires equality.
pay_to_mismatchSigned recipient differs from paymentRequirements.payTo.
not_yet_validCurrent time is before validAfter.
expiredCurrent time is past validBefore.
invalid_signatureEIP-3009 signature does not recover to the from address.
nonce_replayThis nonce was already seen for (payer, chain). Double-spend attempt.
balance_check_failedThe RPC balanceOf call itself failed (endpoint down, wrong asset).
insufficient_fundsPayer's USDC balance is below the authorized value.
POST /settle §7.2

Execute on-chain settlement. The facilitator signs and broadcasts transferWithAuthorization to the USDC contract from its own EOA, paying gas itself; the payer's USDC moves directly to payTo. The request body is identical to /verify.

Best practice

Call /verify first, deliver the paid resource, then call /settle. Settlement is asynchronous from the client's perspective — the response returns the broadcast transaction hash, not a confirmation receipt.

Response

FieldTypeDescription
successbooleanWhether the transaction was broadcast.
transactionstringTransaction hash. Empty string when success is false.
errorReasonstring · nullwallet_not_configured when no key is set, otherwise the node error (truncated to 200 chars).
payerstring · nullAddress that paid.
networkstringCAIP-2 network the settlement was broadcast on.
amountstring · nullAmount settled, in atomic units.
json — 200 OK
{
  "success": true,
  "errorReason": null,
  "payer": "0x7a3F8c2D91eB4f5A6c0E3d7F1b9A8b2E4c5D6e7F",
  "transaction": "0x9f2c…a41b",
  "network": "eip155:11155111",
  "amount": "1000000"
}
GET /supported §7.3

Discovery endpoint. Returns every (scheme, network) pair the facilitator registered at startup, plus protocol extensions and signer addresses. Resource servers should call this at boot to confirm their intended network is active.

FieldTypeDescription
kindsarrayOne entry per registered pair: x402Version, scheme, network, and an extra object naming the asset (USDC, domain version 2, transfer method eip-3009).
extensionsarrayRegistered protocol extensions. Currently empty.
signersobjectCAIP-2 pattern → facilitator signer addresses. Empty in verify-only mode.
GET /list §8

Bazaar discovery. Returns the discoverable x402-enabled resources served through this facilitator. Currently returns an empty set ({ "resources": [] }); the shape is reserved for spec-compliant discovery.

GET /health ops

Liveness probe. Returns { "status": "ok" }. Suitable for container health checks and uptime monitors.

Tip

A bare GET on /verify or /settle returns a human-readable description of the expected request body — useful when integrating by hand.

Verification pipeline

Every /verify call runs the same ordered checks against the authorization and the chain. The first failure short-circuits the pipeline and becomes the invalidReason.

#CheckFailure reason
1Parse the authorization fields (hex value, window, nonce)malformed_authorization
2Signed value equals paymentRequirements.amount exactlyamount_mismatch
3Signed recipient equals paymentRequirements.payTopay_to_mismatch
4Current time inside [validAfter, validBefore]not_yet_valid · expired
5EIP-3009 signature recovers to from (EIP-712 domain: name USDC, version 2, chain ID, verifying contract)invalid_signature
6Nonce unseen for (payer, chain) — recorded on first usenonce_replay
7On-chain balanceOf(payer) covers the authorized valuebalance_check_failed · insufficient_funds

Replay protection uses an in-memory nonce store scoped per (payer, chain). Because EIP-3009 nonces are single-use on-chain as well, a nonce that passes check 6 can never settle twice — the store exists to fail fast and to protect against repeated verify-then-settle races.

Schemes & networks

Supported schemes

SchemeFamilyMechanismAssetStatus
exacteip155EIP-3009 transferWithAuthorizationUSDC● Ready
uptoeip155○ Planned
batcheip155○ Planned

Known USDC deployments

Contract addresses are built in for the chains below; override with FACILITATOR_USDC_<chainId> if needed. A chain is active only when present in NETWORKS.

NetworkCAIP-2USDC contract
Ethereumeip155:10xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
Sepoliaeip155:111551110x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238
Baseeip155:84530x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
Base Sepoliaeip155:845320x036CbD53842c5426634e7929541eC2318f3dCF7e

Error reference

Domain errors are returned with a consistent envelope — an error code plus a human-readable message:

json — 400 Bad Request
{
  "error": "scheme_not_found",
  "message": "No facilitator for scheme=exact network=eip155:999"
}
CodeHTTPCause
scheme_not_found400No facilitator registered for the payload's (scheme, network) pair — the network is missing from NETWORKS.
invalid_payment400The payload structurally cannot be processed.
settlement_failed500Settlement broadcast failed at the node level.
server_error500Unclassified facilitator error.

Malformed JSON bodies fail FastAPI/Pydantic validation first and return the standard 422 validation detail instead of the envelope above.

Deployment

Docker

shell
docker compose up --build

The compose stack bundles an Anvil fork of Base Sepolia for local end-to-end testing alongside the facilitator service.

Public access via Tailscale Funnel

shell
tailscale serve reset
tailscale funnel --bg --set-path / http://127.0.0.1:8788

The service is then reachable at https://<your-node>.<tailnet>.ts.net/.

Security

WALLET_PRIVATE_KEY never leaves the machine — Funnel tunnels to localhost only. The settlement wallet needs ETH for gas on each active chain; it does not need USDC, since funds move via the payer's EIP-3009 authorization.

Testing

shell
# Unit & integration — 29 tests
pytest tests/ -v

# End-to-end against a local Anvil fork (2 tests)
docker compose up anvil -d
pytest tests/test_e2e.py -v

E2E tests are skipped automatically when no Anvil instance is reachable, so the suite stays green in minimal CI environments.