Whitechain x402 Facilitator

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 express
import 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-server
import { 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.

npm install @x402/fetch @x402/evm viem
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

NetworkCAIP-2RPCExplorerGas
Whitechain Sepoliaeip155:1874https://rpc.testnet.whitechain.ioexplorer.testnet.whitechain.iotest WBT (faucet)
Whitechain mainneteip155:<id>Config entry; chain id, RPC and explorer as published by Whitechain at launch. See Mainnet.
TokenNetworkStandardEIP-712 domainDecimals
Inferit Test Credit (ITC)Whitechain SepoliaEIP-3009 + EIP-2612, public faucet (1,000 ITC per address per 24 h)name Inferit Test Credit, version 16
USDC.eWhitechain mainnetCircle FiatToken (EIP-3009)name USD Coin, version 2 (confirm on the deployed contract)6
Any ERC-20anyPermit2 (exact) and upto, where the x402 Permit2 proxies existn/atoken'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.

Response 200
{ "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 the X-API-Key header.

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_.

ReasonHTTPMeaning
invalid_request400 (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_network400No scheme registered for that (version, scheme, network); see /supported.
unsupported_asset400Token not on the network's allowlist.
invalid_api_key401X-API-Key missing (when required), unknown, or not allowed for this payTo.
address_denylisted403Payer or payee is on the sanctions denylist.
fee_policy_rejected403The fee policy declined to sponsor this payment (settle).
duplicate_settlement409Same authorization is in flight or was settled recently by this facilitator.
rate_limit_exceeded429Caller (IP or key), facilitator-wide, per-payer or per-payTo bucket empty. See Retry-After.
gas_budget_exceeded429Daily gas budget (global, per payTo or per key) spent; Retry-After points at 00:00 UTC.
facilitator_out_of_gas503Signer cannot afford a settlement on that network.
rpc_unavailable503The network's RPC did not answer; nothing was checked or broadcast.
unexpected_verify_error, unexpected_settle_error500Unhandled exception (the message never contains URLs).
settle_gas_cap_exceeded200Simulated settlement needs more gas than MAX_SETTLE_GAS.
settlement_pending200Broadcast, receipt not seen within the timeout; transaction carries the hash.
invalid_exact_evm_*, permit2_*, eip6492_factory_not_allowed200Scheme-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.

ControlDefaultSetting
Requests per client IP120 / minuteRATE_LIMIT_IP_PER_WINDOW, RATE_LIMIT_WINDOW_SECONDS
Requests per payer address30 / minuteRATE_LIMIT_PAYER_PER_WINDOW
Requests per payTo address120 / minuteRATE_LIMIT_PAYTO_PER_WINDOW
Requests facilitator-wide1,200 / minuteRATE_LIMIT_GLOBAL_PER_WINDOW
Daily gas budget, global1 WBTGAS_BUDGET_GLOBAL_DAILY
Daily gas budget per payTo0.1 WBTGAS_BUDGET_PER_PAYTO_DAILY
Gas per settlement300,000 unitsMAX_SETTLE_GAS
API key multiplier10x the rate limits, own budgetAPI_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.

Docker
docker 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

VariableDefaultPurpose
FACILITATOR_PRIVATE_KEYrequired0x-prefixed 32-byte key of the gas wallet. Never logged.
NETWORKSWhitechain SepoliaJSON array (or path to a JSON file) of { id, name, rpc, explorer, nativeSymbol, testnet, assets? }.
SCHEMESexact,uptoSchemes to register. upto is only advertised where the x402 Permit2 proxies exist.
PORT, HOST8402, 0.0.0.0Listen address.
TRUST_PROXYfalsetrue or hop count when behind a reverse proxy (client IP for rate limits).
SIMULATE_IN_SETTLEtrueSimulate before broadcasting so doomed transactions never cost gas.
CONFIRMATION_TIMEOUT_MS60000Receipt wait bound; keep it below your platform's request deadline.
MAX_SETTLE_GAS, SETTLE_GAS_ESTIMATE300000, 120000Per-settlement gas cap and the reservation used before the real cost is known.
RATE_LIMIT_*see aboveWindow 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_DAILY1, 0.1Daily budgets in native units (decimal). unlimited disables, 0 refuses every settlement.
LOW_RUNWAY_SETTLES50/health turns degraded below this many affordable settlements.
HEALTH_CACHE_SECONDS, PROXY_PROBE_MINUTES10, 10How long one /health probe is reused; how often networks are re-probed for the Permit2 proxies (0 = at start only).
API_KEYS, REQUIRE_API_KEYnone, falseJSON array of { name, key | keySha256, rateLimitMultiplier?, dailyGasBudget?, payTo? }; require a key for every call.
DENYLIST_FILE, DENYLIST_RELOAD_SECONDSdenylist.txt, 300One address per line; reloaded when the file changes.
FEE_MODE, FEE_BPS, FEE_FLAT_ATOMICoff, 0, 0Fee hook: accrue records a fee per settlement in /metrics.
LANDING_FILE, DOCS_URL, REPO_URL, PUBLIC_URLsite/landing.html, …What GET / serves and links to.
LOG_LEVELinfopino 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; /supported advertises 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/evm will 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, /health or 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.