Three APIs · one payment flow
GET /supportedPOST /verifyPOST /settleDiscover capability, verify the wallet-signed authorization, then settle only after institutional approval.
Batch · channel lifecycle
Discover Batch support with GET /supported first. Then fund a channel, accumulate vouchers, claim and settle; refund unused funds when needed.
POST /verify → /settlePOST /verifyPOST /settlePOST /settlePOST /verify → /settle| Use case | Scheme |
|---|---|
| One authorization, one immediate payment | exact |
| High-frequency small charges accumulated before onchain settlement | batch-settlement |
API base URL: Testnet https://xapg.io · Mainnet https://mainnet.xapg.io
Testnet and Mainnet use separate hosts. Before signing, confirm the environment and CAIP-2 network returned by GET /supported.
x402 v2 · exact · Base · USDC · EIP-3009x402 v2 · batch-settlement · Base · USDC · payment channel
Get an institution API key first
Use an institution API key for the selected environment. Grant supported for discovery, verify for validation, and settle only to the service authorized to submit settlement.
| Requirement | Value / action |
|---|---|
| XAPG_FACILITATOR_API_KEY | Institution API key with supported, verify and, when authorized, settle scopes. |
| Production signer | Connect an institution-controlled HSM/MPC/KMS or wallet service; approve the network, asset, recipient and amount before signing. |
| Node.js | 24 or later for the supplied Testnet runners |
| XAPG_TEST_PRIVATE_KEY | Disposable Testnet EOA wallet: Exact requires 0.01 Base Sepolia USDC; Batch lifecycle requires 0.03, plus Base Sepolia ETH for gas |
| XAPG_ALLOWED_PAY_TO | Approved Testnet recipient; fill after Inspect |
Exact · Quick Start · Testnet
Sign a Base Sepolia USDC authorization and call /verify. This example does not submit settlement.
Install
mkdir xapg-testnet && cd xapg-testnet npm init -y npm install --save-exact viem@2.55.19 mkdir scripts
POC source · scripts/institution-quick-verify.mjs
// Base Sepolia only. Use a disposable Testnet wallet.
import { randomBytes } from 'node:crypto';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { getAddress, formatUnits } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
const BASE = 'https://xapg.io';
const RESOURCE = `${BASE}/x402/resource`;
const NETWORK = 'eip155:84532';
const USDC = '0x036CbD53842c5426634e7929541eC2318f3dCF7e';
const same = (a, b) => a?.toLowerCase() === b?.toLowerCase();
const assert = (ok, message) => { if (!ok) throw new Error(message); };
export async function runQuickVerify(env, deps = {}) {
const fetcher = deps.fetcher ?? fetch;
const log = deps.log ?? console.log;
const apiKey = env.XAPG_FACILITATOR_API_KEY;
assert(/^xapg_(?:test|inst)_[A-Za-z0-9_-]+$/.test(apiKey ?? ''), 'Missing institution API key');
const api = async (path, body) => {
const response = await fetcher(`${BASE}${path}`, {
method: body ? 'POST' : 'GET', redirect: 'error',
headers: { 'X-API-Key': apiKey, Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}) },
...(body ? { body } : {}), signal: AbortSignal.timeout(20_000),
});
assert(response.status === 200, `${path} returned HTTP ${response.status}`);
return response.json();
};
const supported = await api('/supported');
assert(supported.kinds?.some(k => k.x402Version === 2 && k.scheme === 'exact' && k.network === NETWORK), 'Unsupported payment kind');
assert(supported.assets?.some(a => a.network === NETWORK && same(a.address, USDC)), 'Unsupported asset');
const challenge = await fetcher(RESOURCE, { redirect: 'error', signal: AbortSignal.timeout(20_000) });
assert(challenge.status === 402, 'Expected HTTP 402');
const encoded = challenge.headers.get('PAYMENT-REQUIRED');
assert(encoded && encoded.length < 65_536, 'Missing payment requirements');
const required = JSON.parse(Buffer.from(encoded, 'base64url').toString()).accepts
?.find(r => r.scheme === 'exact' && r.network === NETWORK && same(r.asset, USDC));
assert(required, 'No supported payment requirement');
getAddress(required.payTo);
assert(BigInt(required.amount) > 0n && BigInt(required.amount) <= 10_000n, 'Amount exceeds 0.01 test USDC');
if (env.inspect) {
log(`INSPECT ONLY\nNetwork: Base Sepolia\nAsset: USDC\npayTo: ${required.payTo}\nAmount: ${formatUnits(BigInt(required.amount), 6)} USDC\nNO SIGNATURE · NO PAYMENT`);
return { status: 'inspected', payTo: required.payTo, amount: required.amount };
}
assert(same(required.payTo, getAddress(env.XAPG_ALLOWED_PAY_TO)), 'Recipient is not approved');
assert(/^0x[0-9a-fA-F]{64}$/.test(env.XAPG_TEST_PRIVATE_KEY ?? ''), 'Missing Testnet private key');
log('1. DISCOVERED · Recipient and amount approved · NOT PAID.');
const account = privateKeyToAccount(env.XAPG_TEST_PRIVATE_KEY);
const now = deps.now ?? Math.floor(Date.now() / 1000);
const authorization = { from: account.address, to: required.payTo, value: required.amount,
validAfter: String(now - 60), validBefore: String(now + 120),
nonce: `0x${randomBytes(32).toString('hex')}` };
const signature = await account.signTypedData({
domain: { name: 'USDC', version: '2', chainId: 84532, verifyingContract: USDC },
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: authorization,
});
log('2. SIGNED · EIP-3009 authorization signed locally · NOT PAID.');
const body = JSON.stringify({ x402Version: 2, paymentPayload: { x402Version: 2,
resource: { url: RESOURCE }, accepted: required, payload: { signature, authorization } },
paymentRequirements: required });
const verified = await api('/verify', body);
assert(verified.isValid === true && same(verified.payer, account.address), verified.invalidReason ?? 'Verification failed');
log('3. VERIFIED · isValid: true · NOT PAID.');
return { status: 'verified', payer: account.address };
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
runQuickVerify({ ...process.env, inspect: process.argv.includes('--inspect') })
.catch(error => { console.error(error.message); process.exitCode = 1; });
}Create .env with your institution API key. Use a disposable Testnet wallet only.
XAPG_FACILITATOR_API_KEY=xapg_test_REPLACE_ME XAPG_TEST_PRIVATE_KEY=0xREPLACE_WITH_TEST_PRIVATE_KEY XAPG_ALLOWED_PAY_TO=
1. Inspect
node --env-file=.env scripts/institution-quick-verify.mjs --inspect
INSPECT ONLY · NO SIGNATURE · NO PAYMENT
Approve the displayed payTo and amount, then copy payTo into XAPG_ALLOWED_PAY_TO.
Exact EIP-3009: /verify validates authorization locally; /settle checks current chain state before submission. Successful verification does not guarantee payment.
2. Sign + Verify
node --env-file=.env scripts/institution-quick-verify.mjs
1. DISCOVERED · Recipient and amount approved · NOT PAID. 2. SIGNED · EIP-3009 authorization signed locally · NOT PAID. 3. VERIFIED · isValid: true · NOT PAID.
Two different keys
| Credential | Purpose | Sent to xAPG? |
|---|---|---|
| XAPG_FACILITATOR_API_KEY | Authenticate Institutional API calls | Yes, in X-API-Key |
| Wallet Private Key | Sign payment authorization | Never |
Exact · signing with EIP-712 / EIP-3009
The payer signs an EIP-3009 TransferWithAuthorization locally using EIP-712 typed data.
const signature = await account.signTypedData({
domain,
types,
primaryType: "TransferWithAuthorization",
message: authorization
});EIP-712 domain · Base Sepolia example
name USDC version 2 chainId 84532 verifyingContract 0x036CbD53842c5426634e7929541eC2318f3dCF7e
The contract above is Base Sepolia USDC. For another environment, use its advertised network and asset; do not reuse this domain.
EIP-712 domain · Base Mainnet reference
name USD Coin version 2 chainId 8453 verifyingContract 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
Confirm network, asset and eip-3009 from live /supported. Match the domain to approved paymentRequirements.
TransferWithAuthorization · signed fields
from address to address value uint256 validAfter uint256 validBefore uint256 nonce bytes32
Amounts are decimal strings in USDC atomic units: 10000 = 0.01 USDC. Times are Unix seconds; the example expires 120 seconds after signing.
Field mapping
| Source | Must match |
|---|---|
| paymentRequirements.payTo | authorization.to |
| paymentRequirements.amount | authorization.value |
| paymentRequirements.asset | EIP-712 domain.verifyingContract |
| Payer wallet address | authorization.from |
Exact + Batch · GET /supported
Shared API contract · Testnet and Mainnet
Both environments use the same methods, paths, headers and scopes. Capability discovery determines the available schemes and network; it does not authorize Mainnet access.
| API | Authentication / scope | Purpose |
|---|---|---|
GET /supported | X-API-Key / supportedBrowser session not accepted | Discover enabled schemes, networks, assets and signers |
POST /verify | X-API-Key / verifyOr browser session + X-CSRF-Token | Validate a signed payment without transferring funds |
POST /settle | X-API-Key / settleOr browser session + X-CSRF-Token | Submit an approved payment operation and return its result |
GET /settlements/pending | X-API-Key / settleOr browser session Owning account only | Find one unresolved operation across devices; reconcile each request before retrying |
GET /settlements/:requestId | X-API-Key / settleOr browser session Owning account only | Read owned settlement evidence by request ID; no resubmission |
GET /payment-environment-config | Public / none | Read deployment environment, network, asset and limits |
GET /demo/payment-config | Browser session / none | Read payment configuration for the authenticated wallet demo |
GET /version | Public / none | Identify the deployed release |
| Header | Usage |
|---|---|
X-CSRF-Token | Required for browser-session POST requests; institution API keys do not use browser CSRF |
X-API-Key | Institution authentication; key must authorize the selected environment and endpoint scope |
Content-Type: application/json | Required for JSON verify and settle request bodies |
Idempotency-Key | Batch onchain operations only; reuse with the identical body. Exact does not use this header |
X-Payment-Operation-ID | Optional UUID for /settle; reuse for the same business payment across retries and devices, even if its signature changes. Omitting it defaults to X-Request-ID and cannot identify a later duplicate business payment |
X-Payment-Recovery | Optional /settle header: same-operation. Requires the original X-Request-ID, X-Payment-Operation-ID and unchanged payment body. Claims only an expired execution lease; replays an existing signed transaction, never creates a replacement payment. Legacy operations without recovery metadata fail closed |
X-Request-ID | Optional correlation ID; the response returns a request ID |
not_submitted means no submission was attempted; failed means a known failure; unknown means the outcome must be reconciled by request ID before any resubmission. Retryability alone does not authorize resubmission.
Extension fields are accepted. Payload authorization and network compatibility are validated by the selected scheme adapter. Public responses omit internal diagnostics. API errors retain the string error field; additive errorDetail.code is present on standardized errors and optional for legacy authentication/CSRF errors. Local Mainnet execution refusal preserves its nested error.code variant. Successful settlements require network; legacy rejected operations may omit network.
X-Request-ID is optional on all endpoints. Supply a UUID or the server generates one; responses return it in X-Request-ID. Persist a client-generated UUID before settlement to recover a lost response through /settlements/:requestId.
Returns supported kinds, assets and adapters. Requires supported scope; no request body.
network = required network scheme = exact OR batch-settlement asset = required USDC adapter = eip-3009 ALL MATCH → continue OTHERWISE → stop
Match kinds, assets and extensions[].adapters by network.
Response structure
{
"kinds": [
{ "x402Version": 2, "scheme": "exact", "network": "eip155:CHAIN_ID" },
{ "x402Version": 2, "scheme": "batch-settlement", "network": "eip155:CHAIN_ID" }
],
"extensions": [{
"name": "xapg-payment-adapters",
"version": "1",
"adapters": [{
"id": "eip-3009",
"network": "eip155:CHAIN_ID",
"asset": "0x…",
"settlementSpenders": ["0x…"]
}]
}],
"signers": { "eip155:*": ["0x…"] },
"assets": [{
"network": "eip155:CHAIN_ID",
"address": "0x…",
"symbol": "USDC",
"decimals": 6,
"protocols": ["x402"]
}]
}Exact · shared signed payment body
Exact extra.protocol selects an adapter: omitted or eip-3009 (case-insensitive). Batch extra.assetTransferMethod selects token funding: eip3009 only, required by this implementation. These fields have different purposes and are not interchangeable. Permit2 is only exposed for configured Polygon Amoy adapters; this Base Batch profile does not accept it.
| Field | Required / meaning |
|---|---|
x402Version | Required; 2 |
paymentPayload | Required; x402Version, accepted and signed payload |
paymentRequirements | Required; approved scheme, network, asset, amount, payTo, maxTimeoutSeconds |
paymentPayload.resource is optional on /verify and /settle. Its URL and metadata are not verified by these facilitator routes or signed by EIP-3009. Agent approval separately validates resourceUrl against its URL policy; institutions must bind the resource to the approved order.
| paymentPayload.resource | Meaning |
|---|---|
| url | Resource URL for institution-managed order binding; not verified by the facilitator |
| description / mimeType | Optional descriptive metadata |
POST /verifyPOST /settleExact same bodypaymentPayload.accepted must equal paymentRequirements. Keep the signed amount, recipient, nonce and validity window unchanged between /verify and /settle.
Request structure (placeholders, not executable data)
{
"x402Version": 2,
"paymentPayload": {
"x402Version": 2,
"resource": {
"url": "https://merchant.example/resource",
"description": "Order description",
"mimeType": "application/json"
},
"accepted": {
"scheme": "exact",
"network": "eip155:CHAIN_ID",
"amount": "10000",
"asset": "0x…USDC from /supported…",
"payTo": "0x…beneficiary…",
"maxTimeoutSeconds": 120,
"extra": { "name": "USDC", "version": "2", "protocol": "eip-3009" }
},
"payload": {
"signature": "0x…EIP-712 signature…",
"authorization": {
"from": "0x…payer…",
"to": "0x…same beneficiary…",
"value": "10000",
"validAfter": "…unix seconds…",
"validBefore": "…unix seconds…",
"nonce": "0x…32 bytes…"
}
}
},
"paymentRequirements": {
"scheme": "exact",
"network": "eip155:CHAIN_ID",
"amount": "10000",
"asset": "0x…USDC from /supported…",
"payTo": "0x…beneficiary…",
"maxTimeoutSeconds": 120,
"extra": { "name": "USDC", "version": "2", "protocol": "eip-3009" }
}
}Batch Settlement · Base
Deposit USDC into a channel, sign cumulative vouchers, then claim and settle the merchant balance.
Create and identify a channel
Compute channelId locally with computeChannelId(channelConfig, network). Save both values before signing; changing the network or any channel field creates a new ID.
| ChannelConfig | Meaning / constraint |
|---|---|
payer | Wallet funding the channel |
payerAuthorizer | Signer of cumulative vouchers |
receiver | Must equal paymentRequirements.payTo |
receiverAuthorizer | Claim/refund signer agreed with the merchant |
token | USDC asset on the selected network |
withdrawDelay | 900–2592000 seconds |
salt | Persist a random bytes32; use a new value for a new channel |
Wallet Demo uses the connected payer as both authorizers to demonstrate all operations. Merchant integrations use their agreed receiverAuthorizer.
Contract source and signing domains
Use @x402/evm 2.25.0 and import BATCH_SETTLEMENT_ADDRESS and BATCH_SETTLEMENT_DOMAIN for Batch signatures.
settlementSpenders contains facilitator signer addresses. Use BATCH_SETTLEMENT_ADDRESS as the Batch verifyingContract.
| Purpose | name / version | verifyingContract |
|---|---|---|
| USDC EIP-3009 authorization | USDC / 2 | paymentRequirements.asset0x036CbD53842c5426634e7929541eC2318f3dCF7e |
| Channel ID, Voucher, ClaimBatch, Refund | x402 Batch Settlement / 1BATCH_SETTLEMENT_DOMAIN | BATCH_SETTLEMENT_ADDRESSchainId |
| Discovery adapter metadata (not EIP-712) | xapg-payment-adapters / 1 | None. This is a discovery schema version, not a signing domain. |
Use the selected network chainId in both domains. requirements.extra.name/version belongs to USDC. Deposit signs ReceiveWithAuthorization; Exact signs TransferWithAuthorization.
supported.signers lists facilitator transaction signers, not payer or merchant authorizers. settlementSpenders is the adapter-specific spender list; never substitute either for channel payerAuthorizer or receiverAuthorizer.
Operation map
| Operation | API | Effect |
|---|---|---|
| deposit | /verify → /settle | Verify the EIP-3009 deposit authorization, then fund the channel onchain. |
| voucher | /verify | Validate a cumulative offchain commitment. No TXID and no immediate transfer. |
| claim | /settle | Redeem one or more channel vouchers; up to 100 claims per request. |
| settle | /settle | Transfer the receiver/token aggregate pending USDC to merchant payTo; this is not a single-channel amount. |
| refund | /verify → /settle | Verify the refund state and return unused deposit to the payer. |
Amounts by operation
All amounts use decimal strings in 6-decimal USDC atomic units. requirements.amount is the service charge. deposit.amount is channel funding; voucher.maxClaimableAmount and claim.totalClaimed are cumulative ceilings and targets, not incremental charges. Refund amount is the returned balance; settle transfers the receiver/token aggregate pending balance.
Batch · claim response
Each request accepts 1–100 claims and executes atomically. One transaction hash covers the entire request; partial success is not supported.
{
"success": true,
"network": "eip155:84532",
"transaction": "0x...one transaction for the complete claim batch..."
}
Examples below follow the selected environment. The 0.03 USDC lifecycle example requires a deposit limit of at least 30000; confirm the deployed allowance before using it.
Verified on 2026-10-04: both networks have 11175-byte runtime code. Differences are confined to two PUSH32 constants: chainId and the EIP-712 domain separator. Each domain separator matches the SDK domain for that chain. All remaining bytes are identical. This verifies code equivalence after network-domain normalization; it does not verify storage state, withdrawal execution or the deployment mechanism.
| Network | Block | Runtime bytecode · keccak256 |
|---|---|---|
| base-sepolia | 47674643 | 0xbaeca2aab3b55b232145a81b38b687084b986131651ea91da9e2c9f78e976145 |
| base-mainnet | 52164114 | 0x76df856287b981dbf156d17cf05c21dff150df9628c5f7c3d586c27ba608760d |
Normalized bytecode keccak256: 0x92783147043869453341e3beb86cc1594c0c5ed9959270751199bafd2113d205
Shared requirements
Use identical paymentPayload.accepted and paymentRequirements. Generate payloads with the Batch SDK; amounts are decimal strings in USDC atomic units.
{
"scheme": "batch-settlement",
"network": "eip155:84532",
"amount": "10000",
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"payTo": "0x...merchant...",
"maxTimeoutSeconds": 300,
"extra": {
"receiverAuthorizer": "0x...merchant authorizer...",
"withdrawDelay": 900,
"name": "USDC",
"version": "2",
"assetTransferMethod": "eip3009",
"minDeposit": "{{minDeposit}}"
}
}
Deposit must cover requirements.amount and minDeposit. The initial voucher still requires a separate claim after deposit.
Operation payload schema · SDK 2.25.0
Place these structures in paymentPayload.payload. Addresses and signatures use hex; amounts and nonces use decimal strings. withdrawDelay and withdrawRequestedAt are JSON numbers.
ChannelConfig = { payer: address, payerAuthorizer: address,
receiver: address, receiverAuthorizer: address, token: address,
withdrawDelay: number, salt: bytes32 }
Voucher = { channelId: bytes32, maxClaimableAmount: string, signature: hex }
Claim = { voucher: { channel: ChannelConfig, maxClaimableAmount: string },
signature: hex, totalClaimed: string }
deposit = { type: "deposit", channelConfig: ChannelConfig, voucher: Voucher,
deposit: { amount: string, authorization: { erc3009Authorization: {
validAfter: string, validBefore: string, salt: bytes32, signature: hex
} } } }
voucher = { type: "voucher", channelConfig: ChannelConfig, voucher: Voucher }
claim = { type: "claim", claims: Claim[], claimAuthorizerSignature: hex }
settle = { type: "settle", receiver: address, token: address }
refund (/verify) = { type: "refund", channelConfig: ChannelConfig,
voucher: Voucher, amount?: string }
refund (/settle) = { type: "refund", channelConfig: ChannelConfig,
voucher: Voucher, amount: string, refundNonce: string, claims: Claim[],
refundAuthorizerSignature: hex, claimAuthorizerSignature?: hex }
Voucher signature
payerAuthorizer signs Voucher(channelId, maxClaimableAmount). Use signVoucher; maxClaimableAmount is a cumulative ceiling. Save the voucher before service delivery.
ClaimBatch signature
payerAuthorizer signs each voucher; receiverAuthorizer signs ClaimBatch. totalClaimed is the new cumulative target. Supply claimAuthorizerSignature and submit to /settle; /verify does not accept claim or settle.
Refund signature
Verify the refund to obtain refundNonce, then have receiverAuthorizer sign Refund(channelId, nonce, amount). Add refundAuthorizerSignature and claims to the settlement body; nonempty claims also require claimAuthorizerSignature.
Complete settlement envelope · illustrative
Settlement transfers the aggregate receiver/token balance after claims. Testnet illustration only: deposit 30000; claim cumulative 20000; settle 20000; refund unused 10000. Voucher ceilings are not added together.
{
"x402Version": 2,
"paymentPayload": {
"x402Version": 2,
"accepted": {
"scheme": "batch-settlement",
"network": "eip155:84532",
"amount": "10000",
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"payTo": "0x1111111111111111111111111111111111111111",
"maxTimeoutSeconds": 300,
"extra": {
"receiverAuthorizer": "0x2222222222222222222222222222222222222222",
"withdrawDelay": 900,
"name": "USDC",
"version": "2",
"assetTransferMethod": "eip3009",
"minDeposit": "{{minDeposit}}"
}
},
"payload": {
"type": "settle",
"receiver": "0x1111111111111111111111111111111111111111",
"token": "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
}
},
"paymentRequirements": {
"scheme": "batch-settlement",
"network": "eip155:84532",
"amount": "10000",
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"payTo": "0x1111111111111111111111111111111111111111",
"maxTimeoutSeconds": 300,
"extra": {
"receiverAuthorizer": "0x2222222222222222222222222222222222222222",
"withdrawDelay": 900,
"name": "USDC",
"version": "2",
"assetTransferMethod": "eip3009",
"minDeposit": "{{minDeposit}}"
}
}
}
Lifecycle signing example · integration skeleton
Lifecycle example: supply signers, authenticated transport and durable storage. Save each operation body and key before submission. Calling lifecycle again creates a new channel; retries use the saved operation.
Show lifecycle construction
import { computeChannelId, createBatchSettlementEIP3009DepositPayload,
signVoucher } from "@x402/evm/batch-settlement/client";
import { BATCH_SETTLEMENT_ADDRESS, BATCH_SETTLEMENT_DOMAIN } from "@x402/evm";
import { toHex } from "viem";
// Inputs: institutional payerSigner and receiverSigner (ClientEvmSigner),
// merchant-approved requirements, authenticated post(path, body, headers),
// durable persist(record), and reconcile(stage, request, result).
// reconcile must verify the receipt and operation-specific state/movements.
// post returns parsed JSON only after checking the HTTP response status.
async function lifecycle({ payerSigner, receiverSigner, requirements, post, persist, reconcile }) {
if (requirements.network !== "eip155:84532" ||
requirements.amount !== "10000" ||
requirements.extra.receiverAuthorizer.toLowerCase() !==
receiverSigner.address.toLowerCase()) throw Error("Example configuration mismatch");
const channelConfig = {
payer: payerSigner.address, payerAuthorizer: payerSigner.address,
receiver: requirements.payTo, receiverAuthorizer: receiverSigner.address,
token: requirements.asset, withdrawDelay: requirements.extra.withdrawDelay,
salt: toHex(crypto.getRandomValues(new Uint8Array(32)))
};
const channelId = computeChannelId(channelConfig, requirements.network);
const domain = { ...BATCH_SETTLEMENT_DOMAIN, chainId: 84532,
verifyingContract: BATCH_SETTLEMENT_ADDRESS };
const body = payload => ({ x402Version: 2,
paymentPayload: { x402Version: 2, accepted: requirements, payload },
paymentRequirements: requirements });
async function verify(payload) {
const result = await post("/verify", body(payload), {});
if (!result.isValid) throw Error(result.invalidReason);
return result;
}
async function submit(stage, payload) {
const request = body(payload), key = `batch-${crypto.randomUUID()}`;
const requestId = crypto.randomUUID();
const operation = { stage, channelId, channelConfig, request,
idempotencyKey: key, requestId, status: "prepared" };
await persist(operation); // Durable, before any network submission.
let result;
try {
result = await post("/settle", request,
{ "Idempotency-Key": key, "X-Request-ID": requestId });
} catch (error) {
await persist({ ...operation, status: "unknown" });
return { halted: true, requestId, settlementStatus: "unknown" };
}
await persist({ ...operation, result, status: result.success ? "submitted"
: result.failure?.settlementStatus ?? "unknown" });
if (!result.success) {
const state = result.failure?.settlementStatus;
if (state === "unknown" || !state)
return { halted: true, requestId, settlementStatus: "unknown" };
throw Error(result.errorReason ?? result.failure?.code ?? "Settlement rejected");
}
await reconcile(stage, request, result);
await persist({ ...operation, result, status: "reconciled" });
return result;
}
await persist({ channelConfig, channelId });
// First 0.01 USDC commitment, backed by a 0.03 USDC deposit.
const deposit = await createBatchSettlementEIP3009DepositPayload(
payerSigner, 2, requirements, channelConfig, "30000", "10000");
await verify(deposit.payload);
const depositResult = await submit("deposit", deposit.payload);
if (depositResult.halted) return depositResult;
// Second charge raises the cumulative ceiling to 0.02 USDC.
const voucher = await signVoucher(payerSigner, channelId, "20000", requirements.network);
await verify({ type: "voucher", channelConfig, voucher });
await persist({ channelId, voucher, chargedCumulativeAmount: "20000" });
const claims = [{ voucher: { channel: channelConfig, maxClaimableAmount: "20000" },
signature: voucher.signature, totalClaimed: "20000" }];
const claimAuthorizerSignature = await receiverSigner.signTypedData({ domain,
types: { ClaimBatch: [{ name: "claims", type: "ClaimEntry[]" }], ClaimEntry: [
{ name: "channelId", type: "bytes32" },
{ name: "maxClaimableAmount", type: "uint128" },
{ name: "totalClaimed", type: "uint128" }] },
primaryType: "ClaimBatch", message: { claims: [
{ channelId, maxClaimableAmount: 20000n, totalClaimed: 20000n }] } });
const claimResult = await submit("claim", { type: "claim", claims, claimAuthorizerSignature });
if (claimResult.halted) return claimResult;
const settleResult = await submit("settle", { type: "settle", receiver: requirements.payTo, token: requirements.asset });
if (settleResult.halted) return settleResult;
// Claims have already been reconciled, so this refund needs no additional claims.
const refund = { type: "refund", channelConfig, voucher, amount: "10000" };
const checked = await verify(refund);
const refundNonce = checked.extra.refundNonce;
const refundAuthorizerSignature = await receiverSigner.signTypedData({ domain,
types: { Refund: [{ name: "channelId", type: "bytes32" },
{ name: "nonce", type: "uint256" }, { name: "amount", type: "uint128" }] },
primaryType: "Refund", message: { channelId, nonce: BigInt(refundNonce), amount: 10000n } });
await submit("refund", { ...refund, refundNonce, claims: [], refundAuthorizerSignature });
}
Batch validates requirements network, asset, payTo, positive amount within the configured cap, timeout and all supported extra fields; accepted must match these values. Claim, settle and refund retain these requirements as validation context. Their actual onchain amounts come from claims, receiver/token aggregate balance or refund.amount, not requirements.amount.
Mainnet exposes the operation contracts and envelope examples. The complete 0.03 USDC lifecycle construction is available on Testnet; it is hidden on Mainnet because this template cannot establish your deployed allowance or institutional signer.
Open Testnet lifecycle example
Responses and state
Batch verification returns isValid, payer and optional extra channel state. extra includes channelId, balance, totalClaimed, withdrawRequestedAt and refundNonce; it is a current-state read, not a funds reservation.
Read success and network before accepting the response. transaction identifies the onchain operation; payer and extra depend on operation. Submitting voucher to /settle returns HTTP 400 invalid_operation; use /verify for vouchers.
Idempotency-Key
Every Batch /settle requires Idempotency-Key: 8–128 letters, numbers or . _ : -. Reuse the same key and identical body for a retry; different content requires a new operation.
curl https://xapg.io/settle \ -H "X-API-Key: xapg_inst_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: channel-abc-claim-0001" \ --data-binary @batch-claim.json
Accumulation, limits and reconciliation
The merchant stores the latest voucher and accepted cumulative amount, locks each channel and deduplicates orders. /verify does not deduplicate service delivery; the merchant chooses when to claim.
Limits: 1–100 claims; maxTimeoutSeconds 1–3600; withdrawDelay 900–2592000 seconds. Payment and deposit ceilings follow server configuration. Deposit must cover requirements.amount and optional minDeposit.
Save the request, Idempotency-Key, channelId, voucher ceiling and transaction hash. Reconcile channel state and USDC movements before advancing the order; see Retry policy for unknown outcomes.
Exact + Batch · POST /verify
Validates Exact authorizations or Batch deposit, voucher and refund payloads without submitting a transaction. Requires verify scope.
{
"isValid": true,
"payer": "0x..."
}Require HTTP 200 and isValid:true. Check payer when returned; verification does not authorize business approval or reserve funds.
/verifyisValid: trueExact + Batch · POST /settle
/settle does not require a prior /verify call or approval token. Your backend must enforce verification, per-order locking, budget and approval before submission.
Submit the same payment body that passed /verify.
Submit a deposit, claim, settle or refund operation with its required signatures and Idempotency-Key.
Requires settle scope and institutional approval.
{
"success": true,
"network": "eip155:CHAIN_ID",
"payer": "0x...",
"transaction": "0x..."
}Require success:true and the expected network. Save transaction and reconcile the successful receipt and USDC movement.
| Field | Meaning |
|---|---|
isValid | Verification result; false means validation failed |
success | Settlement result; HTTP 200 alone is insufficient |
network | Settlement network; must match the request |
transaction | Optional onchain transaction hash |
payer | Optional payer; validate when returned |
invalidReason / errorReason | Protocol error code |
extra | Operation-specific channel state |
failure | Optional handler error details |
Retry policy
Do not resubmit and do not sign a second authorization. Reconcile the original nonce and transaction first.
Retry with the identical body and original Idempotency-Key. Reconcile an uncertain claim, settle or refund first; automatic pending-transaction recovery is available only for deposits. Never change the key to bypass an unresolved operation.
API change log and migration policy
Configured limits and release policy
The server container retains npm ci --legacy-peer-deps because Circle SDK 10.8.0 has an optional @solana/codecs-strings ^2 peer while the locked Solana stack uses 5.5.1. This is a server dependency workaround, not required by the standalone Base signing example.
GET /payment-environment-config reports the selected deployment configuration. Testnet source defaults are 1000000 atomic units for MAX_AMOUNT and BATCH_MAX_DEPOSIT_ATOMIC (1 USDC). Mainnet requires explicit MAINNET_MAX_AMOUNT and MAINNET_AGENT_TOTAL_LIMIT; its deposit ceiling defaults to that configured total limit. Defaults do not prove deployed limits.
| maxAmountAtomic | Unavailable |
|---|---|
| general API · IP | Unavailable |
| verify / settle · credential | Unavailable |
| batchMaxDepositAtomic | Unavailable |
API 0.3 uses x402 v2, @x402/core and @x402/evm 2.25.0, and viem 2.55.19. Pin the integration package and lockfile, install with npm ci, and record the package version and deployment revision with each release. Review upstream changelogs and rerun signing and settlement checks before upgrades; package version alone does not identify the deployed server.
Production readiness also requires institution-controlled signing, durable operation storage, order locking, budget approval, monitoring, reconciliation and an incident owner. This guide describes technical integration; it does not certify regulatory or AML compliance.
Exact + Batch · errors
Payment failures may include failure. Use code for handling, settlementStatus for reconciliation and requestId for support. Authentication and rate-limit errors use a separate error response.
{
"failure": {
"code": "invalid_signature",
"stage": "authorization_validation",
"message": "The wallet signature could not be recovered or validated.",
"retryable": false,
"settlementStatus": "not_submitted",
"requestId": "..."
}
}
For /verify, handler failures use not_submitted. /settle may return failed before transaction submission; use the transaction hash and chain receipt to establish the outcome. Batch wrapper errors use verification_failed or settlement_failed; invalid_batch_settlement_evm_* identifies SDK-returned validation errors.
Authentication errors retain error as a string; errorDetail.code and requestId may be provided. HTTP 429 includes Retry-After. These are transport/authentication responses, not payment failure envelopes. Do not infer settlement submission from an authentication error or retryable flag.
Authentication and rate limiting
Institution limits for /verify and /settle are configured by the administrator. Settlement queries (/settlements/:requestId and /settlements/pending) follow the institution’s /settle limit with a separate shared query counter; polling does not consume the submission quota. When that limit is disabled, neither settlement submissions nor queries have an application request limit. RPC provider limits still apply.
HTTP 401
{ "error": "environment_mismatch",
"errorDetail": { "code": "environment_mismatch" },
"requestId": "<request UUID>" }
HTTP 403
{ "error": "facilitator_scope_not_authorized",
"errorDetail": { "code": "facilitator_scope_not_authorized" },
"requestId": "<request UUID>", "requiredScope": "settle" }
HTTP 429
Retry-After: <seconds>
{ "error": "rate_limit_exceeded",
"errorDetail": { "code": "rate_limit_exceeded" },
"requestId": "<request UUID>" }Institution keys must be bound to the selected environment. environment_mismatch rejects a key for another environment; institution_key_environment_unbound rejects an unbound legacy key. Reissue legacy keys with an explicit environment before migration; existing unbound keys will stop authenticating. Retry-After is provided for HTTP 429; do not assume this header on other errors.
settlement_provider_unavailable is missing deployment capability and needs operator configuration. rpc_unavailable is a transient RPC failure. Retry reads with backoff; for settlement, reconcile the original operation before any retry.
Public failure code matrix
| failure.code | retryable | settlementStatus | Meaning / action |
|---|---|---|---|
invalid_payment_payloadinvalid_permit2_payloadPolygon Amoy adapter onlyinvalid_signaturesigner_mismatch | false | not_submitted / failed | Correct the payload, domain or signer; do not submit settlement. |
insufficient_funds | false | not_submitted / failed | Fund the payer, then create and verify a new authorization. |
authorization_expiredauthorization_not_yet_validauthorization_too_close_to_expiry | false | not_submitted / failed | Correct the authorization window before settlement. |
nonce_already_used | false | not_submitted / failed | Exact only: reconcile the original nonce and transaction; never sign a replacement until resolved. |
simulation_failedsimulation_call_failedexecution_reverted | false | not_submitted / failed | Correct the request or onchain state; do not loop settlement. |
settlement_provider_unavailabletransaction_revertedsettlement_failed | false | not_submitted / failed / unknown | Stop and reconcile with the requestId and any known transaction hash. |
verification_failedunexpected_provider_error | false | not_submitted / failed / unknown | Do not guess the outcome; inspect stage and settlementStatus, then contact xAPG with requestId. |
cloud_journal_lane_blockedcloud_journal_lane_untracked | false | not_submitted | Self-hosted settlement: a prior expired unsigned nonce or an untracked legacy gap blocks new payments for this sender. Ask the operator to reconcile the lane; do not create new operation IDs or retry automatically. Existing signed operations retain same-operation recovery. |
rpc_rate_limitedrpc_unavailable | true | not_submitted / failed / unknown | Retry verification after backoff. For settlement, follow the scheme-specific retry policy above. |
rpc_timeout | true | not_submitted / unknown | For settlement, follow the scheme-specific retry policy above; retryable does not authorize a new payment. |
invalid_batch_settlement_evm_* | false | not_submitted / failed | Batch only: the suffix identifies the channel, voucher, claim, settlement or refund validation failure. Correct that operation; do not switch keys to bypass it. |
HTTP 401/403: check credentials, scope and browser CSRF. HTTP 429: respect Retry-After when present. HTTP 200 may contain isValid:false or success:false.
Production & Security
Exact · Mainnet workflow
- Discover: Mainnet API key → GET /supported. Require exact, eip155:8453, USDC and eip-3009.
- Approve: bind merchant requirements to one order; lock the order and reserve budget.
- Sign: institutional signer → TransferWithAuthorization; Mainnet domain, fresh nonce, matching recipient/amount and validity window.
- Verify: POST /verify. Require HTTP 200, isValid: true and the expected payer. No funds move.
- Release: record settlement approval; recheck budget/expiry and persist request digest/nonce.
- Settle: POST /settle once, using the identical verified body and settle scope.
- Reconcile: match network/payer/hash, successful receipt and USDC recipient/amount; persist records. Confirm delivery separately.
Mainnet signer: HSM/MPC/KMS or institutional wallet service. Supplied runners are Testnet only. Institution budgets are independent of Agent /agent/approvals allowances.
Agent payment approval API
Agent payment approvals use Agent API key, OAuth or browser authentication; institution API keys are for Facilitator routes. Browser signing requires a session and CSRF. See the standalone reference for both environments, request fields and continuation recovery.
Production signer
Testnet
.env private key → viem signer
Production
Institution Backend
↓
HSM / MPC / KMS / Wallet Service
↓
EIP-712 Signature
↓
xAPGUse an institutional signer for Mainnet. The supplied command-line runners support Base Sepolia only.
Keep API keys and signing material in backend secret storage; exclude them and payment-attempt*/ from version control.
Contact xAPG for the matching integration package and institution access. Install the supplied lockfile with npm ci.
Exact · settlement runner
Testnet settlement runner: scripts/institution-quickstart.mjs. Requires settle scope and business approval.
node --env-file=.env scripts/institution-quickstart.mjs --settle --confirm-settlement BASE_SEPOLIA --out-dir payment-attempt-001
The runner submits once, checks the USDC receipt and saves records. Do not use it to retry an uncertain submission.
Exact + Batch · settlement reconciliation
The Exact runner writes these files. Batch clients must store equivalent operation and receipt records.
| Artifact | Recorded when / contents |
|---|---|
| attempt.json | Before submission: payer, recipient, amount, network, request hash, and Exact nonce or Batch channelId + Idempotency-Key |
| submission.json | After a valid settlement response: transaction hash and attempt context |
| receipt.json | After successful receipt and matching transfer: confirmed block and settlement |
Existing output directories block submission. Missing submission.json or receipt.json does not prove failure; follow Retry policy.
Implement durable per-order locking and budget reservation across workers. Runner directory protection is not order deduplication; settlement submission lookup is available, but no merchant order-status API is provided.
Recover a saved settlement submission
Persist a UUID X-Request-ID with the operation before POST /settle. Query GET /settlements/:requestId with a settle-scoped institution API key or the owning browser session. The response is scoped to the owner and environment.
{
"requestId": "<saved UUID>",
"environment": "{{environment}}",
"network": "{{network}}",
"scheme": "exact",
"status": "unknown",
"createdAt": "<stored timestamp>",
"updatedAt": "<stored timestamp>"
}status is processing, completed or unknown; httpStatus and response are optional saved handler results. This journal is not chain confirmation. processing may indicate an interrupted handler; HTTP 404 only means no matching stored record and never proves that funds did not move. Reconcile transaction receipts, authorization nonce or channel state before advancing.
Recover from the saved operation, not by calling lifecycle again. A permitted Batch retry uses the identical body and original Idempotency-Key, but a new persisted UUID X-Request-ID to journal the retry. Only deposits support automatic pending-transaction recovery; reconcile unknown claim, settle and refund outcomes first. Exact has no automatic resubmission.
Payer exit without the facilitator
SDK 2.25.0 exposes initiateWithdraw(config, amount), pendingWithdrawals(channelId) returning amount and initiatedAt, and finalizeWithdraw(config) in the Batch contract ABI. These are direct payer-wallet contract calls, not xAPG API endpoints. Keep the original ChannelConfig and channelId, connect to the correct network and contract, and fund the wallet with native ETH for gas.
The upstream EVM binding permits payer or payerAuthorizer to initiate a timed withdrawal. The requested amount must fit unclaimed escrow; the merchant may claim outstanding vouchers during the delay. Final withdrawal is capped by the remaining unclaimed balance. Verify that the deployed contract implements this binding.
Upstream EVM withdrawal specification
payer wallet → initiateWithdraw(originalChannelConfig, amount) read pendingWithdrawals(channelId) wait configured withdrawDelay from initiatedAt payer wallet → finalizeWithdraw(originalChannelConfig) confirm receipt and token balance
Before using exit, verify the deployed contract address, ABI, caller permissions, available balance and current withdrawal rules. The delay alone does not guarantee a successful withdrawal; competing claims and contract state may affect the result. Caller permissions have not been independently verified by this guide.
Download unsigned Batch typed-data and hash vectors
Download Voucher, ClaimBatch and Refund signature verification vectors
Vectors include typed data, digests, signatures and recovered signer addresses for both networks. Signatures come from discarded, unfunded test signers; no signing material is retained. Use them only for offline verification.
Resume a saved Batch deposit
async function resumeDeposit(saved, { post, persist, reconcile }) {
if (saved.stage !== "deposit") throw Error("Reconcile this operation first");
const attempt = { ...saved, requestId: crypto.randomUUID() };
await persist(attempt); // Keep the original request and Idempotency-Key.
const result = await post("/settle", saved.request, {
"Idempotency-Key": saved.idempotencyKey, "X-Request-ID": attempt.requestId
});
await persist({ ...attempt, result });
if (!result.success) return { halted: true, result };
await reconcile(saved.stage, saved.request, result);
return result;
}Advanced Signing & Limitations
Exact requires a 65-byte EOA EIP-712 signature (r || s || v). personal_sign, EIP-1271 and contract-wallet signatures are not supported by this recovery path.
Order IDs and resource URLs are not EIP-3009 signed fields. Bind them to the approved requirements and nonce; verify delivery separately.
Compliance controls are separate from payment signature validation. This API does not perform payer/payTo sanctions screening, exchange Travel Rule data or file suspicious transaction reports. The institution controls pre-sign screening and required counterparty data; the institution and xAPG operator must each assess and document their own applicable reporting and service obligations before production.
Responsibilities
| Institution | xAPG |
|---|---|
| Wallet custody | API verification |
| Private-key management | Settlement submission |
| Pre-sign approval | Settlement execution |
| Spending policies | Protocol validation |
| Budgets | Facilitator infrastructure |
| Order deduplication | Settlement response |
| Legal and regulatory assessment | Provide technical integration details; institutions assess applicable custody, AML, sanctions and reporting obligations with their advisers. |
| Audit records | — |
| Merchant/resource delivery verification | — |