Documentation
Everything needed to accept or make x402 payments on Whitechain through the facilitator: integration for merchants and agents, the HTTP API, operating limits and how to run your own.
Overview
The Whitechain x402 Facilitator is a standard x402 v2 facilitator. It implements the three endpoints the official @x402/core HTTPFacilitatorClient calls (GET /supported, POST /verify, POST /settle) and registers the official @x402/evm exact scheme for Whitechain. Nothing in the protocol is Whitechain-specific; what the project adds is a running, funded, policy-guarded facilitator for a chain the public facilitators do not cover, plus the operational layer (rate limits, gas budgets, API keys, sanctions screening, health, metrics).
Public endpoint: https://x402-facilitator-production-ff5f.up.railway.app. Networks: Whitechain Sepolia eip155:1874 today; mainnet as a configuration entry. Payment flow: payer signs an EIP-3009 transferWithAuthorization for the exact amount; the facilitator verifies it, your API serves the response, the facilitator broadcasts the transfer (payer to your payTo) and pays the WBT gas.
In the snippets, 0x2E672dFE33EA977FD064E01aDe7d8c73B3Be7fBB is the Inferit Test Credit (ITC) contract on Whitechain Sepolia, and 0xYourMerchantAddress is the wallet that receives payments. An agent gets 1,000 test ITC with no gas from POST https://api-production-c74b9.up.railway.app/v1/faucet with {"address":"0x…"} (once per address per 24 h).
Accept x402 payments on Whitechain
Any x402 resource server works. The three things that differ from a Base or Solana setup: the facilitator URL, the network id eip155:1874, and an explicit token price, because the x402 SDK has no "$" default asset for Whitechain.
Express
npm install @x402/express @x402/core @x402/evm expressimport express from "express";
import { paymentMiddleware, x402ResourceServer } from "@x402/express";
import { HTTPFacilitatorClient } from "@x402/core/server";
import { ExactEvmScheme } from "@x402/evm/exact/server";
const facilitator = new HTTPFacilitatorClient({ url: "https://x402-facilitator-production-ff5f.up.railway.app" });
const server = new x402ResourceServer(facilitator).register("eip155:1874", new ExactEvmScheme());
const app = express();
app.use(paymentMiddleware({
"GET /weather": {
accepts: {
scheme: "exact",
network: "eip155:1874",
payTo: "0xYourMerchantAddress",
price: {
asset: "0x2E672dFE33EA977FD064E01aDe7d8c73B3Be7fBB",
amount: "10000", // 0.01 ITC, 6 decimals
extra: { name: "Inferit Test Credit", version: "1" }, // the token's EIP-712 domain
},
maxTimeoutSeconds: 120,
},
description: "Current weather, paid per request on Whitechain",
mimeType: "application/json",
},
}, server));
app.get("/weather", (_req, res) => res.json({ city: "Lisbon", temperatureC: 24 }));
app.listen(4021);
Hono
npm install @x402/hono @x402/core @x402/evm hono @hono/node-serverimport { Hono } from "hono";
import { serve } from "@hono/node-server";
import { paymentMiddleware, x402ResourceServer } from "@x402/hono";
import { HTTPFacilitatorClient } from "@x402/core/server";
import { ExactEvmScheme } from "@x402/evm/exact/server";
const facilitator = new HTTPFacilitatorClient({ url: "https://x402-facilitator-production-ff5f.up.railway.app" });
const server = new x402ResourceServer(facilitator).register("eip155:1874", new ExactEvmScheme());
const app = new Hono();
app.use(paymentMiddleware({
"POST /summarize": {
accepts: {
scheme: "exact",
network: "eip155:1874",
payTo: "0xYourMerchantAddress",
price: { asset: "0x2E672dFE33EA977FD064E01aDe7d8c73B3Be7fBB", amount: "2500", extra: { name: "Inferit Test Credit", version: "1" } },
},
},
}, server));
app.post("/summarize", async (c) => c.json({ summary: "..." }));
serve({ fetch: app.fetch, port: 4022 });
Next.js
@x402/next takes the same routes object and x402ResourceServer; wrap your route handlers or use the middleware export, exactly as in the package README, with the facilitator and network from the Express example.
Price in dollars instead of atomic units
If you prefer price: "$0.01", register a money parser on the scheme so the SDK knows which Whitechain token a dollar means:
import { convertToTokenAmount } from "@x402/core/utils";
const scheme = new ExactEvmScheme().registerMoneyParser(async (amount, network) =>
network === "eip155:1874"
? { asset: "0x2E672dFE33EA977FD064E01aDe7d8c73B3Be7fBB", amount: convertToTokenAmount(amount, 6), extra: { name: "Inferit Test Credit", version: "1" } }
: null,
);
const server = new x402ResourceServer(facilitator).register("eip155:1874", scheme);
Merchant API key (optional)
The public facilitator works without a key. A key raises your rate limits and gives your payTo its own gas budget. Send it on every call with createAuthHeaders, which must return headers keyed by path:
const facilitator = new HTTPFacilitatorClient({
url: "https://x402-facilitator-production-ff5f.up.railway.app",
createAuthHeaders: async () => {
const headers = { "X-API-Key": process.env.FACILITATOR_API_KEY! };
return { verify: headers, settle: headers, supported: headers };
},
});
Pay from an AI agent
Clients do not talk to the facilitator at all; they talk to your API and sign. Use @x402/fetch (or @x402/axios) with the EVM exact client scheme. Two Whitechain specifics: register the network, and allow the token in spendControls, because the SDK refuses tokens outside its default list unless you opt in.
import { wrapFetchWithPaymentFromConfig, decodePaymentResponseHeader } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount(process.env.AGENT_PRIVATE_KEY as `0x${string}`);
const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, {
schemes: [{ network: "eip155:1874", client: new ExactEvmScheme(account) }],
spendControls: {
allowedAssets: [{ network: "eip155:1874", asset: "0x2E672dFE33EA977FD064E01aDe7d8c73B3Be7fBB", maxAmountPerPayment: "100000" }],
},
});
const res = await fetchWithPayment("https://api.example.com/weather");
console.log(await res.json());
const receipt = decodePaymentResponseHeader(res.headers.get("PAYMENT-RESPONSE")!);
console.log(receipt.transaction); // https://explorer.testnet.whitechain.io/tx/<hash>
The agent's wallet needs ITC (from the token's faucet) and no WBT: the facilitator pays gas. Keep the key in an environment variable and use a throwaway key on testnet.
Networks and tokens
| Network | CAIP-2 | RPC | Explorer | Gas |
|---|---|---|---|---|
| Whitechain Sepolia | eip155:1874 | https://rpc.testnet.whitechain.io | explorer.testnet.whitechain.io | test WBT (faucet) |
| Whitechain mainnet | eip155:<id> | Config entry; chain id, RPC and explorer as published by Whitechain at launch. See Mainnet. | ||
| Token | Network | Standard | EIP-712 domain | Decimals |
|---|---|---|---|---|
| Inferit Test Credit (ITC) | Whitechain Sepolia | EIP-3009 + EIP-2612, public faucet (1,000 ITC per address per 24 h) | name Inferit Test Credit, version 1 | 6 |
| USDC.e | Whitechain mainnet | Circle FiatToken (EIP-3009) | name USD Coin, version 2 (confirm on the deployed contract) | 6 |
| Any ERC-20 | any | Permit2 (exact) and upto, where the x402 Permit2 proxies exist | n/a | token's |
The facilitator accepts any token on a configured network unless the operator sets an asset allowlist for that network, in which case other tokens are refused with unsupported_asset.
HTTP API reference
All bodies are JSON. Shapes follow the x402 v2 types in @x402/core/types; x402Version is 2. Amounts are decimal strings of atomic units. The facilitator always answers policy refusals with an x402-shaped body so the official client raises a typed VerifyError or SettleError.
GET /supported
What the facilitator can verify and settle. Middleware calls it once on start and validates its routes against it.
{
"kinds": [{ "x402Version": 2, "scheme": "exact", "network": "eip155:1874" }],
"extensions": ["eip2612GasSponsoring"],
"signers": { "eip155:*": ["0xFacilitatorSignerAddress"] }
}
POST /verify
Request{
"x402Version": 2,
"paymentPayload": {
"x402Version": 2,
"resource": { "url": "https://api.example.com/weather" },
"accepted": { "...": "the PaymentRequirements the client chose" },
"payload": {
"signature": "0x...",
"authorization": {
"from": "0xPayer", "to": "0xYourMerchantAddress", "value": "10000",
"validAfter": "0", "validBefore": "1790000000", "nonce": "0x..."
}
}
},
"paymentRequirements": {
"scheme": "exact", "network": "eip155:1874",
"asset": "0x2E672dFE33EA977FD064E01aDe7d8c73B3Be7fBB", "amount": "10000",
"payTo": "0xYourMerchantAddress", "maxTimeoutSeconds": 120,
"extra": { "name": "Inferit Test Credit", "version": "1" }
}
}
Response 200
{ "isValid": true, "payer": "0xPayer" }
{ "isValid": false, "invalidReason": "invalid_exact_evm_payload_authorization_valid_before", "invalidMessage": "...", "payer": "0xPayer" }
Checks, in order: request schema (the payload must be exactly one of EIP-3009 authorization or Permit2 permit2Authorization), API key, scheme and network supported, asset allowed, rate limits (caller, facilitator-wide, payer, payee), payer and payee not denylisted, no duplicate of an in-flight or recently settled authorization, gas budget and signer balance; then the @x402/evm scheme: EIP-712 signature (EOA, EIP-1271 and ERC-6492), to equals payTo, value equals amount, validity window, nonce unused, balance sufficient, and an on-chain simulation of the transfer within MAX_SETTLE_GAS.
POST /settle
Same request body as /verify. The facilitator runs the same checks, claims the authorization so a concurrent settle gets duplicate_settlement, reserves gas budget, re-verifies and simulates, broadcasts transferWithAuthorization and waits for the receipt (bounded by CONFIRMATION_TIMEOUT_MS). The receipt's real cost replaces the reservation.
{ "success": true, "transaction": "0x...", "network": "eip155:1874", "payer": "0xPayer" }
{ "success": false, "errorReason": "gas_budget_exceeded", "errorMessage": "...", "transaction": "", "network": "eip155:1874" }
If the receipt does not arrive within the timeout the response is success: false with errorReason: "settlement_pending" and the broadcast hash in transaction; the transfer may still confirm. A retried settle for the same authorization reconciles against that hash instead of broadcasting again.
GET /health
Liveness plus the number an operator cares about: how many settlements the wallet can still pay for. status is ok or degraded (signer below LOW_RUNWAY_SETTLES affordable settlements, or an RPC unreachable); the endpoint always answers HTTP 200, so alert on the status field, not the HTTP code.
{
"status": "ok",
"service": "whitechain-x402-facilitator",
"x402Version": 2,
"uptimeSeconds": 86400,
"facilitator": "0xFacilitatorSignerAddress",
"denylistSize": 412,
"networks": [
{
"network": "eip155:1874", "name": "Whitechain Sepolia", "chainId": 1874,
"explorer": "https://explorer.testnet.whitechain.io", "nativeSymbol": "WBT",
"schemes": ["exact"],
"permit2": { "deployed": true, "exactProxy": false, "uptoProxy": false, "probedAt": "2026-10-04T18:00:00.000Z" },
"facilitator": "0xFacilitatorSignerAddress",
"gasBudget": { "globalDaily": "1000000000000000000", "globalSpentToday": "41230000000000000", "perPayToDaily": "100000000000000000", "resetsInSeconds": 21600 },
"rpcOk": true, "blockNumber": "9547824",
"gasBalanceWei": "1234500000000000000", "gasBalance": "1.2345 WBT",
"maxFeePerGasWei": "5001000000", "estimatedSettleCostWei": "600120000000000",
"settleRunway": 2056, "lowBalance": false
}
]
}
GET /metrics
Process counters as JSON (no Prometheus dependency; scrape and convert if you need it). Reasons are bucketed so you can see why payments fail without the facilitator ever logging a payload:
{
"startedAt": "2026-10-04T18:00:00.000Z", "uptimeSeconds": 3600,
"verify": { "total": 120, "valid": 115, "invalid": 5, "errors": 0, "byReason": { "invalid_exact_evm_insufficient_balance": 5 } },
"settle": { "total": 110, "success": 109, "failed": 1, "pending": 0, "errors": 0, "byReason": {}, "settledAmountAtomic": { "eip155:1874|0xitc…": "1100000" } },
"policy": { "rateLimited": 3, "denylisted": 0, "gasBudgetExceeded": 0, "gasCapExceeded": 0, "duplicate": 1, "unauthorized": 0, "unsupported": 0, "invalidRequest": 0 },
"gas": { "spentWei": { "eip155:1874": "41230000000000000", "total": "41230000000000000" }, "txCount": 109 },
"http": { "requests": 420, "supported": 12 },
"budget": { "day": "2026-10-04", "globalSpentTodayWei": "41230000000000000", "globalDailyLimitWei": "1000000000000000000", "perPayToDailyLimitWei": "100000000000000000", "totalSpentWei": "41230000000000000", "resetsInSeconds": 21600 },
"fees": { "mode": "off" },
"dedupe": { "tracked": 12 },
"rateLimiters": { "callers": 8, "payers": 5, "payTos": 3 }
}
Headers
X-API-Key(request, optional): merchant key.Retry-After(response, on 429 and 503): seconds until the bucket refills, the daily budget resets, or the RPC / gas wallet should be retried.WWW-Authenticate(response, on 401): names theX-API-Keyheader.
Bodies are limited to 64 KiB (413) and must be application/json (415); both answer in the protocol shape with invalid_request.
Reason codes
Facilitator-level reasons (stable strings; branch on them in invalidReason / errorReason) use a non-2xx status, which HTTPFacilitatorClient turns into a typed VerifyError / SettleError. Verdicts about the payment itself come back as HTTP 200 with isValid: false / success: false; the scheme-level ones come from @x402/evm and mostly start with invalid_exact_evm_.
| Reason | HTTP | Meaning |
|---|---|---|
invalid_request | 400 (413, 415) | Body failed schema validation, x402Version unsupported, accepted network/scheme differ from the requirements, or the payload is not exactly one of EIP-3009 / Permit2. |
unsupported_scheme_network | 400 | No scheme registered for that (version, scheme, network); see /supported. |
unsupported_asset | 400 | Token not on the network's allowlist. |
invalid_api_key | 401 | X-API-Key missing (when required), unknown, or not allowed for this payTo. |
address_denylisted | 403 | Payer or payee is on the sanctions denylist. |
fee_policy_rejected | 403 | The fee policy declined to sponsor this payment (settle). |
duplicate_settlement | 409 | Same authorization is in flight or was settled recently by this facilitator. |
rate_limit_exceeded | 429 | Caller (IP or key), facilitator-wide, per-payer or per-payTo bucket empty. See Retry-After. |
gas_budget_exceeded | 429 | Daily gas budget (global, per payTo or per key) spent; Retry-After points at 00:00 UTC. |
facilitator_out_of_gas | 503 | Signer cannot afford a settlement on that network. |
rpc_unavailable | 503 | The network's RPC did not answer; nothing was checked or broadcast. |
unexpected_verify_error, unexpected_settle_error | 500 | Unhandled exception (the message never contains URLs). |
settle_gas_cap_exceeded | 200 | Simulated settlement needs more gas than MAX_SETTLE_GAS. |
settlement_pending | 200 | Broadcast, receipt not seen within the timeout; transaction carries the hash. |
invalid_exact_evm_*, permit2_*, eip6492_factory_not_allowed | 200 | Scheme-level: bad signature, wrong recipient or amount, expired, nonce used, insufficient balance, token without EIP-3009, simulation failed, counterfactual wallet, or reverted on chain (invalid_exact_evm_transaction_failed, with the hash). |
Limits, budgets and API keys
Testnet is free. These are the defaults of the public facilitator; self-hosters set their own.
| Control | Default | Setting |
|---|---|---|
| Requests per client IP | 120 / minute | RATE_LIMIT_IP_PER_WINDOW, RATE_LIMIT_WINDOW_SECONDS |
| Requests per payer address | 30 / minute | RATE_LIMIT_PAYER_PER_WINDOW |
| Requests per payTo address | 120 / minute | RATE_LIMIT_PAYTO_PER_WINDOW |
| Requests facilitator-wide | 1,200 / minute | RATE_LIMIT_GLOBAL_PER_WINDOW |
| Daily gas budget, global | 1 WBT | GAS_BUDGET_GLOBAL_DAILY |
| Daily gas budget per payTo | 0.1 WBT | GAS_BUDGET_PER_PAYTO_DAILY |
| Gas per settlement | 300,000 units | MAX_SETTLE_GAS |
| API key multiplier | 10x the rate limits, own budget | API_KEYS |
Buckets are token buckets that refill evenly over the window; the payer and payTo buckets are only consumed by payments that verify, so a forged payload cannot use up someone else's quota. Budgets are checked on /verify too, so a merchant hears about an exhausted budget before serving the response. A refused /settle never broadcasts, so a refusal costs nobody anything; the merchant's middleware returns its settlement-failed response and the agent can retry later.
To get a merchant API key for the public facilitator, open a key request issue with your payTo address and expected volume.
Self-hosting
Requirements: Node 22 (or Docker), a wallet funded with WBT for gas, an RPC URL. The signer key is read from the environment only.
Dockerdocker build -t whitechain-x402-facilitator .
docker run -p 8402:8402 \
-e FACILITATOR_PRIVATE_KEY=0x... \
-e GAS_BUDGET_GLOBAL_DAILY=1 \
whitechain-x402-facilitator
curl -s localhost:8402/supported
From source
git clone https://github.com/OGcryptonaut/whitechain-x402-facilitator
cd whitechain-x402-facilitator
npm ci
cp .env.example .env # set FACILITATOR_PRIVATE_KEY
npm run build && npm start
Environment variables
| Variable | Default | Purpose |
|---|---|---|
FACILITATOR_PRIVATE_KEY | required | 0x-prefixed 32-byte key of the gas wallet. Never logged. |
NETWORKS | Whitechain Sepolia | JSON array (or path to a JSON file) of { id, name, rpc, explorer, nativeSymbol, testnet, assets? }. |
SCHEMES | exact,upto | Schemes to register. upto is only advertised where the x402 Permit2 proxies exist. |
PORT, HOST | 8402, 0.0.0.0 | Listen address. |
TRUST_PROXY | false | true or hop count when behind a reverse proxy (client IP for rate limits). |
SIMULATE_IN_SETTLE | true | Simulate before broadcasting so doomed transactions never cost gas. |
CONFIRMATION_TIMEOUT_MS | 60000 | Receipt wait bound; keep it below your platform's request deadline. |
MAX_SETTLE_GAS, SETTLE_GAS_ESTIMATE | 300000, 120000 | Per-settlement gas cap and the reservation used before the real cost is known. |
RATE_LIMIT_* | see above | Window and per-IP, per-payer, per-payTo and facilitator-wide (RATE_LIMIT_GLOBAL_PER_WINDOW, 0 disables) limits; RATE_LIMIT_MAX_KEYS bounds memory. |
GAS_BUDGET_GLOBAL_DAILY, GAS_BUDGET_PER_PAYTO_DAILY | 1, 0.1 | Daily budgets in native units (decimal). unlimited disables, 0 refuses every settlement. |
LOW_RUNWAY_SETTLES | 50 | /health turns degraded below this many affordable settlements. |
HEALTH_CACHE_SECONDS, PROXY_PROBE_MINUTES | 10, 10 | How long one /health probe is reused; how often networks are re-probed for the Permit2 proxies (0 = at start only). |
API_KEYS, REQUIRE_API_KEY | none, false | JSON array of { name, key | keySha256, rateLimitMultiplier?, dailyGasBudget?, payTo? }; require a key for every call. |
DENYLIST_FILE, DENYLIST_RELOAD_SECONDS | denylist.txt, 300 | One address per line; reloaded when the file changes. |
FEE_MODE, FEE_BPS, FEE_FLAT_ATOMIC | off, 0, 0 | Fee hook: accrue records a fee per settlement in /metrics. |
LANDING_FILE, DOCS_URL, REPO_URL, PUBLIC_URL | site/landing.html, … | What GET / serves and links to. |
LOG_LEVEL | info | pino level. Signatures, keys and the X-API-Key header are always redacted. |
The full operations guide (funding, budgets, denylist updates from the OFAC SDN list, monitoring, incident response) is in docs/OPERATIONS.md.
Security model
- No custody. The payer's signature fixes
from,to,value, validity window and nonce. The facilitator can only submit it or not. - Verification is the official implementation.
@x402/evm's facilitator scheme does the EIP-712 recovery (EOA, EIP-1271, ERC-6492), balance and nonce checks and the simulation; this project adds policy around it rather than re-implementing it. - Blast radius is gas. The signer holds WBT only and only ever signs settlement transactions (never messages or typed data). The worst case per request is one transaction capped at
MAX_SETTLE_GAS; the daily budgets bound the total. Rotate the key by changing one environment variable;/supportedadvertises the new signer. - Replay and duplicate protection. On-chain nonces plus an in-memory registry of in-flight and recently settled authorizations; EIP-712 domains bind a signature to one chain, so nothing replays across networks.
- Screening sees the real payer. A payload is exactly one of EIP-3009 or Permit2 (anything else is
invalid_request), so the denylist, rate limits and dedupe always look at the payer@x402/evmwill settle for. - Sanctions screening hook. Payer and payee are checked against a denylist file; the repo includes a script that builds it from the public OFAC SDN list.
- No secrets in logs or responses. pino redaction covers signatures, keys and auth headers; request bodies are never logged whole; error text has URLs (and so RPC provider keys) removed before it reaches a caller,
/healthor the log.
Report vulnerabilities privately: SECURITY.md.
Mainnet
Nothing in the code is testnet-only. To serve Whitechain mainnet, add a network entry with the chain id Whitechain publishes, a mainnet RPC and explorer, fund the signer with WBT, and restrict assets to the tokens you are willing to sponsor (for example USDC.e):
NETWORKS='[
{ "id": "eip155:1874", "name": "Whitechain Sepolia", "rpc": "https://rpc.testnet.whitechain.io", "explorer": "https://explorer.testnet.whitechain.io", "testnet": true },
{ "id": "eip155:<MAINNET_CHAIN_ID>", "name": "Whitechain", "rpc": "https://<mainnet-rpc>", "explorer": "https://<mainnet-explorer>", "testnet": false,
"assets": ["0xUSDCe_ADDRESS"] }
]'
On mainnet the operator may charge for sponsorship: FEE_MODE=accrue with FEE_BPS or FEE_FLAT_ATOMIC records what each merchant owes, visible in /metrics, without touching the payment itself. Details, including key management and budget sizing, are in docs/MAINNET.md.
Troubleshooting
"All payment requirements were rejected by spendControls"
The agent's x402 client only pays default assets unless told otherwise. Add the Whitechain token to spendControls.allowedAssets (with a maxAmountPerPayment in atomic units) or set spendControls: false for testing.
"Facilitator supported returned invalid data" or a 502 from the middleware
The middleware could not read /supported. Check the facilitator URL (no trailing path), that the service is up (/health), and that your route's network appears in kinds.
invalid_exact_evm_payload_authorization_valid_before
The authorization expired before settlement. Raise maxTimeoutSeconds on the route (the client sets validBefore from it) or make the handler faster.
Signature invalid although the token address is right
The EIP-712 domain in extra must match the token: for ITC name: "Inferit Test Credit", version: "1". Read eip712Domain() on the contract if unsure.
gas_budget_exceeded
Your payTo used its daily share of sponsored gas. Wait for the UTC reset shown in Retry-After, request an API key, or self-host.