Blog

How an Agent Buys a Page: The x402 Loop, End to End

· NanoParse team

Most endpoints that want money answer in a way software can't use. A login redirect. A CAPTCHA. A pricing page written for a human with a credit card. The agent gets nothing it can act on, so it stops — and the seller never learns that a buyer showed up and left.

An x402 endpoint answers differently: HTTP 402 with machine-readable terms. What the thing costs, which asset, which network, who gets paid, and what a valid signature has to look like. Everything needed to decide, sign, and retry is in the response body.

NanoParse prices a rendered page at $0.005 USDC on Base, paid per parse. The first 10 parses per network (subnet) are free, lifetime, with no account. After that, the 402 is the quote. Here is what it contains, the payload a client has to build, and the errors worth branching on.

The 402 body, trimmed to the fields that carry the weight

Captured 2026-10-06 with the free bucket exhausted on this network:

{
  "error": "payment_required",
  "error_code": "free_tier_exhausted",
  "free_calls_used": 10,
  "free_calls_limit": 10,
  "payment_required": {
    "x402Version": 2,
    "accepts": [
      {
        "scheme": "exact",
        "network": "eip155:8453",
        "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
        "amount": "5000",
        "payTo": "0x57a1d06873db575baeae1f6af30862a75c7bd570",
        "maxTimeoutSeconds": 60,
        "extra": {
          "assetTransferMethod": "eip3009",
          "name": "USD Coin",
          "version": "2"
        }
      }
    ]
  }
}

The same terms travel base64url-encoded in the payment-required response header, and the recipient is repeated as a CAIP-style pay-to: ethereum:base:0x… header. CORS exposes Content-Type, Payment-Signature, Authorization on this route, so a browser-side agent can read the response.

Four fields carry the weight:

The payload a client has to build

The signed envelope is small and fully determined by the 402:

{
  "x402Version": 2,
  "accepted": { "...": "the accepts[0] object, echoed verbatim" },
  "payload": {
    "signature": "0x…130 hex chars…",
    "authorization": {
      "from": "0x…",
      "to": "0x…",
      "value": "5000",
      "validAfter": "0",
      "validBefore": "1758840000",
      "nonce": "0x…64 hex chars…"
    }
  }
}

Shape rules, enforced before the facilitator is ever called. A wrong shape costs nothing — it answers 400:

A malformed header looks like this, captured live:

POST https://nanoparse.app/fetch
Payment-Signature: not-base64-at-all

HTTP/2 400
{"error":"payment_failed","error_code":"invalid_payment_payload",
 "message":"PAYMENT-SIGNATURE must be a base64url-encoded x402 v2 payment payload."}

No content, no charge, no ambiguity about which side of the transaction failed.

A client that pays its own way

About 60 lines of TypeScript with viem, no SDK and no server-side key:

// read-page.ts — fetch a page, pay for it if asked, return the parse envelope.
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";

const ENDPOINT = "https://nanoparse.app/fetch";
const b64url = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url");

export async function readPage(url: string, key: `0x${string}`) {
  const account = privateKeyToAccount(key);
  const wallet = createWalletClient({ account, chain: base, transport: http() });
  const post = (headers: Record<string, string> = {}) =>
    fetch(ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/json", ...headers },
      body: JSON.stringify({ url }),
    });

  const first = await post();
  if (first.status !== 402) return first.json();   // free tier, or an error to handle

  const terms = (await first.json()).payment_required.accepts[0];
  const now = Math.floor(Date.now() / 1000);
  const nonce = `0x${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex")}`;

  // EIP-3009 transferWithAuthorization. The EIP-712 domain is the token
  // contract's, not the service's — take it from terms.extra, never hardcode it.
  const signature = await wallet.signTypedData({
    account,
    domain: {
      name: terms.extra.name,          // "USD Coin"
      version: terms.extra.version,    // "2"
      chainId: base.id,                // 8453
      verifyingContract: terms.asset,  // USDC on Base
    },
    types: {
      TransferWithAuthorization: [
        { name: "from", type: "address" },
        { name: "to", type: "address" },
        { name: "value", type: "uint256" },
        { name: "validAfter", type: "uint256" },
        { name: "validBefore", type: "uint256" },
        { name: "nonce", type: "bytes32" },
      ],
    },
    primaryType: "TransferWithAuthorization",
    message: {
      from: account.address,
      to: terms.payTo,
      value: BigInt(terms.amount),
      validAfter: 0n,
      validBefore: BigInt(now + 900),
      nonce,
    },
  });

  const payment = {
    x402Version: 2,
    accepted: terms,                   // echoed verbatim from the 402
    payload: {
      signature,
      authorization: {
        from: account.address,
        to: terms.payTo,
        value: terms.amount,           // "5000"
        validAfter: "0",
        validBefore: String(now + 900),
        nonce,
      },
    },
  };

  const paid = await post({ "Payment-Signature": b64url(payment) });
  if (!paid.ok) {
    const err = await paid.json();
    throw new Error(`payment failed: ${err.error_code} — ${err.message}`);
  }
  return paid.json();                  // { markdown, metadata, litmus }
}

Three things decide whether that works on the first try: the domain comes from terms.extra, the authorization block repeats the exact values the signature covers, and the payload goes back base64url-encoded in the Payment-Signature header. (Outside Node, swap Buffer for the base64url helper in the runtime — btoa plus a character swap does it.)

What happens between the retry and the content

  1. Shape validation. No network calls, no charge.
  2. Signature verification through the CDP Facilitator — free to the seller, and no private key sits on the server side of this transaction.
  3. A balance pre-check on the payer, so an unfunded wallet gets insufficient_usdc instead of an opaque contract revert.
  4. A payment lock keyed to the signature plus the URL, so a retried request during a slow render cannot be charged twice.
  5. Render. Then settle — the charge lands on-chain only after the page was read successfully.
  6. If settlement cannot be confirmed, no content is delivered, and the error says so explicitly.

A request with a permit that was already settled is rejected as a reused authorization. The nonce is spent on-chain, which is exactly what makes "retry the same payload" a safe instruction rather than a hopeful one.

Failed reads are never charged — structurally

There is no refund path, because nothing is charged up front. Settlement runs after a successful render; a render that fails, times out, or lands on a bot wall settles nothing and returns no content. The buyer's exposure between signing and content is zero, and the seller's cost is a browser-second.

The error codes worth branching on

Every failure returns error_code and message; none of them is a bare status line.

That list is a client's whole retry policy. Two codes are terminal, one is idempotent, the rest mean "sign again".

MCP clients pay with an argument

Not every MCP client can set custom headers. The tool takes the payment as an argument instead — as served by tools/list today:

> nanoparse_fetch(url, payment_signature) — "when the tool returns a payment_required error, sign the included payment terms and retry with the payment_signature argument (works in every MCP client; no custom headers needed)."

So the loop collapses to: call the tool, read the payment_required payload out of the error, sign it, pass it back as payment_signature. nanoparse_status(address) reports quota, wallet balance, and whether the next call needs payment — worth calling before an agent plans a hundred-page run.

Cost, and the absence of a subscription

Getting started

Point an MCP client at the hosted endpoint:

{
  "mcpServers": {
    "nanoparse": {
      "url": "https://nanoparse.app/mcp"
    }
  }
}

REST clients POST a URL to https://nanoparse.app/fetch, follow the returned statusUrl, and read the same envelope — {markdown, metadata, litmus} — from GET /status?jobId=….

Docs, response schemas, and the live demo: nanoparse.app · quickstart · MCP server repo

← All posts NanoParse home