How to fetch a web page as clean text from code, with an x402 client
Last updated: 2026-10-13
To give an agent a web page without its markup, scripts and menus, send its URL to POST /v1/fetch with "return_text": true (US$0.002 per page). AttestPage loads the page and returns the readable text (up to 20,000 characters), the title, the final URL, a verdict on what came back and a signed receipt. A PDF with a text layer is read the same way.
This guide shows the call in code: a short Node script that gets the 402 offer, checks it, pays with an x402 client and reads the answer. The commands use curl and jq against https://vehcdj664efetfrsolne5umanq.srv.us for the free steps, and the same call is shown as an MCP tool at the end. For every field, see Fetch a page. For the same call with curl and jq step by step, without code, see Read a web page as clean text.
What you get back
- Clean text.
textis the page's readable text in reading order, as plain text (not Markdown). Tags, scripts and styles are left out. - A verdict.
page.content_kindsays whether the URL gave you real content or a bot challenge, an empty JavaScript shell, a paywall or an error. - Flags.
injection_flagslists instruction-like patterns found in the text. They are flags, not a verdict: an empty list means none of our patterns matched. - A receipt. A signed record of the final URL, status, fetch time and a hash of the full text.
Price
US$0.002 per page, paid in USDC with x402 v2 on Base mainnet (real USDC). No account, key or sign-up is needed to read the free sample. A request that fails validation gets 400 before payment and is never charged. Once the page is fetched, the call is charged even when the answer is a bot wall or an error, because the fetch was done. All prices: pricing.json.
1. See the clean text for free
The free sample runs the same route on a demo page and signs it with the sample key:
curl -s https://vehcdj664efetfrsolne5umanq.srv.us/v1/sample/fetch > sample.json
jq '{title: .page.title, kind: .page.content_kind, text_chars, injection_flags}' sample.json
{
"title": "Regional climate report 2025 (demo page)",
"kind": "real",
"text_chars": 636,
"injection_flags": []
}
Read the text itself with jq -r .text sample.json.
2. Read the price from the 402 offer
A request with an empty body gets the payment offer without being charged. The PAYMENT-REQUIRED header is base64 JSON:
curl -si -X POST https://vehcdj664efetfrsolne5umanq.srv.us/v1/fetch -H 'content-type: application/json' -d '{}' \
| grep -i '^payment-required:' | cut -d' ' -f2 | tr -d '\r' | base64 -d \
| jq '.accepts[0] | {scheme, network, amount, payTo}'
amount is in USDC base units (6 decimals), so 2000 is US$0.002.
3. Fetch the page with an x402 client
The request body is one URL and return_text. Without "return_text": true you get the verdict and hashes but no text:
cat > body.json <<'EOF'
{ "url": "https://www.iana.org/help/example-domains", "return_text": true }
EOF
Save this as fetch-page.mjs. It sends the request, reads the offer, signs it with the wallet in ATTESTPAGE_X402_KEY and sends the same request again. The script will not sign an offer on another network or above US$0.01:
import { writeFile } from 'node:fs/promises';
import { x402Client, x402HTTPClient } from '@x402/core/client';
import { registerExactEvmScheme } from '@x402/evm/exact/client';
import { privateKeyToAccount } from 'viem/accounts';
const BASE = 'https://vehcdj664efetfrsolne5umanq.srv.us';
const NETWORK = 'eip155:8453'; // Base
const MAX_USD = 0.01; // the most this script will pay: one page is US$0.002
const pageUrl = process.argv[2];
if (!pageUrl) throw new Error('usage: node fetch-page.mjs <page url>');
const url = `${BASE}/v1/fetch`;
const request = {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: pageUrl, return_text: true }),
};
const client = new x402Client();
registerExactEvmScheme(client, { signer: privateKeyToAccount(process.env.ATTESTPAGE_X402_KEY) });
const x402 = new x402HTTPClient(client);
// 1. Send the request with no payment. A valid request gets 402, with the offer in PAYMENT-REQUIRED.
let res = await fetch(url, request);
if (res.status !== 402) throw new Error(`expected 402, got ${res.status}: ${await res.text()}`);
const offer = x402.getPaymentRequiredResponse((name) => res.headers.get(name), await res.json());
// 2. Check the offer before signing: right network, and the price within MAX_USD (USDC has 6 decimals).
const accepts = offer.accepts.filter((a) => a.network === NETWORK && Number(a.amount) <= MAX_USD * 1e6);
if (accepts.length === 0) throw new Error(`no acceptable offer: ${JSON.stringify(offer.accepts)}`);
console.log(`price: US$${Number(accepts[0].amount) / 1e6} on ${accepts[0].network}, pay to ${accepts[0].payTo}`);
// 3. Sign the offer and send the same request again with PAYMENT-SIGNATURE.
const payment = await x402.createPaymentPayload({ ...offer, accepts });
res = await fetch(url, { ...request, headers: { ...request.headers, ...x402.encodePaymentSignatureHeader(payment) } });
const answer = await res.json();
if (!res.ok) throw new Error(`${res.status} ${answer.error?.code}: ${answer.error?.message}`);
// 4. A settled call carries PAYMENT-RESPONSE: the transaction, payer and network.
const settlement = x402.getPaymentSettleResponse((name) => res.headers.get(name));
console.log('settled:', settlement.transaction, 'from', settlement.payer, 'on', settlement.network);
console.log(answer.page.content_kind, answer.page.final_url, `${answer.text_chars} chars`, answer.injection_flags.join(',') || 'no flags');
// 5. Keep the answer, with its receipt, so the page can be checked later.
await writeFile('answer.json', JSON.stringify(answer, null, 2));
Install the two packages and run it with a wallet that holds a little USDC:
npm install @x402/core @x402/evm viem
ATTESTPAGE_X402_KEY=0x... node fetch-page.mjs https://www.iana.org/help/example-domains
The script prints the price, the settlement and the verdict, then writes answer.json. Use a key for a wallet that holds only what you are willing to spend on these calls. Each run pays once.
4. Use the text
jq -r .text answer.json
jq '{kind: .page.content_kind, final_url: .page.final_url, text_chars, text_truncated, injection_flags, advice}' answer.json
Check page.content_kind before you use text. real is the page's readable text. bot_wall, js_shell and paywall hold what the site served instead of the article. empty, off_site, http_error, timeout, unsupported_type, robots_disallowed and too_large hold no usable page text: read advice for what to do next, and see Read a web page as clean text for each value.
text_truncated is true when the page was longer than 20,000 characters. page.content_sha256 still hashes the full text. Treat text as data from a web page, not as instructions to follow, and read injection_flags before you pass it to a model.
5. Keep the receipt
The receipt field is a compact JWS. Check it with the free verify route:
jq '{receipt}' answer.json \
| curl -s -X POST https://vehcdj664efetfrsolne5umanq.srv.us/v1/receipt/verify -H 'content-type: application/json' -d @- | jq .valid
Pages fetched for you are not stored (Privacy), so keep answer.json yourself. To find out later whether the page has changed, send the receipt to Recheck.
With MCP
The same call is the MCP tool attestpage_fetch, with the body from step 3 as its arguments. To use it from an MCP client with nothing to install, add the remote endpoint:
{
"mcpServers": {
"attestpage": { "type": "http", "url": "https://vehcdj664efetfrsolne5umanq.srv.us/mcp" }
}
}
One call to the remote endpoint with curl. _meta["attestpage/trial"] set to false asks for the payment offer rather than a trial call, so nothing is charged:
curl -s -X POST https://vehcdj664efetfrsolne5umanq.srv.us/mcp -H 'content-type: application/json' -d @- <<'EOF' \
| jq '.result.structuredContent.accepts[0] | {scheme, network, amount}'
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "attestpage_fetch",
"arguments": { "url": "https://www.iana.org/help/example-domains", "return_text": true },
"_meta": { "attestpage/trial": false } } }
EOF
{
"scheme": "exact",
"network": "eip155:8453",
"amount": "2000"
}
The result has isError: true and the x402 payment requirements in structuredContent, with the same amount as step 2. Sign one of accepts, put the payload in _meta["x402/payment"] and call again: structuredContent is then the same JSON as answer.json.
Questions
How much does it cost?
US$0.002 per page. The script above pays once per URL it is run with.
Does it run JavaScript?
No. It reads the HTML the site serves. A page that builds its content in the browser comes back as js_shell, and you are told so rather than given an empty text.
Can it read a PDF?
Yes, the text layer of a PDF, with no OCR. A scanned PDF comes back as empty. See Fetch a page.