catalogue / docs / html-clean-text

HTML Clean Text API

Fetches a web page and returns the article as clean text and markdown, with its declared metadata.

It needs the network and a real DOM: boilerplate removal, legacy charset decoding and HTML-to-markdown are three heavy libraries an agent does not carry, and feeding raw HTML to a model instead costs far more in tokens than this call costs.

01Request

HTTP request
POST /v1/html-clean-text
content-type: application/json
payment-signature: <base64 x402 payload>

{
  "url": "https://grist.tools/samples/web/render-article.html",
  "include_links": true,
  "readability": true,
  "max_chars": 2000
}

Without the payment-signature header the call answers 402 with the payment requirements, including this endpoint's price and its max_timeout_seconds, so the deadline is known before anything is paid. The payment is verified before the handler runs and settled only after it succeeds, so a call refused by the gate, or one that fails inside the handler, is never settled. The single case we cannot state for you is a settlement call that fails without answering: the error message says so when that is what happened.

02Call it from code

JavaScript
// html-clean-text: $0.002 per call, paid in USD Coin on eip155:8453 via x402
// Install: npm install @x402/fetch@2 @x402/evm@2 viem@2
// Save as client.mjs (ES module, Node 18+), export EVM_PRIVATE_KEY with the paying wallet's key in your shell, then run: node client.mjs
// The wrapper reads the 402 (payment-required), signs and retries with payment-signature.
import { wrapFetchWithPayment, x402Client, decodePaymentResponseHeader } from '@x402/fetch'
import { ExactEvmScheme } from '@x402/evm/exact/client'
import { privateKeyToAccount } from 'viem/accounts'

const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY)
const client = new x402Client().register('eip155:8453', new ExactEvmScheme(account))
const fetchWithPayment = wrapFetchWithPayment(fetch, client)

const body = {
  "url": "https://grist.tools/samples/web/render-article.html",
  "include_links": true,
  "readability": true,
  "max_chars": 2000
}

const res = await fetchWithPayment('https://grist.tools/v1/html-clean-text', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify(body),
})
console.log(res.status, await res.json())
const settle = res.headers.get('payment-response')
if (settle) console.log(decodePaymentResponseHeader(settle).transaction)
Python
# html-clean-text: $0.002 per call, paid in USD Coin on eip155:8453 via x402
# Install: pip install "x402[requests,evm]>=2.24,<3"
# The session reads the 402 (payment-required), signs and retries with payment-signature.
import os
from eth_account import Account
from x402 import x402ClientSync
from x402.http import decode_payment_response_header
from x402.http.clients.requests import x402_requests
from x402.mechanisms.evm.exact import register_exact_evm_client
from x402.mechanisms.evm.signers import EthAccountSigner

account = Account.from_key(os.environ["EVM_PRIVATE_KEY"])
client = x402ClientSync()
register_exact_evm_client(client, EthAccountSigner(account), networks="eip155:8453")
session = x402_requests(client)

payload = {
    "url": "https://grist.tools/samples/web/render-article.html",
    "include_links": True,
    "readability": True,
    "max_chars": 2000
}

res = session.post("https://grist.tools/v1/html-clean-text", json=payload)
print(res.status_code, res.json())
settle = res.headers.get("payment-response")
if settle:
    print(decode_payment_response_header(settle).transaction)
curl
# html-clean-text: $0.002 per call, paid in USD Coin on eip155:8453 via x402
# 1. Unpaid call: HTTP 402, the requirements in the payment-required header (base64 JSON) and in the body.
curl -i -X POST 'https://grist.tools/v1/html-clean-text' -H 'content-type: application/json' -d '{"url":"https://grist.tools/samples/web/render-article.html","include_links":true,"readability":true,"max_chars":2000}'

