402tools402 docs
docs · live

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

bash
bun add viem
# or
npm install viem

#Minimal pay-an-endpoint client

ts
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.

ts
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 :

ts
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.