# 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](/docs/fetch). For the same call with `curl` and `jq` step by step, without code, see [Read a web page as clean text](/guides/read-web-page-as-clean-text).

## What you get back

- **Clean text.** `text` is the page's readable text in reading order, as plain text (not Markdown). Tags, scripts and styles are left out.
- **A verdict.** `page.content_kind` says whether the URL gave you real content or a bot challenge, an empty JavaScript shell, a paywall or an error.
- **Flags.** `injection_flags` lists 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](https://vehcdj664efetfrsolne5umanq.srv.us/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:

```sh
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
```

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

```sh
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:

```sh
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:

```js
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:

```sh
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

```sh
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](/guides/read-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:

```sh
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](/privacy)), so keep `answer.json` yourself. To find out later whether the page has changed, send the receipt to [Recheck](/docs/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:

```json
{
  "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:

```sh
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
```

```json
{
  "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](/docs/fetch).
