Skip to main content

Build an x402 facilitator on XDC

A facilitator is the small service that stands between a paid resource and the chain. It does two jobs: it verifies a buyer's signed payment, and it settles that payment on-chain. Because it submits the transaction and pays the gas, the buyer only ever signs. Nothing else about your resource server needs to know how the chain works.

This guide shows how to build one on XDC. It assumes you already understand the x402 flow; if not, read that first.

:::note Who needs this Most people do not need to run a facilitator - you can point your resource server at an existing one. XDC AI runs a hosted facilitator you can use directly:

https://facilitator.xdcai.tech/api/facilitator

Build your own when you want full control of settlement, your own gas policy, or custom verification rules. :::

Why XDC makes this straightforward

USDC on XDC is Circle's FiatTokenV2, which implements EIP-3009 transferWithAuthorization. That single feature is what makes a gasless facilitator possible:

  • The buyer signs an off-chain EIP-712 authorization to move an exact amount of USDC to the payee. No gas, no on-chain transaction yet.
  • Anyone can submit that signed authorization on-chain. The submitter pays the gas, not the buyer.

So a facilitator is just a relayer with a verification step: check the signature, then broadcast transferWithAuthorization from a funded wallet.

The three endpoints

An x402 facilitator exposes a tiny, standard HTTP interface:

EndpointMethodPurpose
/supportedGETAdvertise which scheme, network, and asset you settle.
/verifyPOSTCheck a signed payment without moving funds.
/settlePOSTSubmit the payment on-chain and return the tx hash.

/verify and /settle both receive the same two objects: the payment payload (what the buyer signed) and the payment requirements (what the resource asked for in its 402).

Prerequisites

  • A relayer wallet (a plain EOA) funded with a little XDC for gas. This wallet never holds user funds; it only signs and broadcasts the settle transaction.
  • An XDC RPC endpoint (use a reliable provider, ideally with failover).
  • The USDC contract address for your network (see Constants).
  • A library for chain calls. Examples below use viem.

Network constants

NetworkChain IDUSDC address
XDC Mainnet500xfA2958CB79b0491CC627c1557F441eF849Ca8eb1
XDC Apothem (testnet)510xb5AB69F7bBada22B28e79C8FFAECe55eF1c771D4

USDC has 6 decimals. Always test on Apothem before mainnet.

1. /supported

Return the payment kinds you can settle. A resource server or client reads this to decide whether to route through you.

{
"kinds": [
{ "x402Version": 1, "scheme": "exact", "network": "xdc",
"asset": "0xfA2958CB79b0491CC627c1557F441eF849Ca8eb1", "extra": { "decimals": 6 } },
{ "x402Version": 1, "scheme": "exact", "network": "xdc-apothem",
"asset": "0xb5AB69F7bBada22B28e79C8FFAECe55eF1c771D4", "extra": { "decimals": 6 } }
]
}

2. /verify

The request carries what the buyer signed and what the resource required:

{
"paymentPayload": {
"x402Version": 1,
"scheme": "exact",
"network": "xdc",
"payload": {
"signature": "0x…",
"authorization": {
"from": "0xBuyer…",
"to": "0xPayTo…",
"value": "10000",
"validAfter": "0",
"validBefore": "1999999999",
"nonce": "0x…32bytes"
}
}
},
"paymentRequirements": {
"scheme": "exact", "network": "xdc",
"maxAmountRequired": "10000",
"asset": "0xfA2958CB79b0491CC627c1557F441eF849Ca8eb1",
"payTo": "0xPayTo…"
}
}

Verification is a checklist. Reject the payment unless all of these hold:

  • Signature recovers to from. Rebuild the EIP-712 TransferWithAuthorization typed data from the token's domain (name, version, chainId, the USDC address as verifyingContract) and recover the signer. It must equal authorization.from.
  • Amount is correct. authorization.value equals maxAmountRequired (exact scheme). Do not accept less; decide your policy on more.
  • Recipient matches. authorization.to equals payTo.
  • Network and asset match what you advertise in /supported.
  • Time window is valid. Now is at or after validAfter and strictly before validBefore.
  • Nonce is unused. The nonce has not already been settled (see idempotency below). On XDC you can also read the token's authorizationState(from, nonce).
  • Balance is sufficient. from's USDC balance is at least value.

Return a small verdict:

{ "isValid": true }

or, on failure, a reason the resource can surface:

{ "isValid": false, "invalidReason": "amount_mismatch" }

:::warning Verify is not a guarantee A /verify pass can go stale (balance spent, nonce used) before /settle. Always re-run the critical checks inside /settle, and treat the on-chain result as the source of truth. :::

3. /settle

Submit transferWithAuthorization from your relayer wallet. XDC has two gotchas worth knowing:

  1. USDC on XDC is ecrecover-only (no ERC-1271 / smart-contract signatures). Split the 65-byte signature into v, r, s and call the v/r/s overload.
  2. Use legacy gas pricing. Send the transaction as type: "legacy" with an explicit gasPrice read from the node.
import { createWalletClient, createPublicClient, http, parseAbi, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";

const usdcAbi = parseAbi([
"function transferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce,uint8 v,bytes32 r,bytes32 s)",
"function authorizationState(address authorizer,bytes32 nonce) view returns (bool)",
]);

function splitSig(sig: string): { r: Hex; s: Hex; v: number } {
const h = sig.startsWith("0x") ? sig.slice(2) : sig;
const r = `0x${h.slice(0, 64)}` as Hex;
const s = `0x${h.slice(64, 128)}` as Hex;
let v = parseInt(h.slice(128, 130), 16);
if (v < 27) v += 27;
return { r, s, v };
}

async function settle(auth, signature, { publicClient, relayer, usdc }) {
const { r, s, v } = splitSig(signature);
const gasPrice = await publicClient.getGasPrice();
const hash = await relayer.writeContract({
address: usdc,
abi: usdcAbi,
functionName: "transferWithAuthorization",
args: [auth.from, auth.to, BigInt(auth.value), BigInt(auth.validAfter), BigInt(auth.validBefore), auth.nonce, v, r, s],
type: "legacy",
gasPrice,
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("settlement reverted");
return { success: true, transaction: hash, network: "xdc" };
}

Return the receipt so the resource can attach it to its X-PAYMENT-RESPONSE:

{ "success": true, "transaction": "0x…txHash", "network": "xdc" }

Testing your facilitator

1. Confirm the interface is live.

curl https://your-facilitator.example/supported

2. Verify against a real challenge. Any x402 resource returns a 402 you can parse. For example, our demo resource:

curl https://api.xdcai.tech/x402/echo

That gives you a real payTo, asset, network, and maxAmountRequired to build a test payment around.

3. Settle on Apothem first. Fund a test buyer with Apothem USDC, sign a transferWithAuthorization to a payee, and POST it to your /settle. Confirm the USDC moved and the tx is on the Apothem explorer before you touch mainnet.

Production hardening

  • Idempotency. Persist every settled nonce. If the same (from, nonce) arrives twice, return the first result instead of broadcasting again. This is the single most important safeguard against double-settlement.
  • Verify before you settle. Re-check amount, recipient, expiry, and balance inside /settle, not just /verify.
  • Gas monitoring. Alert when the relayer's XDC balance runs low, or settlement stops.
  • Rate limiting and caps. Throttle per caller and optionally cap spend per payer to blunt abuse.
  • Reliable RPC. Use a primary plus fallback RPC so a single provider outage does not halt settlement.
  • Never trust the client. The signed authorization is the only thing you should act on. Ignore any amount or recipient the caller states outside the signature.

Further reading