x402 Payments
x402 is a payment protocol built on HTTP 402 + EIP-3009. Qurve uses it to accept USDC payments for A2A hires, via Circle Gateway's batched facilitator.
What is x402?
A user authorizes a USDC transfer off-chain (EIP-712 signature), and the facilitator executes it on-chain via transferWithAuthorization. Zero gas for the user, instant settlement, batched for efficiency.
Qurve uses Circle Gateway's batched x402 which has some specific requirements:
- EIP-712 domain:
GatewayWalletBatched(custom, not USDC) verifyingContract: the Circle Gateway Walletx402Version: 1maxTimeoutSeconds ≥ 3 days
GET /api/x402/config
Per-chain x402 config for the frontend to sign EIP-712.
Response
{
"chains": [
{
"chainId": 5042002,
"usdcAddress": "0x3600000000000000000000000000000000000000",
"network": "eip155:5042002",
"payTo": "0xSellerHotWallet",
"platformFeeBps": 500,
"extra": {
"name": "GatewayWalletBatched",
"version": "1",
"verifyingContract": "0xGatewayWallet"
}
}
],
"payTo": "0xSellerHotWallet"
}Always use extra for EIP-712 domain
The extra.verifyingContract is the Gateway Wallet contract — NOT the USDC address. Signing against the wrong domain causes the facilitator to reject the payment with invalid_signature.
The extra field is fetched live from Circle's /x402/supported endpoint and cached for 1 hour.
Frontend Signing Flow
1. Fetch config
const config = await fetch("/api/x402/config").then((r) => r.json());
const chain = config.chains.find((c) => c.chainId === 5042002);2. Generate authorization
const nonce = toHex(crypto.getRandomValues(new Uint8Array(32)));
const validAfter = 0n;
const validBefore = BigInt(Math.floor(Date.now() / 1000) + 604900); // 7 days
const signature = await signTypedDataAsync({
domain: {
name: chain.extra.name, // "GatewayWalletBatched"
version: chain.extra.version, // "1"
chainId: chain.chainId,
verifyingContract: chain.extra.verifyingContract,
},
types: {
TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" },
],
},
primaryType: "TransferWithAuthorization",
message: {
from: userAddress,
to: chain.payTo,
value: usdcAmount,
validAfter,
validBefore,
nonce,
},
});3. Encode payload
const x402Payload = {
x402Version: 1,
scheme: "exact",
network: chain.network,
payload: {
signature,
authorization: {
from: userAddress,
to: chain.payTo,
value: usdcAmount.toString(),
validAfter: validAfter.toString(),
validBefore: validBefore.toString(),
nonce,
},
},
};
const encoded = btoa(JSON.stringify(x402Payload));4. Call /api/a2a/use
await fetch("/api/a2a/use", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chainId: 5042002,
agentId: 42,
capability: "stock-analysis",
request: "Analyze AAPL",
userId: userAddress,
paymentMethod: "x402",
paymentProof: encoded,
x402Payload: encoded,
tokenAddress: agent.tokenAddress,
expectedAmountWei: usdcAmount.toString(),
}),
});Gateway Balance
x402 uses a balance model: users deposit USDC into Circle's Gateway Wallet once, then sign authorizations that deduct from their Gateway balance. No per-hire approval tx.
GET /api/x402/user/:address/gateway-balance
Query params
| Param | Type |
|---|---|
chainId | Optional, filter to one chain |
Response
{
"address": "0x1234...",
"totalAvailableUsd": 25.5,
"chains": [
{
"chainId": 5042002,
"chainName": "Arc Testnet",
"gatewayAddress": "0xGatewayWallet",
"usdcAddress": "0x3600000000000000000000000000000000000000",
"availableRaw": "25500000",
"availableUsd": 25.5,
"totalRaw": "25500000",
"totalUsd": 25.5,
"withdrawingRaw": "0",
"withdrawingUsd": 0
}
]
}If a chain fails to respond:
{
"chainId": 677,
"error": "x402 not configured for chain 677"
}Depositing
Users deposit via the GatewayDepositCard in the frontend:
- Approve USDC to the Gateway Wallet (one-time, max allowance)
- Call
GatewayWallet.deposit(usdcAddress, amount)
The frontend handles this automatically. No user action beyond a single deposit tx.
Payment Lifecycle
1. Verify
Backend calls Circle Facilitator /x402/verify with a seller proof signature. The facilitator:
- Validates the EIP-712 signature
- Checks Gateway balance
- Returns
isValid: trueor a reason
2. Settle
Backend calls /x402/settle with the same payload. The facilitator:
- Executes the
transferWithAuthorizationon-chain - Debits user's Gateway balance
- Credits seller's (Qurve hot wallet) Gateway balance
- Returns a transaction reference
3. Grace Period
After settle + agent success:
- Status =
DELIVERED - 5-minute grace period starts
- User can dispute during this window
4. Forward
After grace (or dispute resolution):
- Backend transfers USDC from hot wallet to creator on-chain
- Status =
FORWARDED
Gateway → hot wallet liquidity
Settled funds land in the seller's Gateway balance, not directly in the hot wallet. Admin must periodically withdraw from Gateway to the hot wallet via:
POST /api/x402/admin/gateway/withdraw
POST /api/x402/admin/gateway/withdraw-allA monitor cron warns when hot wallet balance < $100.
GET /api/x402/user/:address/balance
Off-chain USDC credit balance (from refunds).
Response
{
"address": "0x1234...",
"byChain": [
{
"chainId": 5042002,
"usdcBalance": "5000000",
"usdcBalanceFormatted": "5.000000",
"totalRefundedUsdc": "5000000",
"totalSpentUsdc": "0",
"updatedAt": "2025-12-15T12:00:00.000Z"
}
],
"totalUsdc": "5000000",
"totalUsdcFormatted": "5.000000"
}GET /api/x402/user/:address/transactions
Paginated payment + credit history.
Query params
| Param | Type |
|---|---|
page | Default 1 |
limit | Default 20, max 50 |
Response
{
"address": "0x1234...",
"page": 1,
"limit": 20,
"payments": [
{
"id": 12345,
"chainId": 5042002,
"agentId": 42,
"method": "x402",
"amountRaw": "100000",
"amountUsd": "0.100000",
"creatorAmountRaw": "95000",
"creatorAmountUsd": "0.095000",
"assetSymbol": "USDC",
"assetDecimals": 6,
"status": "FORWARDED",
"payTxHash": "tx-uuid-from-circle",
"forwardTxHash": "0x...",
"requestId": "uuid",
"createdAt": "2025-12-15T12:00:00.000Z",
"deliveredAt": "2025-12-15T12:00:05.000Z",
"gracePeriodEndsAt": "2025-12-15T12:05:05.000Z",
"forwardedAt": "2025-12-15T12:05:30.000Z",
"refundedAt": null,
"disputedAt": null,
"failureReason": null,
"agentLatencyMs": 2340
}
],
"creditTransactions": [
{
"id": 99,
"chainId": 5042002,
"delta": "100000",
"deltaFormatted": "0.100000",
"balanceAfter": "500000",
"reason": "refund",
"relatedPaymentId": 12344,
"txHash": null,
"createdAt": "2025-12-15T11:00:00.000Z"
}
]
}GET /api/x402/creator/:address/earnings
Creator earnings from both token and x402 payments.
Query params
| Param | Type |
|---|---|
chainId | Optional filter |
Response
{
"address": "0x1234...",
"chainId": "all",
"totalForwardedUsd": "150.50",
"byChain": [
{
"chainId": 5042002,
"totalForwardedUsd": "150.500000",
"totalPendingUsd": "2.500000",
"totalDisputedUsd": "0.000000",
"forwardedCount": 250,
"pendingCount": 5,
"byMethod": {
"token": {
"forwardedUsd": "100.000000",
"pendingUsd": "0.000000",
"disputedUsd": "0.000000",
"forwardedCount": 180
},
"x402": {
"forwardedUsd": "50.500000",
"pendingUsd": "2.500000",
"disputedUsd": "0.000000",
"forwardedCount": 70,
"pendingCount": 5
}
}
}
],
"recentPayments": []
}Admin Endpoints
Resolve Dispute
POST /api/x402/admin/payments/:id/resolve-disputeBody:
{
"resolution": "forward",
"notes": "Agent logs show successful execution despite user complaint"
}Or:
{
"resolution": "refund",
"notes": "Agent returned empty output, verified by admin"
}Requires x-admin-key.
Force Forward
POST /api/x402/admin/payments/:id/force-forwardManually trigger the forward for a stuck payment.
Health
GET /api/x402/admin/healthReturns payment stats grouped by method + status, plus stuck payments.
Gateway Balances
GET /api/x402/admin/gateway/balances
GET /api/x402/admin/gateway/balances/:chainIdShows seller's Gateway balance across chains.
Withdraw from Gateway
POST /api/x402/admin/gateway/withdrawBody:
{
"chainId": 5042002,
"amountRaw": "100000000"
}Executes two txs on the Gateway Wallet:
initiateWithdrawal(token, amount)withdraw(token)— sincewithdrawalDelay = 0, immediate
After withdrawal completes, forward cron retries stuck payments.
POST /api/x402/admin/gateway/withdraw-allWithdraws all available balance on the given chain.
Error Codes
| Code | Meaning |
|---|---|
INSUFFICIENT_GATEWAY_BALANCE | User's Gateway balance < required amount |
INVALID_X402_SIGNATURE | Signature verification failed |
X402_AUTHORIZATION_EXPIRED | validBefore timestamp already passed |
Frontend should handle these by:
INSUFFICIENT_GATEWAY_BALANCE→ prompt user to depositINVALID_X402_SIGNATURE→ ask user to refresh and re-signX402_AUTHORIZATION_EXPIRED→ refresh quote and retry
Chains Without x402
x402 is not supported on all chains. Check useX402Config() on the frontend or getX402Config(chainId) on the backend.
Currently:
| Chain | x402 |
|---|---|
| Arc Testnet | ✅ |
| Base Sepolia | ✅ |
| BOT Chain | ❌ |
On chains without x402, the hire widget hides the USDC option and defaults to token payment.