# 2. Same call with the signed payment (an EIP-3009 authorization, EIP-712 signed: it cannot be
#    typed by hand). x-payment is accepted as the v1 alternative. HTTP 200 carries payment-response.
curl -i -X POST 'https://grist.tools/v1/html-clean-text' -H 'content-type: application/json' -H 'payment-signature: <base64 x402 payload>' -d '{"url":"https://grist.tools/samples/web/render-article.html","include_links":true,"readability":true,"max_chars":2000}'

03Response

HTTP response
200 OK
payment-response: <base64 settlement receipt>

{
  "requested_url": "https://grist.tools/samples/web/render-article.html",
  "source_url": "https://grist.tools/samples/web/render-article.html",
  "final_url": "https://grist.tools/samples/web/render-article.html",
  "content_type": "text/html",
  "charset": "utf-8",
  "http_status": 200,
  "title": "Measuring Rainfall with a Simple Gauge",
  "byline": "Sample Author",
  "excerpt": "A short guide to reading a cylinder rain gauge at the same hour every day.",
  "site_name": "Grist Samples",
  "lang": "en",
  "lang_source": "html_lang",
  "text": "A cylinder rain gauge is a clear tube with a scale printed on the side. Rain falls through a funnel at the top and collects in the tube, where the water level shows how much fell since the last reading.\nThe most useful habit is consistency. Read the gauge at the same hour every day, ideally in the morning, so that each number covers one full day.\nPlacing the gauge\nPut the gauge in an open spot, away from walls, trees and roofs. A good rule is to keep it at least twice as far from any obstacle as the obstacle is tall.\nMount it level, so the scale reads true.\nKeep the funnel clear of leaves and insects.\nEmpty the tube right after each reading.\nReading the scale\nBend down until your eye is level with the water. The surface curves slightly where it meets the tube; read the number at the bottom of that curve.\nWrite the reading in a notebook with the date and time. A printable schedule helps when several people share the job.",
  "markdown": "A cylinder rain gauge is a clear tube with a scale printed on the side. Rain falls through a funnel at the top and collects in the tube, where the water level shows how much fell since the last reading.\n\nThe most useful habit is consistency. Read the gauge at the same hour every day, ideally in the morning, so that each number covers one full day.\n\n## Placing the gauge\n\nPut the gauge in an open spot, away from walls, trees and roofs. A good rule is to keep it at least twice as far from any obstacle as the obstacle is tall.\n\n-   Mount it level, so the scale reads true.\n-   Keep the funnel clear of leaves and insects.\n-   Empty the tube right after each reading.\n\n## Reading the scale\n\nBend down until your eye is level with the water. The surface curves slightly where it meets the tube; read the number at the bottom of that curve.\n\nWrite the reading in a notebook with the date and time. A [printable schedule](https://grist.tools/samples/web/render-print.html) helps when several people share the job.",
  "word_count": 178,
  "char_count": 933,
  "truncated": false,
  "readability_applied": true,
  "links": [
    {
      "href": "https://grist.tools/samples/web/render-print.html",
      "text": "printable schedule",
      "rel": null,
      "is_internal": true
    }
  ],
  "link_count": 1,
  "fetched_at": "2026-09-01T12:00:00.000Z"
}

04Input schema

The same schema the handler validates against, published from the same source. Also served, free, at/v1/html-clean-text/schema.

JSON Schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "url": {
      "description": "URL of an HTML, XHTML or plain-text page to fetch, up to 2048 characters; redirects are followed, the body is capped at 1 MB, and exactly one of url or html is required.",
      "type": "string",
      "maxLength": 2048,
      "format": "uri"
    },
    "html": {
      "description": "Inline HTML source to clean instead of fetching, up to 262,144 characters; binary content such as PDF or JSON is refused.",
      "type": "string",
      "minLength": 1,
      "maxLength": 262144
    },
    "base_url": {
      "description": "Base URL for resolving relative links in the html input; not allowed together with url, where the final fetched URL is the base.",
      "type": "string",
      "maxLength": 2048,
      "format": "uri"
    },
    "include_links": {
      "default": false,
      "description": "Also return the links inside the extracted content, each with its text, rel and same-host flag, resolved against the fetched URL or base_url when there is one, else kept as authored.",
      "type": "boolean"
    },
    "readability": {
      "default": true,
      "description": "Run Readability to keep only the main article; false, or an article Readability cannot find, converts the whole body, navigation and footer included.",
      "type": "boolean"
    },
    "max_chars": {
      "default": 50000,
      "description": "Maximum length of text and markdown, 200 to 200,000 Unicode code points (default 50,000); a longer result is cut and flagged as truncated.",
      "type": "integer",
      "minimum": 200,
      "maximum": 200000
    }
  },
  "additionalProperties": false
}

