Build an x402 batch-settlement client
OneSource advertises a batch-settlement scheme on every /api/chain/* route (see
x402 on Base → Batch settlement
for how it works and when it pays off). The common x402 clients, x402-fetch,
AgentCash, the Coinbase agentic wallet (awal), only implement the exact scheme,
so they ignore the batch challenge and keep paying per call. To actually batch, you need a
client that implements the scheme: open a channel with one deposit, sign an off-chain
voucher per call, and let OneSource settle the cumulative total in a single on-chain claim.
This guide is that client, end to end. It's the reference implementation we use to
validate the feature against mainnet: roughly 60 lines of TypeScript on top of the
official @x402 SDK. Copy it, point it at your wallet, and run.
If you just want batch from an AI assistant, the
OneSource MCP Server's batch mode
does it with one env var (X402_PAYMENT_MODE=batch): no client code. This guide is for
building a custom client or wiring batch into your own app.
Either way, batch only earns its keep for a burst of calls from one long-lived process: the channel state (deposit, cumulative voucher) has to persist across calls. For a handful of one-off requests, plain per-call x402 is simpler and cheaper. See Batching and cost optimization to decide.
Prerequisites
- Node.js ≥ 24.
- A wallet funded with USDC on Base (mainnet asset
0x833589…). The first call depositsprice × depositMultiplierinto the channel escrow, so fund a little above one call's price. Per-call prices are on each API Reference page. - The wallet's private key. Treat it like any secret: load it from the environment, never commit it.
Project setup
Create a directory with three files.
package.json
{
"name": "onesource-x402-batch-client",
"private": true,
"type": "module",
"scripts": {
"start": "tsx index.ts"
},
"dependencies": {
"@x402/evm": "2.14.0",
"@x402/fetch": "2.14.0",
"dotenv": "^16.4.7",
"viem": "^2.48.11"
},
"devDependencies": {
"tsx": "^4.21.0",
"typescript": "^5.7.3"
}
}
The batch scheme lives in @x402/evm (the scoped SDK family), not the legacy
x402-fetch package; that one is exact-only.
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["index.ts"]
}
.env
EVM_PRIVATE_KEY=0x… # funded Base wallet (the payer)
RESOURCE_SERVER_URL=https://api.onesource.io
ENDPOINT_PATH=/api/chain/block-number # any priced /api/chain/* route
NUMBER_OF_REQUESTS=3
DEPOSIT_MULTIPLIER=5 # deposit = price × this on channel open
STORAGE_DIR=./channel-storage # persist channel state across runs
# CHANNEL_SALT=0x… # bump for a fresh channel (see below)
# REFUND_AFTER_REQUESTS=true # reclaim unused deposit at the end
Then npm install.
The client
index.ts
import { toClientEvmSigner } from '@x402/evm';
import { BatchSettlementEvmScheme } from '@x402/evm/batch-settlement/client';
import { FileClientChannelStorage } from '@x402/evm/batch-settlement/client/file-storage';
import { x402Client, wrapFetchWithPayment, x402HTTPClient } from '@x402/fetch';
import { config } from 'dotenv';
import { createPublicClient, http } from 'viem';
import { base } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';
config();
const privateKey = process.env.EVM_PRIVATE_KEY?.trim() as `0x${string}`;
if (!privateKey) throw new Error('EVM_PRIVATE_KEY is required');
const baseURL = process.env.RESOURCE_SERVER_URL ?? 'https://api.onesource.io';
const endpointPath = process.env.ENDPOINT_PATH ?? '/api/chain/block-number';
const url = `${baseURL}${endpointPath}`;
const storageDir = process.env.STORAGE_DIR;
const channelSalt = (process.env.CHANNEL_SALT ?? `0x${'0'.repeat(64)}`) as `0x${string}`;
const numberOfRequests = Number(process.env.NUMBER_OF_REQUESTS ?? '3');
const depositMultiplier = Number(process.env.DEPOSIT_MULTIPLIER ?? '5');
const refundAtEnd = process.env.REFUND_AFTER_REQUESTS === 'true';
async function main(): Promise<void> {
const account = privateKeyToAccount(privateKey);
// The public client lets the scheme read chain state and submit the deposit.
// Pass a URL to http() to use your own RPC instead of the public default.
const publicClient = createPublicClient({ chain: base, transport: http() });
const signer = toClientEvmSigner(account, publicClient);
const scheme = new BatchSettlementEvmScheme(signer, {
depositPolicy: { depositMultiplier },
salt: channelSalt,
// Persist channel + voucher state to disk so a later run reuses the same
// channel instead of opening (and funding) a new one.
...(storageDir ? { storage: new FileClientChannelStorage({ directory: storageDir }) } : {}),
});
const client = new x402Client();
client.register('eip155:*', scheme);
const fetchWithPayment = wrapFetchWithPayment(fetch, client);
const httpClient = new x402HTTPClient(client);
console.log(`payer ${signer.address} → ${url}\n`);
for (let i = 0; i < numberOfRequests; i++) {
const res = await fetchWithPayment(url, { method: 'GET' });
const result = await httpClient.processResponse(res);
if (result.kind === 'success') {
console.log(`Request ${i + 1}:`, result.body);
// settleResponse.transaction is the on-chain claim hash on the call that
// triggers a settlement; off-chain voucher-only calls have it empty.
console.log(' settle:', JSON.stringify(result.settleResponse));
} else {
console.log(`Request ${i + 1} - ${result.kind}:`, JSON.stringify(result));
}
}
if (refundAtEnd) {
console.log('\nRequesting refund of the unused channel balance…');
console.log(JSON.stringify(await scheme.refund(url), null, 2));
}
}
main().catch((err) => {
console.error(err?.response?.data?.error ?? err);
process.exit(1);
});
Run it:
npm start
What happens on each run
- First call: channel open. The scheme reads the
402, sees thebatch-settlementchallenge, and depositsprice × DEPOSIT_MULTIPLIERUSDC into the escrow contract on Base (one on-chain transaction). It signs a voucher for this call's amount and retries; OneSource serves the response. - Subsequent calls: off-chain vouchers. Each call signs a new voucher for the
cumulative amount and sends it in the payment header. No on-chain transaction:
settleResponse.transactionis empty for these. - Settlement: one claim. OneSource (the channel authorizer) redeems accrued vouchers in a single on-chain claim on a short interval, transferring the cumulative total from escrow to the receiver.
So N calls cost one deposit + one claim in gas instead of N settlements. With the example's 3 calls you'll see the deposit tx on call 1, empty settle on calls 2–3, and the cumulative total claimed in one transaction. Verify any hash on basescan.org.
Channel lifecycle
- Persistent storage.
STORAGE_DIRkeeps the channel record on disk. A later run with the same wallet + sameCHANNEL_SALTreuses the open channel: no second deposit. This is why batch only makes sense in a long-lived process or across runs that share that directory; a fresh process with no stored state opens a new channel every time (worst of both worlds). - A fresh channel. Change
CHANNEL_SALTto any new 32-byte hex value to open a separate channel under the same wallet (useful when the previous one is drained or pending withdrawal). - The unused deposit. Because you deposit
price × multiplier, a residual stays locked in escrow after your calls. SetREFUND_AFTER_REQUESTS=trueto callscheme.refund(url)and reclaim it, but note the on-chain withdraw delay (~1 day on mainnet) before the funds are releasable. LowerDEPOSIT_MULTIPLIERto shrink the residual, at the cost of re-depositing sooner if the burst runs long.
Any unclaimed-but-deposited balance is locked until the withdraw delay elapses. Don't open a large channel for a workload you might abandon; size the deposit to the burst.
Caveats
- The two batch-capable paths today are this custom client and the
OneSource MCP Server's batch mode.
Standard third-party agent wallets (
x402-fetch, AgentCash, Coinbaseawal) are exact-only and will silently pay per call against the same endpoints; that's fine, just not batched. - The scheme is published in TypeScript and Go. This guide is the TypeScript path; the Go SDK exposes the equivalent batch-settlement client.
- For the protocol-level contract (authorizer model, voucher format, withdraw mechanics), see the x402 batch-settlement spec.
Next
- x402 on Base: the per-call scheme and the conceptual batch overview
- Batching and cost optimization: when batching beats other savings
- MPP on Tempo: the same amortization model on Tempo