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:
amountis micro-USDC —"5000"is $0.005 at six decimals. There is no currency-formatted string to parse, and no rounding to argue about.networkis a CAIP-2 chain id (8453is Base) andassetis the USDC contract on that chain, so the client never has to guess which USDC.extra.nameandextra.versionare the EIP-712 domain of the token contract:"USD Coin"v2. Signing against"USDC"instead produces a signature that fails verification, because the domain is part of the digest — a detail worth pinning before anything else works.payTois the only address a permit should ever be signed for. A payload pointing somewhere else is rejected before any signature is checked.
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:
signatureis0xplus 130 hex characters (65-byte packedr‖s‖v).authorization.nonceis0xplus 64 hex characters. It is single-use on-chain, which is what makes retries safe.authorization.valuemust equalamountexactly.validAftermust be in the past andvalidBeforein the future — an expired permit is rejected aspermit_expiredwith a message telling the client to sign a fresh one.acceptedrepeats the terms verbatim: scheme, network, asset, amount,payTo.
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
- Shape validation. No network calls, no charge.
- Signature verification through the CDP Facilitator — free to the seller, and no private key sits on the server side of this transaction.
- A balance pre-check on the payer, so an unfunded wallet gets
insufficient_usdcinstead of an opaque contract revert. - A payment lock keyed to the signature plus the URL, so a retried request during a slow render cannot be charged twice.
- Render. Then settle — the charge lands on-chain only after the page was read successfully.
- 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.
insufficient_usdc— the payer's balance is short. Fund the wallet and retry; the permit is still valid untilvalidBefore.permit_expired/permit_not_yet_valid— sign a fresh permit. Do not reuse a dead one.invalid_payment/invalid_payment_payload— envelope or signature rejected. Fix the payload; retrying it unchanged cannot succeed.screening_declined— the facilitator's compliance screening declined the payment. Do not retry.self_send_not_allowed— payer and recipient are the same address.amount_too_low— below the facilitator's minimum.location_blocked— a jurisdiction the facilitator does not serve.settlement_failed— settlement could not be confirmed on-chain. Retry with the same payload; it is idempotent.
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
- 10 free parses per network (subnet), lifetime. No account, no API key, no signup form.
- $0.005 per successful parse after that, USDC on Base, flat. No format surcharges, no per-field extract fees, no plan to pick.
- Nothing monthly, no minimum, no reset date. The bill is pages read, not seats or months.
- Failed or blocked reads are never charged.
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