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: https://xapg.io
The host is the same; select the environment from GET /supported and the returned CAIP-2 network, not from the hostname.
x402 v2 · exact · Base · USDC · EIP-3009x402 v2 · batch-settlement · Base Sepolia · USDC · payment channel
Get an institution API key first
Get this key first; the POC cannot call /supported or /verify without it.
| Requirement | Value / action |
|---|---|
| Node.js | 24 or later |
| XAPG_FACILITATOR_API_KEY | Institution API key (supported + verify scopes) |
| XAPG_TEST_PRIVATE_KEY | Disposable Testnet EOA wallet with at least 0.01 Base Sepolia USDC |
| XAPG_ALLOWED_PAY_TO | Approved Testnet recipient; fill after Inspect |
Exact · 5-minute Quick Start · Testnet
This POC does one thing: sign a Base Sepolia USDC authorization locally and call /verify. It never calls /settle.
All institutional examples use xAPG Facilitator only.
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_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_inst_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.
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.
TransferWithAuthorization · signed fields
from address to address value uint256 validAfter uint256 validBefore uint256 nonce bytes32
10000 atomic units = 0.01 USDC. Times are Unix seconds; the example uses a random 32-byte nonce and an authorization valid for at most 120 seconds.
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
Call /supported before signing to discover the currently supported network, asset, scheme and signing adapter. No body; requires supported scope. Do not assume batch-settlement is available unless its kind is returned.
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
POST /verifyPOST /settleExact same bodyFor Exact, never modify the signed amount, recipient, nonce or authorization window. Do not regenerate authorization between /verify and /settle. paymentPayload.accepted must equal paymentRequirements.
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 Sepolia
XAPG implements the capital-backed x402 batch-settlement scheme with an EVM payment channel. The payer deposits test USDC once, signs cumulative offchain vouchers, and the receiver later claims and settles the accumulated amount. Exact remains available as a separate scheme.
Create and identify a channel
There is no channel-creation API or server-issued channelId. The payer and merchant agree the immutable ChannelConfig locally; use computeChannelId(channelConfig, "eip155:84532") from @x402/evm/batch-settlement/client. It is the EIP-712 hash of ChannelConfig bound to the network and batch contract domain. Changing any field or network creates a different ID. Persist the config and ID before signing; deposit funds the corresponding onchain channel.
payer owns the deposited USDC; payerAuthorizer signs cumulative vouchers; receiver is paymentRequirements.payTo; receiverAuthorizer authorizes claims and refunds. Use the merchant-approved receiverAuthorizer, not an arbitrary payer address. token is the Base Sepolia USDC address; withdrawDelay is seconds; salt is a chosen, persisted 32-byte value; choose a new salt for a separate channel with otherwise identical fields. The deposit helper signs ReceiveWithAuthorization, which differs from the Exact TransferWithAuthorization path.
Contract source and signing domains
SDK 2.25.0 exports BATCH_SETTLEMENT_ADDRESS and BATCH_SETTLEMENT_DOMAIN from @x402/evm. Its Batch address is 0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003, not a placeholder. computeChannelId uses those same constants internally. Import them for ClaimBatch and Refund signing instead of maintaining a second hard-coded domain. Keep the client and facilitator SDK versions aligned; a custom contract address cannot be supplied to this computeChannelId API.
In the current xAPG /supported response, settlementSpenders lists configured facilitator transaction-signing addresses. It is not the Batch contract address and must not be used as verifyingContract. Discovery confirms supported scheme/network/asset; it does not currently publish a Batch contract-domain field. Confirm the enabled network and matching SDK deployment with xAPG before signing; an SDK constant alone is not proof of a live deployment.
| 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_ADDRESS |
| Discovery adapter metadata (not EIP-712) | xapg-payment-adapters / 1 | None. This is a discovery schema version, not a signing domain. |
Both signing domains above use chainId 84532 for Base Sepolia. requirements.extra.name/version describe the USDC token domain, not the Batch domain or SDK package version. Deposit uses ReceiveWithAuthorization; Exact uses TransferWithAuthorization. Their recipient semantics differ even though both signatures use the USDC domain.
SDK availability and xAPG onboarding
@x402/evm is a public npm package; this guide targets version 2.25.0. You can obtain the SDK independently. The matching xAPG integration package, institution access and deployment confirmation are obtained from xAPG, so the complete xAPG onboarding flow is not self-serve. npm ci --legacy-peer-deps applies to the supplied xAPG package and its lockfile; it does not mean the SDK itself is private.
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. |
Batch · claim response
A claim request may contain 1–100 claims, but the contract executes the array atomically in one claimWithSignature transaction. The API therefore returns one result and one transaction hash for the whole batch; it does not return per-claim results or partial success.
{
"success": true,
"network": "eip155:84532",
"transaction": "0x...one transaction for the complete claim batch..."
}
Shared requirements
paymentPayload.accepted and paymentRequirements must contain identical values. Amounts are USDC atomic units (six decimals). Generate signatures and payloads with the matching @x402/evm batch-settlement client instead of hand-building typed data.
{
"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": "30000"
}
}
The same channel configuration must bind payer, payerAuthorizer, receiver/payTo, receiverAuthorizer, USDC token, withdrawDelay and salt. Deposit must cover at least one voucher and stay within the configured XAPG limit; confirm active limits with xAPG. The deposit transaction funds the channel but does not claim the accompanying voucher onchain; a separate claim is required.
Operation payload schema · SDK 2.25.0
The following shapes replace paymentPayload.payload. Address means a 20-byte hex address; bytes32 means 32-byte hex; signatures are signed hex values; all token amounts and nonces below are decimal strings. Only withdrawDelay and withdrawRequestedAt use JSON numbers. Keep paymentPayload.accepted identical to paymentRequirements.
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) in the Batch contract domain. Use signVoucher with the agreed channel ID and cumulative ceiling; this is not a new USDC transfer authorization. Persist the signed voucher and accepted cumulative charge before serving the request.
ClaimBatch signature
For claim, the voucher signature comes from payerAuthorizer; claimAuthorizerSignature signs the ClaimBatch with receiverAuthorizer and covers channelId, maxClaimableAmount and totalClaimed for each entry. totalClaimed is the new cumulative target, not an incremental charge. xAPG has no receiver authorizer signer configured in this path: callers must supply the authorizer signatures. /verify does not accept claim or settle operations.
Refund signature
Refund is an authorizer-approved return of unused deposit, not an automatic withdrawal after withdrawDelay. Read refundNonce from refund verification, reconcile the latest voucher and channel state, then sign Refund(channelId, nonce, amount) with receiverAuthorizer. Supply claims (possibly empty); nonempty claims also require claimAuthorizerSignature. The plain /verify refund body cannot be submitted unchanged to /settle. xAPG exposes no public requestWithdraw/finalizeWithdraw endpoints.
Complete settlement envelope · illustrative
This is a complete JSON request for the final receiver/token settlement after successful claims. The receiver address is illustrative; replace it with the approved merchant address and use the same agreed requirements. It is not an executed testnet fixture. A deposit of 30000 units (0.03 USDC), followed by vouchers with cumulative ceilings 10000 and 20000, can claim totalClaimed=20000, settle the receiver balance, and refund the unused 10000 with the required authorizer signature. Do not add the two voucher ceilings 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": "30000"
}
},
"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": "30000"
}
}
}
Lifecycle signing example · integration skeleton
This SDK 2.25.0 integration skeleton shows every payload and signature for the 0.03 / 0.02 / 0.01 USDC example above. It requires your signers, authenticated transport and durable storage; it has not been executed against testnet. Implement per-order/channel locking, receipt reconciliation between steps and recovery using the saved request/key before using it for service delivery. Calling lifecycle again creates a new channel and new operations: it is not a retry procedure.
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()}`;
await persist({ stage, channelId, channelConfig, request, idempotencyKey: key });
const result = await post("/settle", request, { "Idempotency-Key": key });
await persist({ stage, channelId, result });
if (!result.success) throw Error(result.errorReason);
await reconcile(stage, request, result);
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);
await submit("deposit", deposit.payload);
// 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 }] } });
await submit("claim", { type: "claim", claims, claimAuthorizerSignature });
await submit("settle", { type: "settle", receiver: requirements.payTo, token: requirements.asset });
// 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 });
}
Responses and state
POST /verify returns {isValid, payer?, invalidReason?, extra?, failure?}; success is isValid:true, not HTTP 200 alone. Successful verification exposes flat extra fields: channelId, balance, totalClaimed, withdrawRequestedAt and refundNonce. Deposit verification reads the pre-deposit balance; settlement responses may instead nest state under extra.channelState. Verification is a point-in-time read, not a reservation of funds or a durable record of service delivery.
POST /settle returns {success, network, transaction?, payer?, errorReason?, extra?, failure?}. The transaction is one hash for the submitted onchain operation. Optional extra fields depend on operation; do not assume payer or a per-channel amount is present for claim/settle. A rejected voucher settlement returns success:false and errorReason:"voucher_is_offchain_commitment". HTTP 200 can carry success:false; malformed requests may return HTTP 400. Keep failure.code, stage, retryable, settlementStatus and requestId for reconciliation.
Idempotency-Key
Every batch-settlement POST /settle request requires Idempotency-Key. Use 8–128 letters, numbers, period, underscore, colon or hyphen. Keep the same key when retrying the exact same operation body; use a new key for every different deposit, claim, settlement or refund. Reusing one key with different content is rejected.
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 backend owns durable order deduplication, per-channel locking, accepted cumulative amounts and the latest signed voucher. Accept each service charge only once; verify a new cumulative ceiling against the previous accepted amount and current balance. /verify alone does not prevent the same voucher from being used for repeated service delivery. Choose your own claim schedule; this API does not run a merchant accumulation queue or promise a settlement interval.
Current request limits: 1–100 claims; maxTimeoutSeconds 1–3600; withdrawDelay 900–2592000 seconds. Deposit must cover requirements.amount and optional minDeposit and remain within the configured deposit cap (default 1000000 atomic units, 1 USDC). requirements.amount also has a configured payment cap. These are server policy limits, not universal protocol constants; confirm active limits with xAPG. maxTimeoutSeconds is not a guarantee of settlement latency.
Persist request body, Idempotency-Key, channel IDs, accepted voucher ceilings and every returned transaction hash before advancing business state. For deposit reconcile payer-to-contract funding and channel balance; for claim reconcile each channel’s totalClaimed and receiver pending balance; for settle reconcile aggregate receiver USDC transfer; for refund reconcile channel nonce/balance and payer receipt. A claim receipt is not yet a merchant transfer. On timeout or unknown outcome, follow the retry policy below and reconcile the original operation before signing or submitting a replacement.
Exact + Batch · POST /verify
Validates an Exact authorization or a Batch deposit, voucher or refund payload without submitting an onchain transaction. Requires verify scope. Exact checks signatures, limits, replay protection and settlement simulation. Batch deposit checks include authorization and simulation; voucher/refund verification checks the commitment and current channel state, not merchant service deduplication.
{
"isValid": true,
"payer": "0x..."
}Success requires HTTP 200, isValid: true and the expected payer. /verify alone does not move funds and does not replace institutional approval. For Batch vouchers, persist channelId and the cumulative amount before granting service.
/verifyisValid: trueExact + Batch · POST /settle
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..."
}Confirm success: true and the expected network. Persist the returned transaction hash for onchain Batch operations, then reconcile the successful receipt, channel and matching USDC movement before recording completion.
Retry policy
Do not resubmit and do not sign a second authorization. Reconcile the original nonce and transaction first.
You may resubmit only the byte-for-byte identical operation body with the original Idempotency-Key. First reconcile an uncertain claim, settle or refund using the original transaction hash and chain state; automatic pending-transaction recovery is implemented only for deposits. An identical retry does not guarantee recovery of a lost success response for other operations. A changed body or a new key is a new operation and must wait for reconciliation.
Exact + Batch · errors
Both /verify and /settle keep their protocol fields and also return the same safe failure object. Use failure.code for handling, failure.message for debugging, requestId when contacting xAPG, and settlementStatus to decide whether reconciliation is required.
{
"failure": {
"code": "invalid_signature",
"stage": "authorization_validation",
"message": "The wallet signature could not be recovered or validated.",
"retryable": false,
"settlementStatus": "not_submitted",
"requestId": "..."
}
}
Public failure code matrix
| failure.code | retryable | settlementStatus | Meaning / action |
|---|---|---|---|
invalid_payment_payloadinvalid_permit2_payloadinvalid_signaturesigner_mismatch | false | not_submitted | Correct the payload, domain or signer; do not submit settlement. |
insufficient_funds | false | not_submitted | Fund the payer, then create and verify a new authorization. |
authorization_expiredauthorization_not_yet_validauthorization_too_close_to_expiry | false | not_submitted | 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 | failed | Stop and reconcile with the requestId and any known transaction hash. |
verification_failedunexpected_provider_error | false | not_submitted / failed | Do not guess the outcome; inspect stage and settlementStatus, then contact xAPG with requestId. |
rpc_rate_limitedrpc_unavailable | true | not_submitted / failed | 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 means the institution key, environment or scope is wrong. HTTP 429 means rate limiting; respect Retry-After when present. HTTP status alone never determines whether settlement occurred—use failure.settlementStatus.
Production & Security
Production signer
Testnet
.env private key → viem signer
Production
Institution Backend
↓
HSM / MPC / KMS / Wallet Service
↓
EIP-712 Signature
↓
xAPGReplace the local test signer with your institutional adapter, not by exporting a production key. The quick start is pinned to Base Sepolia; changing constants is not a production rollout. Validate the live capability, policy and signer together before launch.
Keep .env backend-only, set chmod 600 .env, and exclude .env and payment-attempt*/ from version control. Use an editor or secret manager rather than putting keys in shell history. Never expose API keys or live signatures in logs, chat or screenshots.
Contact xAPG for the matching integration package and institution access. For the supplied package, install with npm ci --legacy-peer-deps.
Exact · settlement runner
Settlement is intentionally separate from the POC. After institutional approval, use the supplied scripts/institution-quickstart.mjs with a key that has settle scope.
node --env-file=.env scripts/institution-quickstart.mjs --settle --confirm-settlement BASE_SEPOLIA --out-dir payment-attempt-001
The runner submits the verified body once, waits for the matching USDC receipt and writes reconciliation records. It is not a retry command.
Exact + Batch · settlement reconciliation
The filenames below are Exact runner artifacts. Batch integrations must implement equivalent durable records themselves; the facilitator does not write these files for Batch clients.
| 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 |
An existing output directory blocks submission; never bypass it after an uncertain outcome. Missing submission.json or receipt.json is not proof of failure. For timeout handling, use the single Retry policy above.
Implement durable per-order locking and budget reservation before signing, including across concurrent workers. The Exact sample has directory collision protection, not business-order deduplication. No public order-status API is currently provided.
Advanced Signing & Limitations
This path uses EOA recovery with a 65-byte hex r || s || v EIP-712 signature; personal_sign is not interchangeable. EIP-1271 and contract-wallet signatures are not supported by this recovery path. Confirm your institutional service can produce the required signature.
Order IDs and resource URLs are not EIP-3009 signed fields. Bind them to the approved requirements and nonce in your backend. The example proves verification/settlement only; it does not verify merchant or resource delivery.
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 |
| Audit records | — |
| Merchant/resource delivery verification | — |
Acceptance: verify-only makes no transfer; one approved settlement produces the correct USDC transfer and receipt; bad input is rejected; an unknown outcome blocks another payment for the same order.
Offline example tests do not replace joint acceptance with your real institutional signer.