05Errors

CodeHTTPWhen
invalid_input400The body does not match the schema on this page. Nothing is charged.
blocked_target403The destination is refused by the network guard: a disallowed address, or, for HTTP requests, a scheme other than http/https, a port other than 80/443, or credentials in the URL.
unreachable_target424The destination you named did not resolve or refused the connection.
upstream_status424The destination you named answered with a status this endpoint cannot use.
upstream_timeout424The destination you named did not answer before the declared timeout for this endpoint expired.
unsupported_content_type415The destination returned a type this endpoint does not handle.
too_large413The response exceeded the size cap while streaming, or decompressed at more than the allowed ratio.
unprocessable422The bytes arrived but could not be processed, for instance a malformed document.
internal500Our fault, and in almost every case nothing is charged. The exceptions come after the call has run and the settlement call was made: the message "The work was done but settlement could not be completed", and the messages starting with "Payment settlement returned". There, whether the payment moved is unknown to us as well. The payment moves at most once: its nonce is single-use and settlement is idempotent, so a retry with it is never charged twice. A retry is normally refused with a 402: replay_consumed, an expiry reason, or a reason saying the nonce was already used once the transfer is on-chain. Only if our server restarted before the transfer was final can a retry run the call again. Even then, the payment moves at most once. To see whether it was settled, read the EIP-3009 authorizationState of its nonce on-chain; to call again, sign a new payment.
rate_limited429Too many calls from the same payer, or too many calls towards the same destination domain. Nothing is charged.
concurrency_limit, registry_at_capacity, upstream_unavailable503We could not serve the call right now. Retryable: wait the number of seconds in the retry-after header, then repeat the call. concurrency_limit and registry_at_capacity mean we were at capacity: the call did not run. You can send the same payment again if its validBefore is still far enough away to cover another call. If it is not, the retry gets a 402 authorization_validity_too_short: sign a new payment. upstream_unavailable means our internal service failed or did not answer in time: the call may already have run and the payment may have been settled. A retry with the same payment can get a 402 whose error field says why. authorization_validity_too_short: too little time is left before validBefore to cover another call. This check comes before the replay check, so it does not tell whether the first payment was settled. replay_consumed (the payment was used and may have been settled) or replay_exhausted (too many attempts): sign a new payment. replay_in_flight: the first call is still running, so wait and do not pay again. invalid_exact_evm_nonce_already_used: the transfer is already on-chain, so this payment cannot move again. Sign a new payment. An authorization can be settled only once, since its nonce is single-use. To see whether it was settled, read the EIP-3009 authorizationState of its nonce on-chain.
misconfigured503The service is not configured to serve calls. The call did not run and nothing is charged. It needs a fix on our side, so there is no retry delay to wait for.

06Questions

What does a call to html-clean-text cost?
$0.002 per call, paid via x402. An unpaid call answers 402 with the price and max_timeout_seconds, so both are known before anything is paid.
How long can a call take?
The handler has 12 s to answer. The 402 envelope declares max_timeout_seconds as 33: that timeout plus payment verification and settlement around it.
What happens if the call fails?
The payment is verified before the handler runs and settled only after it succeeds, so a call that fails inside the handler is not settled. The errors table in section 05 lists each code, its HTTP status and when it is raised.
Where is the input schema?
In section 04 on this page, and served free at /v1/html-clean-text/schema. It is the schema the handler validates against, also published in /openapi.json.

07Guides