TypeScript
The TypeScript flow uses viem 2.x for the on-chain layer
and standard fetch for the HTTP layer. No tools402 SDK install required —
the whole client fits in one file.
#Install
bun add viem
# or
npm install viem#Minimal pay-an-endpoint client
import { createWalletClient, http, encodeFunctionData } from "viem";
import { base, polygon } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`;
const account = privateKeyToAccount(PRIVATE_KEY);
const USDC = {
base: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
polygon: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
} as const;
const wallet = (chain: "base" | "polygon") =>
createWalletClient({
account,
chain: chain === "base" ? base : polygon,
transport: http(),
});
const TRANSFER_ABI = [{
name: "transfer",
type: "function",
inputs: [{ name: "to", type: "address" }, { name: "value", type: "uint256" }],
outputs: [{ name: "ok", type: "bool" }],
}] as const;
interface QuoteEntry {
scheme: string;
network: string;
asset: string;
payTo: string;
maxAmountRequired: string;
maxTimeoutSeconds: number;
}
async function pay(endpoint: string, body: BodyInit, chain: "base" | "polygon" = "base") {
// 1 · ask
const r1 = await fetch(endpoint, { method: "POST", body });
if (r1.status !== 402) return r1; // already paid or no charge
const { accepts } = (await r1.json()) as { accepts: QuoteEntry[] };
const quote = accepts.find((a) => a.network === chain && a.scheme === "exact");
if (!quote) throw new Error(`No ${chain} quote available`);
// 2 · pay USDC on-chain
const hash = await wallet(chain).writeContract({
address: quote.asset as `0x${string}`,
abi: TRANSFER_ABI,
functionName: "transfer",
args: [quote.payTo as `0x${string}`, BigInt(quote.maxAmountRequired)],
});
// 3 · build X-Payment receipt (base64url JSON)
const xPayment = Buffer.from(
JSON.stringify({
x402Version: 1,
scheme: "exact",
network: chain,
payload: { tx_hash: hash },
}),
).toString("base64url");
// 4 · retry with receipt
return fetch(endpoint, {
method: "POST",
body,
headers: { "X-Payment": xPayment },
});
}
// Usage
const r = await pay(
"https://api.tools402.dev/v1/pdf-md",
new FormData([["file", new Blob([await Bun.file("receipt.pdf").arrayBuffer()])]]),
);
console.log(await r.json()); // { markdown: "# Receipt …" }#Production-grade with retry + confirmation wait
The minimal client above sends the X-Payment retry immediately. In practice you want to wait for the tx to confirm on-chain before retrying, otherwise the marketplace returns 402 again.
import { createPublicClient, http as publicHttp } from "viem";
import { base } from "viem/chains";
const publicClient = createPublicClient({ chain: base, transport: publicHttp() });
async function payWithConfirm(endpoint: string, body: BodyInit) {
const r1 = await fetch(endpoint, { method: "POST", body });
if (r1.status !== 402) return r1;
const { accepts } = (await r1.json()) as { accepts: QuoteEntry[] };
const quote = accepts[0];
const hash = await wallet("base").writeContract({
address: quote.asset as `0x${string}`,
abi: TRANSFER_ABI,
functionName: "transfer",
args: [quote.payTo as `0x${string}`, BigInt(quote.maxAmountRequired)],
});
// Wait for 1-block confirmation (~1.2 s on Base)
await publicClient.waitForTransactionReceipt({ hash, confirmations: 1 });
const xPayment = Buffer.from(
JSON.stringify({ x402Version: 1, scheme: "exact", network: "base",
payload: { tx_hash: hash } }),
).toString("base64url");
return fetch(endpoint, { method: "POST", body, headers: { "X-Payment": xPayment } });
}#EIP-3009 gasless variant
For an extra-clean UX, sign an EIP-3009 transferWithAuthorization off-chain
instead of doing a normal transfer(). The buyer never holds ETH for gas —
the facilitator pays it. See the
transfer-with-authorization scheme spec for the typed
data structure ; this matches the second accepts[] entry the marketplace
returns alongside the basic exact entry.
#Multi-chain selection
If you have USDC on multiple chains, pick the cheapest available :
const quote = accepts.find((a) => a.network === "polygon") // cheapest gas
?? accepts.find((a) => a.network === "solana") // fastest finality
?? accepts.find((a) => a.network === "base"); // fallback#Common errors
| HTTP | Body | Action |
|------|----------------------------------------|---------------------------------------------------------------|
| 402 | { "error": "X-PAYMENT header is required" } | Normal first response. Pay then retry. |
| 402 | { "error": "payment_unconfirmed" } | Retried too fast. Wait one block, retry same X-Payment. |
| 409 | { "error": "tx_already_consumed" } | This tx hash was already used. Pay again, build new receipt. |
| 502 | { "error": "upstream_5xx", "tx": "0x…" } | Seller's upstream failed after payment. Not refundable. Dispute with seller. |
Full error list : /reference/errors.