x402 Facilitator
Self-hosted payment verification and settlement service implementing the facilitator role of the x402 protocol (v2, §7). Accept EIP-3009 USDC payments on any HTTP API without running blockchain infrastructure.
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.
# 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:
curl http://localhost:8788/supported
{
"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
| Variable | Required | Description |
|---|---|---|
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
WALLET_PRIVATE_KEY=0xabc123… NETWORKS=eip155:11155111=https://sepolia.drpc.org,eip155:84532=https://sepolia.base.org
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
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
| Field | Type | Description |
|---|---|---|
x402Version | int | Protocol version. Always 2. |
paymentPayload | object | The client's signed payload (§5.2). Must contain accepted (the terms it signed for) and payload (signature + authorization). |
paymentRequirements | object | The exact terms the resource server expects (§5.1.2). Compared field-by-field against the authorization. |
{
"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
| Field | Type | Description |
|---|---|---|
isValid | boolean | Whether the payload passed every check in the verification pipeline. |
invalidReason | string · null | Machine-readable failure reason when isValid is false. See table below. |
payer | string · null | Address recovered from the authorization (from). |
{
"isValid": true,
"invalidReason": null,
"payer": "0x7a3F8c2D91eB4f5A6c0E3d7F1b9A8b2E4c5D6e7F"
}
Failure reasons
invalidReason is one of the following, in the order the checks run:
| Reason | Meaning |
|---|---|
malformed_authorization | Authorization fields could not be parsed (bad hex, wrong types). |
amount_mismatch | Signed value differs from paymentRequirements.amount. The exact scheme requires equality. |
pay_to_mismatch | Signed recipient differs from paymentRequirements.payTo. |
not_yet_valid | Current time is before validAfter. |
expired | Current time is past validBefore. |
invalid_signature | EIP-3009 signature does not recover to the from address. |
nonce_replay | This nonce was already seen for (payer, chain). Double-spend attempt. |
balance_check_failed | The RPC balanceOf call itself failed (endpoint down, wrong asset). |
insufficient_funds | Payer's USDC balance is below the authorized value. |
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.
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
| Field | Type | Description |
|---|---|---|
success | boolean | Whether the transaction was broadcast. |
transaction | string | Transaction hash. Empty string when success is false. |
errorReason | string · null | wallet_not_configured when no key is set, otherwise the node error (truncated to 200 chars). |
payer | string · null | Address that paid. |
network | string | CAIP-2 network the settlement was broadcast on. |
amount | string · null | Amount settled, in atomic units. |
{
"success": true,
"errorReason": null,
"payer": "0x7a3F8c2D91eB4f5A6c0E3d7F1b9A8b2E4c5D6e7F",
"transaction": "0x9f2c…a41b",
"network": "eip155:11155111",
"amount": "1000000"
}
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.
| Field | Type | Description |
|---|---|---|
kinds | array | One entry per registered pair: x402Version, scheme, network, and an extra object naming the asset (USDC, domain version 2, transfer method eip-3009). |
extensions | array | Registered protocol extensions. Currently empty. |
signers | object | CAIP-2 pattern → facilitator signer addresses. Empty in verify-only mode. |
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.
Liveness probe. Returns { "status": "ok" }. Suitable for container health checks and uptime monitors.
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.
| # | Check | Failure reason |
|---|---|---|
| 1 | Parse the authorization fields (hex value, window, nonce) | malformed_authorization |
| 2 | Signed value equals paymentRequirements.amount exactly | amount_mismatch |
| 3 | Signed recipient equals paymentRequirements.payTo | pay_to_mismatch |
| 4 | Current time inside [validAfter, validBefore] | not_yet_valid · expired |
| 5 | EIP-3009 signature recovers to from (EIP-712 domain: name USDC, version 2, chain ID, verifying contract) | invalid_signature |
| 6 | Nonce unseen for (payer, chain) — recorded on first use | nonce_replay |
| 7 | On-chain balanceOf(payer) covers the authorized value | balance_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
| Scheme | Family | Mechanism | Asset | Status |
|---|---|---|---|---|
exact | eip155 | EIP-3009 transferWithAuthorization | USDC | ● Ready |
upto | eip155 | — | — | ○ Planned |
batch | eip155 | — | — | ○ 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.
| Network | CAIP-2 | USDC contract |
|---|---|---|
| Ethereum | eip155:1 | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 |
| Sepolia | eip155:11155111 | 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 |
| Base | eip155:8453 | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
| Base Sepolia | eip155:84532 | 0x036CbD53842c5426634e7929541eC2318f3dCF7e |
Error reference
Domain errors are returned with a consistent envelope — an error code plus a human-readable message:
{
"error": "scheme_not_found",
"message": "No facilitator for scheme=exact network=eip155:999"
}
| Code | HTTP | Cause |
|---|---|---|
scheme_not_found | 400 | No facilitator registered for the payload's (scheme, network) pair — the network is missing from NETWORKS. |
invalid_payment | 400 | The payload structurally cannot be processed. |
settlement_failed | 500 | Settlement broadcast failed at the node level. |
server_error | 500 | Unclassified facilitator error. |
Malformed JSON bodies fail FastAPI/Pydantic validation first and return the standard 422 validation detail instead of the envelope above.
Deployment
Docker
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
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/.
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
# 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.