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:
| Endpoint | Method | Purpose |
|---|---|---|
/supported | GET | Advertise which scheme, network, and asset you settle. |
/verify | POST | Check a signed payment without moving funds. |
/settle | POST | Submit 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
| Network | Chain ID | USDC address |
|---|---|---|
| XDC Mainnet | 50 | 0xfA2958CB79b0491CC627c1557F441eF849Ca8eb1 |
| XDC Apothem (testnet) | 51 | 0xb5AB69F7bBada22B28e79C8FFAECe55eF1c771D4 |
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-712TransferWithAuthorizationtyped data from the token's domain (name,version,chainId, the USDC address asverifyingContract) and recover the signer. It must equalauthorization.from. - Amount is correct.
authorization.valueequalsmaxAmountRequired(exact scheme). Do not accept less; decide your policy on more. - Recipient matches.
authorization.toequalspayTo. - Network and asset match what you advertise in
/supported. - Time window is valid. Now is at or after
validAfterand strictly beforevalidBefore. - Nonce is unused. The
noncehas not already been settled (see idempotency below). On XDC you can also read the token'sauthorizationState(from, nonce). - Balance is sufficient.
from's USDC balance is at leastvalue.
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:
- USDC on XDC is ecrecover-only (no ERC-1271 / smart-contract signatures). Split the 65-byte signature into
v, r, sand call thev/r/soverload. - Use legacy gas pricing. Send the transaction as
type: "legacy"with an explicitgasPriceread 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
- x402 protocol - the open standard and its facilitator interface.
- EIP-3009 -
transferWithAuthorization. - EIP-712 - the typed-data signature the buyer produces.
- x402 flow on XDC AI and Constants.