Skip to main content

Paid quotes with x402

Get CoW Protocol quotes from a script or agent without an API key. Deposit tokens into a prepaid channel, then authorise each quote with an off-chain signature. The deposit funds future requests; it is not an extra quote fee. CoW collects the charges in batches.

Get a quote​

This example uses Node.js 22 and USDC on Base to buy an Ethereum quote. The payment and quote chains are independent. It returns a quote; it does not execute a swap. Browser paid retries are not supported.

1. Check the terms​

Save inspect.mjs and run node inspect.mjs. It shows the current price, minimum deposit and withdrawal delay without a key, signature or payment. Fund your wallet with enough Base USDC if you accept the terms.

Read-only terms preview
inspect.mjs
// inspect.mjs — read-only; no private key, SDK, signature or payment.
const response = await fetch("https://x402.cow.fi/mainnet/api/v1/quote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const header = response.headers.get("PAYMENT-REQUIRED");
if (response.status !== 402 || !header) throw new Error("No payment challenge received");
const challenge = JSON.parse(Buffer.from(header, "base64").toString("utf8"));
const option = challenge.accepts.find(r =>
r.scheme === "batch-settlement" && r.network === "eip155:8453" &&
r.asset.toLowerCase() === "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
);
if (!option) throw new Error("Base USDC is not currently offered");
function usdc(atomic) {
if (typeof atomic !== "string" || !/^\d+$/.test(atomic)) throw new Error("Invalid amount");
const digits = BigInt(atomic).toString().padStart(7, "0");
return (digits.slice(0, -6) + "." + digits.slice(-6)).replace(/\.?0+$/, "") + " USDC";
}
console.table({
payment: "USDC on Base",
quotePrice: usdc(option.amount),
minimumDepositOrTopUp: usdc(option.extra.minDeposit),
newChannelWithdrawalDelay: option.extra.withdrawDelay / 3600 + " hours",
depositAuthorizationValidity: option.maxTimeoutSeconds + " seconds",
});

For illustration, a 1 USDC deposit at 0.001 USDC per quote funds 1,000 quotes while that price is unchanged. Always read current terms from the service.

2. Set up the example​

mkdir x402-quickstart
cd x402-quickstart
npm init -y
npm install @x402/fetch@2.26.0 @x402/evm@2.26.0 @x402/core@2.26.0 viem
npm install --save-dev tsx
umask 077
touch .env
chmod 600 .env

Edit .env locally with your payer key and a Base RPC URL:

PRIVATE_KEY=YOUR_PAYER_PRIVATE_KEY
RPC_URL=YOUR_BASE_RPC_URL

Keep .env, x402-state/, x402-channels/ and x402-attempts/ out of source control. Saved signed attempts are sensitive too.

The example defaults to 0.001 USDC per quote, 1 USDC per deposit or top-up, and a 24-hour maximum accepted withdrawal delay. These are client limits, not guaranteed server terms. If an offer exceeds them, the script stops before signing.

Optional limits
# Optional overrides; these are the example's defaults.
MAX_QUOTE_USDC=0.001
MAX_DEPOSIT_USDC=1
MAX_WITHDRAW_DELAY_HOURS=24

MAX_WITHDRAW_DELAY_HOURS only limits which offers your client accepts. It does not choose the waiting period: a new channel takes the delay advertised by the server, and keeps that delay for its lifetime. You can omit this setting; the example accepts delays up to 24 hours.

Enter price and deposit limits in human USDC units. A per-deposit limit applies to each top-up; it is not a lifetime spending cap.

3. Request a quote​

Save quote.mts in the project directory. It checks payment terms, uses SDK receipt validation and saves channel state and recovery files. The example uses @x402/* 2.26.0.

Complete quote example
quote.mts
import { randomUUID } from "node:crypto";
import { decodePaymentRequiredHeader, decodePaymentResponseHeader } from "@x402/core/http";
import { mkdirSync, writeFileSync } from "node:fs";
import { x402Client, wrapFetchWithPayment } from "@x402/fetch";
import { BatchSettlementEvmScheme, computeChannelId } from "@x402/evm/batch-settlement/client";
import { FileClientChannelStorage } from "@x402/evm/batch-settlement/client/file-storage";
import { privateKeyToAccount } from "viem/accounts";
import { createPublicClient, formatUnits, http, parseUnits } from "viem";
import { base } from "viem/chains";
import { toClientEvmSigner } from "@x402/evm";

const NETWORK = "eip155:8453";
const ASSET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const PAY_TO = "0xaa0a2b5E68351C46474f4CE142A1357e25EedBA9";
const RECEIVER_AUTHORIZER = "0x0DA62b1f9904AB97be179579A32533210d7ebBA5";
function usdcLimit(name: string, fallback: string) {
const value = process.env[name] ?? fallback;
if (!/^\d+(\.\d{1,6})?$/.test(value)) throw new Error(`Set ${name} in USDC (up to 6 decimals)`);
return parseUnits(value, 6);
}
const maxQuote = usdcLimit("MAX_QUOTE_USDC", "0.001");
const maxDeposit = usdcLimit("MAX_DEPOSIT_USDC", "1");
const maxDelay = Number(process.env.MAX_WITHDRAW_DELAY_HOURS ?? "24") * 3600;
if (maxQuote <= 0n || maxDeposit < maxQuote || !Number.isSafeInteger(maxDelay) || maxDelay <= 0) {
throw new Error("Set your quote, deposit and withdrawal-delay limits");
}

if (!process.env.RPC_URL) throw new Error("Set RPC_URL for Base");
if (!/^0x[0-9a-fA-F]{64}$/.test(process.env.PRIVATE_KEY ?? "")) throw new Error("Set PRIVATE_KEY securely in .env");
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const publicClient = createPublicClient({ chain: base, transport: http(process.env.RPC_URL) });
if (await publicClient.getChainId() !== base.id) throw new Error("RPC_URL points to the wrong chain");
const signer = toClientEvmSigner(account, publicClient);
const client = new x402Client().register(
NETWORK,
new BatchSettlementEvmScheme(signer, {
storage: new FileClientChannelStorage({ directory: "./x402-state" }),
depositStrategy: ({ depositAmount }) => {
if (BigInt(depositAmount) > maxDeposit) throw new Error("Deposit exceeds your limit");
return depositAmount;
},
}),
);
// Sign only the terms you expect.
client.registerPolicy((_version, requirements) =>
requirements.filter(
(r) =>
r.scheme === "batch-settlement" &&
r.network === NETWORK &&
r.asset.toLowerCase() === ASSET.toLowerCase() &&
r.payTo.toLowerCase() === PAY_TO.toLowerCase() &&
typeof r.extra?.receiverAuthorizer === "string" &&
r.extra.receiverAuthorizer.toLowerCase() === RECEIVER_AUTHORIZER.toLowerCase() &&
BigInt(r.amount) > 0n && BigInt(r.amount) <= maxQuote &&
typeof r.extra?.minDeposit === "string" &&
/^\d+$/.test(r.extra.minDeposit) &&
BigInt(r.extra.minDeposit) > 0n && BigInt(r.extra.minDeposit) <= maxDeposit &&
typeof r.extra?.withdrawDelay === "number" &&
Number.isSafeInteger(r.extra.withdrawDelay) &&
r.extra.withdrawDelay > 0 && r.extra.withdrawDelay <= maxDelay,
),
);
// Allow the selected token; the policy caps quote prices and withdrawal delay.
// depositStrategy checks the actual deposit amount before signing it.
client.setSpendControls({
allowedAssets: [{ network: NETWORK, asset: ASSET, maxAmountPerPayment: maxDeposit.toString() }],
});
// Save the original configuration and signed attempt before sending.
let latestAttemptFile: string | undefined;
let latestChannelFile: string | undefined;
client.onAfterPaymentCreation(async ({ paymentPayload }) => {
const config = paymentPayload.payload.channelConfig as Parameters<typeof computeChannelId>[0];
const network = paymentPayload.accepted.network;
const channelId = computeChannelId(config, network);
mkdirSync("./x402-channels", { recursive: true, mode: 0o700 });
mkdirSync("./x402-attempts", { recursive: true, mode: 0o700 });
latestAttemptFile = `./x402-attempts/${randomUUID()}.json`;
writeFileSync(latestAttemptFile, JSON.stringify(paymentPayload), { mode: 0o600, flag: "wx" });
latestChannelFile = `./x402-channels/${channelId}.json`;
writeFileSync(latestChannelFile, JSON.stringify({ network, channelId, channelConfig: config }, null, 2), { mode: 0o600 });
});
const paidFetch = wrapFetchWithPayment(fetch, client);

try {
const res = await paidFetch("https://x402.cow.fi/mainnet/api/v1/quote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sellToken: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH
buyToken: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
from: "0x0000000000000000000000000000000000000000",
receiver: "0x0000000000000000000000000000000000000000",
kind: "sell",
sellAmountBeforeFee: "1000000000000000000",
}),
});
const requiredHeader = res.headers.get("PAYMENT-REQUIRED");
const responseHeader = res.headers.get("PAYMENT-RESPONSE");
const challenge = requiredHeader ? decodePaymentRequiredHeader(requiredHeader) : undefined;
const receipt = responseHeader ? decodePaymentResponseHeader(responseHeader) : undefined;
const body = await res.text();
if (res.status !== 200 || receipt?.success !== true) {
console.error({
status: res.status,
error: receipt?.errorReason ?? challenge?.error,
transaction: receipt?.transaction,
savedAttempt: latestAttemptFile,
body,
});
throw new Error("Payment outcome needs checking; follow Troubleshooting before retrying");
}
const result = JSON.parse(body);
if (!result.quote) throw new Error("Expected a quote in the successful response");
const charged = receipt.extra?.chargedAmount ?? "0";
if (typeof charged !== "string" || !/^\d+$/.test(charged)) throw new Error("Invalid receipt charge");
console.log({
status: res.status,
quoteReceived: true,
paymentNetwork: receipt.network,
chargedUSDC: formatUnits(BigInt(charged), 6),
channelFile: latestChannelFile,
});
console.log(result);
} catch (error) {
console.error({ savedAttempt: latestAttemptFile, message: "Keep state and check Troubleshooting before rerunning an unresolved attempt." });
throw error;
}
node --env-file=.env --import tsx quote.mts

A successful run prints status: 200, quoteReceived: true, the charge, a saved channel-file path and the quote JSON. The first request funds the channel; later requests reuse it and top up when needed, within your limits. CoW pays deposit and collection gas.

Run one request at a time per channel and keep the saved files across restarts.

Uncertain payment outcomes

If a request times out or returns a settlement error, keep the state, signed attempt and transaction hash. A charge or deposit may have succeeded even without a quote response. Do not repeatedly rerun it or create another deposit; check the original transaction and use the SDK’s validated recovery. Each new successful request can incur another charge. The API reference describes the payment response and recovery errors. Never reset your charged total to on-chain claims alone: they omit charges awaiting collection.

Withdraw unused funds​

You initiate a withdrawal, wait for the saved channel's deadline, then finalize it. These are two transactions, paid by your wallet in Base ETH for this example. Finalization returns tokens to the payer; it is not automatic.

Save withdraw.mts in the same project directory. In .env, set CHANNEL to the channel-file path printed by the quote example. Keep RPC_URL on that channel's payment chain.

CHANNEL=./x402-channels/YOUR_CHANNEL_ID.json
Complete withdrawal example
withdraw.mts
// withdraw.mts — status is read-only; initiate/finalize each send one transaction.
import { readFileSync } from "node:fs";
import { createPublicClient, createWalletClient, http, parseAbi } from "viem";
import { base, mainnet, bsc } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { computeChannelId } from "@x402/evm/batch-settlement/client";

const action = process.argv[2] ?? "status";
if (!["status", "initiate", "finalize"].includes(action)) throw new Error("Use status, initiate or finalize");
if (!process.env.CHANNEL || !process.env.RPC_URL) throw new Error("Set CHANNEL and RPC_URL");
const saved = JSON.parse(readFileSync(process.env.CHANNEL, "utf8"));
const { network, channelConfig: config } = saved;
const chain = [base, mainnet, bsc].find(c => network === `eip155:${c.id}`);
if (!chain) throw new Error("Unsupported payment chain");
const channelId = computeChannelId(config, network);
if (saved.channelId && saved.channelId.toLowerCase() !== channelId.toLowerCase()) throw new Error("Channel configuration mismatch");
const ESCROW: `0x${string}` = "0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003";
const abi = parseAbi([
"struct ChannelConfig { address payer; address payerAuthorizer; address receiver; address receiverAuthorizer; address token; uint40 withdrawDelay; bytes32 salt; }",
"function channels(bytes32 channelId) view returns (uint128 balance, uint128 totalClaimed)",
"function pendingWithdrawals(bytes32 channelId) view returns (uint128 amount, uint40 initiatedAt)",
"function initiateWithdraw(ChannelConfig config, uint128 amount)",
"function finalizeWithdraw(ChannelConfig config)",
]);
const publicClient = createPublicClient({ chain, transport: http(process.env.RPC_URL) });
if (await publicClient.getChainId() !== chain.id) throw new Error("RPC_URL points to the wrong chain");
const block = await publicClient.getBlock();
const [balance, totalClaimed] = await publicClient.readContract({ address: ESCROW, abi, functionName: "channels", args: [channelId], blockNumber: block.number });
const [requested, initiatedAt] = await publicClient.readContract({ address: ESCROW, abi, functionName: "pendingWithdrawals", args: [channelId], blockNumber: block.number });
const finalizeAt = initiatedAt ? BigInt(initiatedAt) + BigInt(config.withdrawDelay) : null;
console.log({ channelId, balanceAtomic: balance.toString(), claimedAtomic: totalClaimed.toString(), requestedAtomic: requested.toString(), finalizeAt: finalizeAt ? new Date(Number(finalizeAt) * 1000).toISOString() : null });

if (action !== "status") {
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
if (![config.payer, config.payerAuthorizer].some((a: string) => a.toLowerCase() === account.address.toLowerCase())) throw new Error("Wrong payer key");
const wallet = createWalletClient({ account, chain, transport: http(process.env.RPC_URL) });
if (action === "initiate") {
if (initiatedAt) throw new Error("Withdrawal already pending");
if (balance <= totalClaimed) throw new Error("Nothing remains to withdraw");
} else {
if (!finalizeAt || block.timestamp < finalizeAt) throw new Error("No pending withdrawal, or its on-chain delay has not elapsed");
}
const fees = await publicClient.estimateFeesPerGas({ type: "eip1559" });
const floor = chain.id === 8453 ? 1_000_000n : chain.id === 56 ? 50_000_000n : 100_000_000n;
const extraTip = fees.maxPriorityFeePerGas < floor ? floor - fees.maxPriorityFeePerGas : 0n;
const pricing = { maxPriorityFeePerGas: fees.maxPriorityFeePerGas + extraTip, maxFeePerGas: fees.maxFeePerGas + extraTip };
let hash: `0x${string}`;
if (action === "initiate") {
const call = { address: ESCROW, abi, functionName: "initiateWithdraw", args: [config, balance - totalClaimed], account } as const;
await publicClient.simulateContract(call);
hash = await wallet.writeContract({ ...call, ...pricing });
} else {
const call = { address: ESCROW, abi, functionName: "finalizeWithdraw", args: [config], account } as const;
await publicClient.simulateContract(call);
hash = await wallet.writeContract({ ...call, ...pricing });
}
console.log({ action, transaction: hash }); // Keep this hash if receipt waiting times out.
const receipt = await publicClient.waitForTransactionReceipt({ hash, timeout: 60_000 });
if (receipt.status !== "success") throw new Error(`Transaction reverted: ${hash}`);
console.log({ status: receipt.status, transaction: hash });
}

1. Check status without spending gas:

node --env-file=.env --import tsx withdraw.mts status

2. Stop using the channel and initiate the withdrawal:

node --env-file=.env --import tsx withdraw.mts initiate
node --env-file=.env --import tsx withdraw.mts status

3. Wait until finalizeAt, calculated from the on-chain initiation time and that channel's original delay. A new offer cannot change it.

4. Finalize, then check the receipt and your token balance:

node --env-file=.env --import tsx withdraw.mts finalize
node --env-file=.env --import tsx withdraw.mts status

CoW may collect outstanding charges during the wait, reducing the returned amount. Collection happens only when economic; uncollected fees do not extend the withdrawal deadline. A pending withdrawal cannot be cancelled. If a transaction times out, check its hash and status before sending another. Resolve uncertain payments before withdrawing.

Other payment options and help​

The current options are Base USDC/COW, Ethereum USDC and BNB USDC. COW and BNB USDC require an explicit SDK asset entry and Permit2 approval. Approval and withdrawal gas is paid in the payment chain's native token. COW amounts have no guaranteed dollar equivalent.

Use the live API reference for payment fields, networks and errors. Contact CoW Protocol Discord with your network, channel ID and transaction hash; never share keys or signed payments.