Skip to main content

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.

Prefer not to write code?

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 deposits price × depositMultiplier into 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

  1. First call: channel open. The scheme reads the 402, sees the batch-settlement challenge, and deposits price × DEPOSIT_MULTIPLIER USDC 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.
  2. 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.transaction is empty for these.
  3. 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_DIR keeps the channel record on disk. A later run with the same wallet + same CHANNEL_SALT reuses 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_SALT to 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. Set REFUND_AFTER_REQUESTS=true to call scheme.refund(url) and reclaim it, but note the on-chain withdraw delay (~1 day on mainnet) before the funds are releasable. Lower DEPOSIT_MULTIPLIER to shrink the residual, at the cost of re-depositing sooner if the burst runs long.
Switching away mid-channel

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, Coinbase awal) 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