A parse endpoint answers "no" more ways than it answers "yes". Before a browser opens, POST https://nanoparse.app/fetch can refuse a request on shape, on policy, on quota, or on the caller's own history — and it also answers 402 when nothing is wrong at all, because 402 is how this endpoint quotes its price.
Those answers need different responses from a client. A 400 on a malformed body will never succeed on retry. A 403 on a paywalled domain will never succeed on retry either, for a different reason, and the body does not say so in the same field. A 402 means either "here are the terms" or "the free tier is spent" depending on a field present in one case and absent in the other.
Everything below was probed against the live endpoint on 2026-10-22 and is reproducible with curl. Twenty-eight requests, all unpaid: the free tier on this host was already spent (free_calls_used: 3, free_calls_limit: 3), so every probe either got a refusal or a price. The tier still read 3 of 3 after the last probe, no job id was ever returned, and nothing was charged. The raw response bodies are committed beside this post.
The same bad request, two answers
The first thing a client author trips over is that the endpoint's answer to an unparseable body depends on a header that has nothing to do with the body.
Four requests, two bodies, with and without a Payment-Signature header:
body header answer
-------------------------- --------------------- ------------------------------------
not json none 402 payment_required
not json Payment-Signature: x 400 invalid json
{} none 402 payment_required
{} Payment-Signature: x 400 url required
Both left-hand bodies are client errors. Without a signature they are answered with the payment challenge instead of an error, and that is deliberate. The deployed source says why:
// ── GET /fetch — unpaid probes get the 402 challenge, never a 404 ──
// Directory/discovery monitors (x402-list.com, Bazaar/CDP preflight) verify a
// resource with a GET or a body-less POST and require 402 + PAYMENT-REQUIRED.
// x402-list.com rejected the NanoParse listing (2026-09-14) because this route
// did not exist and the body-less POST died at the JSON parse below.
The consequence for a client: an unpaid request is a discovery probe whether or not it meant to be, and GET /fetch answers 402 too — never 404, never 405. When a call comes back 402 with no error_code, the request was not rejected. It was quoted.
That single field sorts most of the taxonomy. Seven answer shapes appear across the twenty-eight probes, and four of them carry no error_code at all.
The order, as measured
The checks run in a fixed order, and the order is what makes most refusals free.
This host had zero free calls left when the probes ran. A plain https://example.com answered 402 with error_code: free_tier_exhausted. A blocked Instagram URL from the same host, seconds later, answered 403 domain_blocked — no payment challenge, no charge. Policy is decided before money is asked for, so a request that will be refused is refused without a quote.
The gate, in the order a request meets it:
- Body shape — JSON parse, then
urlpresence. - URL shape — absolute URL,
httporhttpsonly. - Target safety — private, loopback, link-local and IPv6-local hosts.
- Recent failure cache — a URL that rendered under fifty words in the last fifteen minutes.
- Domain policy — a dynamic block list, then a static paywall list.
- Caller reputation — quota and abuse rules, where
429and503live. - Payment — free tier, then the x402 challenge, then the signed payload.
Steps one to five answered in a median of 211 ms for the 400 class and 216 ms for the 403 class. Step seven costs more: the eight calls that reached the exhausted-quota branch took 557 to 1227 ms, because that path reads a counter, logs a payment event and records usage before answering. A refusal is cheap and a price is not.
Four hundred: the shape layer
What gets refused, and with which message:
request status body
not json (with a signature) 400 {"error":"invalid json"}
{} (with a signature) 400 {"error":"url required"}
{"url":"not a url"} 400 {"error":"invalid url"}
{"url":"file:///etc/passwd"} 400 {"error":"invalid url — only http and https are supported"}
{"url":"ftp://example.com/x"} 400 {"error":"invalid url — only http and https are supported"}
{"url":"https://example.com:99999/"} 400 {"error":"invalid url"}
{"url":42} 400 {"error":"invalid url"}
What gets through the shape layer is a longer list than the docs suggest. Every row below reached the payment gate:
{"url":"HTTP://EXAMPLE.COM"} accepted scheme normalised
{"url":" https://example.com "} accepted whitespace trimmed
{"url":"http://example.com"} accepted plain http is allowed
{"url":"https://example.com/" plus 1900} accepted no length limit at the gate
{"url":["https://example.com"]} accepted an array of one stringifies
{"url":"https://example.com","unknown":true} accepted extra keys ignored
The array case is worth knowing even though nobody writes it on purpose. new URL() stringifies its argument, so a one-element array is a valid URL and a two-element array is not. A client that serialises a URL field from a list will sometimes pass validation by accident.
No two-hundred in this section means a request that would never have succeeded. Each one is terminal: the same bytes fail the same way. Retrying a 400 is always a bug.
Four-oh-three: three meanings, one status
The most misleading status on the endpoint. It covers a target that is dangerous to fetch, a domain the service has given up on, and a domain its terms exclude — and the fields that distinguish them are inconsistent.
request body
http://127.0.0.1:8787/ {"error":"invalid or restricted target url"}
http://169.254.169.254/latest/... {"error":"invalid or restricted target url"}
http://[::1]/ {"error":"invalid or restricted target url"}
http://10.0.0.5/ {"error":"invalid or restricted target url"}
https://www.wsj.com/tech {"error":"Paywalled site — blocked per Terms of Service"}
https://www.instagram.com/p/Cx1/ {"error":"domain_blocked","error_code":"domain_blocked",
"message":"This domain is blocked by NanoParse — it
repeatedly returns no parseable content. You were
NOT charged.","not_charged":true}
Only the third one speaks the same dialect as the rest of the API. domain_blocked carries error_code and not_charged. The SSRF refusals carry neither. The paywall refusal carries neither, and it is the only refusal of the set that states a policy without naming it in a field, so a client matching on error_code gets undefined there and has to fall back to the message string.
The recommendation that follows is crude but correct: treat every 403 as terminal, whatever it says. Building a retry on error_code alone does not work, because four of the seven answer shapes do not have one. Match on the status, and use the body for the log line.
One wart is worth publishing alongside the rule. not_charged appeared on exactly one of the twenty-eight responses. None of the twenty-eight was charged — the free tier stayed at 3 of 3 across the whole run — but only the domain-blocked refusal says so in a field a client can branch on.
Four-oh-two: a quote, or a refusal
Two different things wear this status, and one field separates them:
no body, no signature
402 {"error":"payment_required",
"message":"This endpoint requires x402 v2 payment. POST a url with a signed
Payment-Signature header to parse a page."}
valid body, no signature, free tier spent
402 {"error":"payment_required",
"error_code":"free_tier_exhausted",
"message":"This IP has used 3 of 3 free calls. Send $0.005 USDC on Base per
parse to continue.",
"free_calls_used":3,"free_calls_limit":3}
The first is an offer. The second is a refusal with a payment path attached. Both are 402, both are error: "payment_required", and the only structural difference is the presence of error_code and the counters. A client that reads error alone cannot tell "you asked wrong" from "you have used your quota".
A third case in the same family is the one that will bite a wallet integration. An empty Payment-Signature header is treated as no header at all — the request falls back to the free path and gets the quota refusal. A header with unusable content is a different answer entirely:
Payment-Signature: not-base64
400 {"error":"payment_failed",
"error_code":"invalid_payment_payload",
"message":"PAYMENT-SIGNATURE must be a base64url-encoded x402 v2 payment payload.",
"next_step":"Check the payment payload and retry."}
So a wallet that returns an empty string when it fails silently demotes the call to the free tier, where the answer looks like a quota problem. A wallet that returns a string which is not a payment payload gets a 400 that names the problem. The second is easier to debug, which argues for failing loudly in the client that builds the header rather than sending "".
The refusal that is not there
A host that does not resolve is not caught by the gate. https://no-such-host-2026-10-22.invalid/ — a syntactically valid URL on a TLD that cannot exist — got the same 402 with free_tier_exhausted as https://example.com, 827 ms against 1227 ms, with no distinguishable refusal.
That is by design rather than an oversight. The gate validates shape and policy; it does not test reachability. Name resolution happens inside the render, and because settlement runs only after a render succeeds, an unreachable host ends as a failed job that bills nothing. The price of learning that the domain is dead is a job, not a charge.
The practical shape for a client: the refusal layer answers "will this be refused for policy", not "will this page exist". Those are different questions, and only the second one needs the render.
What was not measured
Four branches exist in the deployed source and were deliberately not triggered here, because triggering them means behaving like an abusive client or holding a valid permit:
422 content_insufficient— the fifteen-minute cache of a render that came back under fifty words, measured on 2026-10-13 in Ask Twice.429 abuse_detected— the automated rules, which answer withretry_after: 3600.409 payment_race_rejected— the same payment payload against a URL already rendering, withretry_after: 3.503 quota_unavailable— the counter store being unreachable, answered as a retry rather than a block.
Naming them without measuring them is the honest version. Everything else in this post is a status code and a body captured today.
Sorting the answers
A classifier that reads the fields actually present, with the fallbacks the measurements require:
def classify(status: int, body: dict) -> tuple[str, float | None]:
"""Return (action, retry_after_seconds). Action is one of:
ok, pay, retry_later, fatal."""
code = body.get("error_code")
msg = (body.get("message") or body.get("error") or "").lower()
if status == 200:
return "ok", None
if status == 402:
# An offer has no error_code; a quota refusal does. Both are payment
# paths, and neither is a transport failure.
return "pay", None
if status == 422:
# Cached thin render: the same URL fails again inside 15 minutes.
return "retry_later", 900
if status == 429:
return "retry_later", float(body.get("retry_after") or 3600)
if status == 503:
return "retry_later", 30
if status == 409:
return "retry_later", float(body.get("retry_after") or 3)
if status == 400:
# Bad body, bad URL shape, or a header that is not a payment payload.
if code == "invalid_payment_payload":
return "retry_later", 2
return "fatal", None
if status == 403:
# Three meanings, one status: SSRF, block list, paywall.
# All terminal, all uncharged.
return "fatal", None
return "fatal", None
The 403 branch collapses to one line on purpose: three checks, three messages, one instruction. The only 400 worth another attempt is the one that names a payment payload problem, because that is the one where the request was fine and the header was not.
Two rules survive the code and are worth stating without it. A 402 is never a transport failure, so it must not be handled by a generic retry wrapper — a wrapper that retries on any non-2xx will hammer a payment challenge forever. And nothing in this layer needs a timeout longer than a couple of seconds: the whole refusal set answered inside 472 ms except the calls that reached the quota counter, which topped out at 1227 ms.
What the layer is for
A dozen of the twenty-eight probes were requests no client should send: a loopback address, a metadata endpoint, a file:// URL, a paywalled domain, a body that is not JSON. Each was answered in roughly two hundred milliseconds, before a browser was allocated, before a queue slot was consumed, and before any payment question was asked. That ordering is the useful part of the design: a refusal costs less than a render, so the checks that can refuse cheaply are placed first.
A parse that gets past all of it costs $0.005 USDC on Base, settled after a successful render. A refused parse costs nothing, and the free tier shown at nanoparse.app/quickstart is the balance a client can read before deciding whether to sign at all — the pre-call check covered in Before the Charge.
For the surfaces this gate sits in front of: nanoparse.app · quickstart · the built-in MCP endpoint at nanoparse.app/mcp · the request schema at nanoparse.app/openapi.json.